Conectar un ESP32 a AWS IoT Core parece sencillo hasta que el dispositivo dice "publicado" y a AWS no llega nada. En esta guía te muestro, paso a paso, cómo lo hice en IHouse, la plataforma de domótica que tengo funcionando en mi casa: un ESP32-S3 conectado a AWS IoT Core por MQTT sobre TLS, que recibe órdenes a través del Device Shadow y controla las luces.
Al final vas a tener un ESP32 que enciende o apaga un LED cuando cambias un valor en la nube, y que reporta su estado real de vuelta. Es la base de cualquier proyecto IoT serio en AWS.

El ESP32-S3 Super Mini que uso en IHouse: USB-C, botones BOOT y RESET, y un LED RGB integrado.
Qué vas a necesitar
- Una placa ESP32. Yo uso un ESP32-S3 para el dispositivo que habla con la nube. Un ESP32-C3 también funciona, pero con un ajuste de memoria que explico en los errores comunes.
- Arduino IDE (o
arduino-cli) con el core de ESP32 instalado. - Dos librerías desde el Library Manager:
- MQTT, de Joël Gähwiler (256dpi/arduino-mqtt).
- ArduinoJson, de Benoît Blanchon, versión 7.
- Una cuenta de AWS y, si prefieres la terminal, la AWS CLI configurada.
¿Por qué esa librería MQTT y no PubSubClient, que es la más popular? Te lo cuento en los errores comunes: fue el problema más difícil de todo el proyecto.
Cómo funciona la conexión
El ESP32 abre una conexión MQTT sobre TLS (puerto 8883) hacia el endpoint de tu cuenta en AWS IoT Core. No hay usuario ni contraseña: el dispositivo se identifica con un certificado X.509 propio, y una política de IoT define exactamente qué puede hacer.
Para intercambiar estado uso el Device Shadow: un documento JSON que AWS guarda por cada dispositivo, con dos partes:
desired: lo que la nube (tu app, un script, un asistente de voz) quiere que pase. Por ejemplo,"led": "on".reported: lo que el dispositivo confirma que realmente pasó.
Cuando desired y reported no coinciden, AWS publica un delta en un tópico reservado, el ESP32 lo recibe, actúa y reporta el nuevo estado. La ventaja frente a tópicos propios: si el dispositivo estaba desconectado, al volver puede pedir el estado pendiente y ponerse al día.
Paso 1: Crear el Thing y su certificado
Un Thing es la representación del dispositivo en AWS IoT. Desde la consola: AWS IoT Core → Manage → All devices → Things → Create things → Create single thing, ponle un nombre (en esta guía, mi-esp32) y elige Auto-generate a new certificate.
Al final, AWS te deja descargar los archivos del certificado una sola vez:
certificate.pem.crt(certificado del dispositivo)private.pem.key(llave privada, no la compartas nunca)AmazonRootCA1.pem(la CA raíz de Amazon)
Si prefieres la terminal, esto hace lo mismo:
aws iot create-thing --thing-name mi-esp32
aws iot create-keys-and-certificate --set-as-active \
--certificate-pem-outfile certificate.pem.crt \
--public-key-outfile public.pem.key \
--private-key-outfile private.pem.key
# Guarda el "certificateArn" que devuelve este comando
curl -o AmazonRootCA1.pem https://www.amazontrust.com/repository/AmazonRootCA1.pem
Paso 2: Una política con el mínimo de permisos
Aquí es donde muchos tutoriales usan "Resource": "*" para que "funcione rápido". No lo hagas: con esa política, cualquiera que obtenga el certificado podría publicar en cualquier tópico de tu cuenta. En mi proyecto la política del dispositivo solo le permite conectarse con su propio nombre y usar los tópicos de su Shadow.
Guarda esto como policy.json, cambiando REGION y CUENTA por los tuyos:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "iot:Connect",
"Resource": "arn:aws:iot:REGION:CUENTA:client/mi-esp32"
},
{
"Effect": "Allow",
"Action": ["iot:Subscribe", "iot:Receive"],
"Resource": [
"arn:aws:iot:REGION:CUENTA:topicfilter/$aws/things/mi-esp32/shadow/update/delta",
"arn:aws:iot:REGION:CUENTA:topic/$aws/things/mi-esp32/shadow/update/delta",
"arn:aws:iot:REGION:CUENTA:topicfilter/$aws/things/mi-esp32/shadow/update/rejected",
"arn:aws:iot:REGION:CUENTA:topic/$aws/things/mi-esp32/shadow/update/rejected",
"arn:aws:iot:REGION:CUENTA:topicfilter/$aws/things/mi-esp32/shadow/get/accepted",
"arn:aws:iot:REGION:CUENTA:topic/$aws/things/mi-esp32/shadow/get/accepted"
]
},
{
"Effect": "Allow",
"Action": "iot:Publish",
"Resource": [
"arn:aws:iot:REGION:CUENTA:topic/$aws/things/mi-esp32/shadow/update",
"arn:aws:iot:REGION:CUENTA:topic/$aws/things/mi-esp32/shadow/get"
]
}
]
}
Fíjate en dos detalles: iot:Subscribe se evalúa sobre topicfilter/... e iot:Receive sobre topic/..., por eso aparecen ambos. Y el client/mi-esp32 obliga a que el dispositivo se conecte usando exactamente ese nombre como client ID.
Crea la política y engánchala al certificado y al Thing:
aws iot create-policy --policy-name mi-esp32-policy \
--policy-document file://policy.json
aws iot attach-policy --policy-name mi-esp32-policy \
--target <certificateArn>
aws iot attach-thing-principal --thing-name mi-esp32 \
--principal <certificateArn>
Paso 3: Obtener el endpoint de tu cuenta
Cada cuenta tiene su propio endpoint de IoT. Lo ves en la consola, en Settings → Device data endpoint, o con:
aws iot describe-endpoint --endpoint-type iot:Data-ATS
Usa siempre el endpoint ATS (el que termina en -ats.iot.REGION.amazonaws.com), que es el que funciona con la CA raíz AmazonRootCA1.
Paso 4: Guardar las credenciales en secrets.h
En la carpeta del sketch crea un archivo secrets.h y pega el contenido de los tres archivos del paso 1. Las comillas R"EOF(...)EOF" son raw strings de C++: permiten pegar el certificado tal cual, con sus saltos de línea.
#pragma once
#define WIFI_SSID "tu-red-wifi"
#define WIFI_PASSWORD "tu-clave-wifi"
#define THING_NAME "mi-esp32"
#define AWS_IOT_ENDPOINT "xxxxxxxxxxxxxx-ats.iot.us-east-1.amazonaws.com"
// Contenido de AmazonRootCA1.pem
static const char AWS_CERT_CA[] PROGMEM = R"EOF(
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
)EOF";
// Contenido de certificate.pem.crt
static const char AWS_CERT_CRT[] PROGMEM = R"EOF(
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
)EOF";
// Contenido de private.pem.key
static const char AWS_CERT_PRIVATE[] PROGMEM = R"EOF(
-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----
)EOF";
Si usas Git, agrega secrets.h a tu .gitignore antes del primer commit. Una llave privada en un repositorio público es una puerta abierta a tu cuenta.
Paso 5: El código del ESP32
Este sketch es una versión simplificada del firmware real de IHouse: se conecta, escucha el Shadow y controla un LED. Cambia LED_PIN por el pin de tu placa.

En el ESP32-S3 Super Mini los números de GPIO están impresos por detrás. El número que ves junto al pin donde conectas el LED es el que va en LED_PIN.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <MQTTClient.h> // Librería "MQTT" de 256dpi (Joël Gähwiler)
#include <ArduinoJson.h> // v7
#include "secrets.h"
#define LED_PIN 2 // ajusta al pin de tu placa
WiFiClientSecure net;
MQTTClient client(2048); // buffer amplio: el documento del Shadow no cabe en el tamaño por defecto
String topicUpdate, topicDelta, topicRejected, topicGet, topicGetAccepted;
void buildTopics() {
String base = String("$aws/things/") + THING_NAME + "/shadow";
topicUpdate = base + "/update";
topicDelta = base + "/update/delta";
topicRejected = base + "/update/rejected";
topicGet = base + "/get";
topicGetAccepted = base + "/get/accepted";
}
void reportLed(const char* state) {
JsonDocument doc;
doc["state"]["reported"]["led"] = state;
char buffer[128];
size_t n = serializeJson(doc, buffer);
client.publish(topicUpdate.c_str(), buffer, n);
}
void applyLed(const char* state) {
digitalWrite(LED_PIN, strcmp(state, "on") == 0 ? HIGH : LOW);
reportLed(state); // confirma a la nube lo que realmente hizo el dispositivo
}
void onMessage(String &topic, String &payload) {
Serial.printf("[%s] %s\n", topic.c_str(), payload.c_str());
if (topic == topicRejected) return; // solo para depurar: AWS explica aquí por qué rechazó un reporte
JsonDocument doc;
if (deserializeJson(doc, payload)) return;
// get/accepted trae el documento completo bajo state.desired;
// update/delta trae solo la diferencia, directo bajo state
JsonVariant state = (topic == topicGetAccepted)
? doc["state"]["desired"].as<JsonVariant>()
: doc["state"].as<JsonVariant>();
const char* led = state["led"];
if (led != nullptr) applyLed(led);
}
void connectWiFi() {
if (WiFi.status() == WL_CONNECTED) return;
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
Serial.print("Conectando a Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println(" listo");
}
void connectAWS() {
while (!client.connected()) {
connectWiFi();
Serial.print("Conectando a AWS IoT Core... ");
if (!client.connect(THING_NAME)) {
Serial.printf("falló (lastError=%d), reintento en 5 s\n", client.lastError());
delay(5000);
continue;
}
Serial.println("conectado");
delay(300); // deja asentar la sesión TLS antes de suscribirse
client.subscribe(topicDelta.c_str());
client.subscribe(topicRejected.c_str());
client.subscribe(topicGetAccepted.c_str());
// Pide el estado actual en vez de esperar a que llegue un delta
delay(200);
client.publish(topicGet.c_str(), "");
}
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
buildTopics();
connectWiFi();
net.setCACert(AWS_CERT_CA);
net.setCertificate(AWS_CERT_CRT);
net.setPrivateKey(AWS_CERT_PRIVATE);
client.begin(AWS_IOT_ENDPOINT, 8883, net);
client.onMessage(onMessage);
connectAWS();
}
void loop() {
client.loop();
delay(10);
if (!client.connected()) connectAWS();
}
Tres decisiones de este código salen directamente de problemas que tuve en el proyecto real:
- El buffer de 2048 bytes. El documento del Shadow que llega en
get/acceptedincluyedesired,reported, metadatos y versión. Con un buffer pequeño, el mensaje simplemente no llega a tu funciónonMessage, sin ningún error visible. - La pausa de 300 ms antes de suscribirse. Deja que la sesión TLS termine de asentarse antes de pedir las suscripciones. Es un margen pequeño que en el firmware real me dio conexiones estables.
- El
publishashadow/getal reconectar. En teoría, AWS vuelve a enviar eldeltapendiente cuando el dispositivo se reconecta, pero en la práctica no me resultó confiable. Pedir el estado completo en cada conexión garantiza que el dispositivo siempre se ponga al día.
Paso 6: Probarlo desde la nube
Sube el sketch, abre el Monitor Serie a 115200 baudios y espera el mensaje conectado. Después cambia el estado deseado.
Desde la consola: AWS IoT Core → Things → mi-esp32 → Device Shadows → Classic Shadow → Edit, y pon:
{
"state": {
"desired": { "led": "on" }
}
}
Desde la terminal (usa tu endpoint ATS en --endpoint-url):
aws iot-data update-thing-shadow --thing-name mi-esp32 \
--endpoint-url https://xxxxxxxxxxxxxx-ats.iot.us-east-1.amazonaws.com \
--cli-binary-format raw-in-base64-out \
--payload '{"state":{"desired":{"led":"on"}}}' \
salida.json
El LED se enciende, y en el Monitor Serie verás llegar el delta. Si vuelves a abrir el Shadow, reported.led también dirá "on": el dispositivo confirmó el cambio y el delta desaparece.
Para ver el tráfico del Shadow sin tocar el ESP32, abre AWS IoT Core → Test → MQTT test client y suscríbete a $aws/things/mi-esp32/shadow/+/accepted. El + es un comodín: recibes las respuestas de update y de get. Cada vez que cambias el estado deseado aparece un mensaje en update/accepted.

Un mensaje real de mi dispositivo en update/accepted (oculté parte del nombre del Thing). En IHouse el Shadow guarda un campo compacto code en lugar de led, pero el flujo es el mismo. El aviso azul solo indica que no se puede publicar en un tópico con comodín; suscribirse sí se puede.
Errores comunes (y los que me pasaron a mí)
"Publiqué y no llegó nada." Con la librería PubSubClient, publish() devolvía éxito en el ESP32-S3, pero el mensaje nunca llegaba a AWS: ni los reportes al Shadow ni las peticiones a shadow/get (0 respuestas en 5 intentos). Descarté la política (otro cliente MQTT con el mismo certificado publicaba sin problema), el QoS y el tamaño del buffer. La causa era la combinación de PubSubClient con WiFiClientSecure en esa placa. Al cambiar a la librería MQTT de 256dpi, con el mismo certificado, los mismos tópicos y el mismo diseño, todo funcionó a la primera.
La conexión se cierra apenas se establece. Casi siempre es la política. Si el client ID no coincide con el de iot:Connect, o el dispositivo publica en un tópico que la política no permite, AWS IoT cierra la conexión sin darte una explicación en el dispositivo. Revisa que THING_NAME coincida exactamente con el recurso client/... de la política.
El Shadow rechaza los reportes. Suscríbete a shadow/update/rejected, como en el código: ahí AWS publica el motivo exacto del rechazo (JSON mal formado, versión desactualizada, etc.). Sin esa suscripción, el error pasa en silencio.
Con un ESP32-C3, AWS cierra la conexión en menos de un segundo. El C3 tiene menos RAM y un solo núcleo, y TLS + MQTT + JSON exprimen el stack por defecto de la tarea loop de Arduino. El síntoma que vi fue la conexión cerrándose con CLIENT_ERROR casi al instante. La solución es ampliar el stack después de todos los #include:
SET_LOOP_TASK_STACK_SIZE(16 * 1024);
Con ese ajuste el C3 funciona, aunque con menos margen. Por eso uso ESP32-S3 para el dispositivo que se conecta a la nube.
El documento del Shadow no puede crecer sin límite. El Shadow clásico admite hasta 8 KB por documento. Si guardas configuración ahí, mantenla compacta.
¿Cuánto cuesta?
Para un proyecto personal, prácticamente nada. AWS IoT Core cobra aproximadamente 1 dólar por millón de mensajes y 0,08 dólares por millón de minutos de conexión (precios de referencia al momento de escribir; revisa la página de precios de AWS para tu región). Un ESP32 conectado las 24 horas suma unos 43.200 minutos al mes: menos de medio centavo de dólar.
En IHouse bajé todavía más el costo con una arquitectura de gateway: solo un ESP32 por casa se conecta a AWS y los demás dispositivos hablan con él por ESP-NOW, sin certificados ni conexiones propias. Te lo cuento en un próximo artículo.
Seguridad: lo mínimo antes de ir a producción
- Un certificado por dispositivo. Si uno se compromete, lo revocas sin afectar a los demás.
- Políticas de mínimo privilegio, como la del paso 2. Nada de
Resource: "*". - La llave privada nunca en el repositorio. Y si fabricas varios dispositivos, no quemes credenciales en el firmware: en IHouse cada dispositivo recibe su identidad después de flashearlo, con un portal de configuración y un código de un solo uso.
Preguntas frecuentes
¿Necesito un servidor propio para controlar el ESP32?
No. AWS IoT Core hace de broker MQTT y guarda el estado en el Device Shadow. Tu app, un script o una función Lambda pueden cambiar el estado deseado y el dispositivo reacciona, sin servidores que mantener.
¿Por qué usar el Device Shadow en lugar de tópicos MQTT propios?
Porque guarda el estado aunque el dispositivo esté desconectado. Con tópicos propios, un mensaje enviado mientras el ESP32 estaba apagado se pierde; con el Shadow, al reconectar el dispositivo pide el estado y se pone al día.
¿Funciona con cualquier ESP32?
Sí, el código es el mismo. Con placas de menos memoria, como el ESP32-C3, agrega el ajuste de stack de la sección de errores. En mi experiencia, un ESP32-S3 da más margen cuando el dispositivo hace TLS, MQTT y JSON al mismo tiempo.
¿Puedo usar PubSubClient?
Mucha gente lo usa sin problemas. En mi caso, con un ESP32-S3 y WiFiClientSecure, los mensajes no llegaban a AWS aunque la librería reportara éxito, y cambiar a la librería MQTT de 256dpi lo resolvió. Si te pasa lo mismo, ya sabes por dónde empezar.