ESP32 MQTT SSL is the step that turns a hobby sketch into a device you can leave on a real network: credentials and payloads are encrypted, and the ESP32 verifies that it is talking to the genuine broker. In this tutorial you will connect an ESP32 to broker.connectmq.com on port 8883 with WiFiClientSecure and PubSubClient, verify the server with the correct root certificate, sync the clock over NTP, and learn how much memory TLS really costs. You will also get a checklist for the handshake errors almost everyone hits once.
What TLS Adds, and What It Does Not
TLS on MQTT does three things: it encrypts the connection so nobody on the Wi-Fi or the path to the broker can read your username, password or payloads; it protects messages from tampering; and, if you configure it properly, it authenticates the server, so a rogue access point cannot impersonate your broker.
That last part is the one most tutorials skip. Calling setInsecure() gives you encryption without authentication. It is fine for a five-minute experiment and a real weakness in production, because an attacker who can intercept DNS or the network can present any certificate and collect your credentials.
TLS does not replace authentication. On the broker side, each device should still have its own username, password and client ID. For the bigger picture, see the MQTT security best practices guide.
| Port | Protocol | Use |
|---|---|---|
| 1883 | MQTT over TCP | Testing on the public area, never for credentials |
| 8883 | MQTT over TLS | ESP32 and other devices in production |
| 8083 | MQTT over WebSocket | Browser testing |
| 8084 | MQTT over Secure WebSocket (/mqtt) |
Browser apps in production |
Which Root Certificate Does the ESP32 Need?
broker.connectmq.com uses a certificate issued by Let's Encrypt, which chains up to the ISRG Root X1 root. You can check this yourself from any computer with OpenSSL:
openssl s_client -connect broker.connectmq.com:8883 -servername broker.connectmq.com -showcerts </dev/null
The output lists the server certificate for broker.connectmq.com, the Let's Encrypt intermediate that issued it, and a chain that ends at ISRG Root X1, followed by Verify return code: 0 (ok) when your system trusts that root.
The important rule: give the ESP32 the root, not the server certificate. Let's Encrypt server certificates are short-lived and rotate automatically, and intermediates change over time too. A device that pins the leaf certificate or its fingerprint will stop connecting after the next renewal. The ISRG Root X1 certificate is valid until 2035, so embedding it means your firmware survives every routine renewal.
Prerequisites
- An ESP32 board with the esp32 board package by Espressif installed in Arduino IDE 2.x.
- PubSubClient by Nick O'Leary from the Library Manager.
- A ConnectMQ broker: sign up, choose a plan, create a broker and copy the generated username, password and client ID from the portal. The free Serverless plan is enough.
If this is your first ESP32 MQTT sketch, the ESP32 IoT tutorial covers board setup and plain MQTT first; TLS is easier to debug once you know the non-TLS version works.
The Complete ESP32 MQTT SSL Sketch
The sketch below connects Wi-Fi, syncs time, loads the root certificate, connects to port 8883 with credentials, subscribes to a command topic and publishes telemetry every ten seconds.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <time.h>
const char* WIFI_SSID = "YOUR_WIFI";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 8883;
const char* MQTT_CLIENT_ID = "YOUR_CLIENT_ID";
const char* MQTT_USERNAME = "YOUR_USERNAME";
const char* MQTT_PASSWORD = "YOUR_PASSWORD";
const char* TOPIC_TELEMETRY = "devices/esp32-01/telemetry";
const char* TOPIC_COMMAND = "devices/esp32-01/cmd";
// ISRG Root X1 (Let's Encrypt), valid until 2035-06-04
static const char ROOT_CA[] PROGMEM = R"EOF(
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----
)EOF";
WiFiClientSecure net;
PubSubClient mqtt(net);
unsigned long lastAttempt = 0, lastPublish = 0;
void syncTime() {
configTime(0, 0, "pool.ntp.org", "time.google.com");
Serial.print("Waiting for NTP time");
time_t now = time(nullptr);
unsigned long start = millis();
while (now < 1700000000 && millis() - start < 20000) { // any date after 2023
delay(250);
Serial.print(".");
now = time(nullptr);
}
Serial.printf("\nUTC time: %s", ctime(&now));
}
void onMessage(char* topic, byte* payload, unsigned int length) {
Serial.printf("Command on %s: %.*s\n", topic, (int)length, (const char*)payload);
}
bool connectMqtt() {
Serial.printf("Free heap before TLS: %u bytes\n", ESP.getFreeHeap());
if (!mqtt.connect(MQTT_CLIENT_ID, MQTT_USERNAME, MQTT_PASSWORD)) {
char err[128];
int code = net.lastError(err, sizeof(err));
Serial.printf("MQTT state=%d, TLS error %d: %s\n", mqtt.state(), code, err);
return false;
}
Serial.printf("Connected over TLS. Free heap: %u bytes\n", ESP.getFreeHeap());
mqtt.subscribe(TOPIC_COMMAND, 1);
return true;
}
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) { delay(250); Serial.print("."); }
Serial.printf("\nWi-Fi connected, IP %s\n", WiFi.localIP().toString().c_str());
syncTime();
net.setCACert(ROOT_CA);
net.setHandshakeTimeout(15); // seconds
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setCallback(onMessage);
mqtt.setKeepAlive(60);
mqtt.setSocketTimeout(10);
mqtt.setBufferSize(512);
}
void loop() {
if (!mqtt.connected()) {
if (millis() - lastAttempt > 10000) {
lastAttempt = millis();
connectMqtt();
}
return;
}
mqtt.loop();
if (millis() - lastPublish > 10000) {
lastPublish = millis();
char json[96];
snprintf(json, sizeof(json), "{\"uptime\":%lu,\"heap\":%u,\"rssi\":%d}",
millis() / 1000, ESP.getFreeHeap(), WiFi.RSSI());
mqtt.publish(TOPIC_TELEMETRY, json);
}
}
Replace the three credential placeholders with the values from the portal. Because ConnectMQ binds each client ID to the credentials it was issued with, use the generated client ID rather than inventing one, and do not run two devices with the same set. To watch the messages arrive, open the portal's live topic explorer or its in-browser MQTT client and subscribe to devices/#.
Why Time Sync Matters for ESP32 MQTT SSL
A certificate is valid only between its "not before" and "not after" dates. The ESP32 has no battery-backed clock, so after every boot it believes the date is 1 January 1970. Depending on how mbedTLS is configured in your Arduino core version, certificate dates are either checked against that clock, in which case every certificate looks "not yet valid" and the handshake fails, or not checked at all, in which case an expired certificate would be accepted silently. Neither is what you want.
Syncing with NTP before the first TLS connection fixes both cases. It costs a second or two at boot and requires outbound UDP port 123. On networks that block NTP, point configTime() at the local NTP server your firewall allows. Accurate time is also useful in its own right: you can put real timestamps in your telemetry, as described in ESP32 NTP timestamps for MQTT messages.
Memory: What TLS Costs on an ESP32
TLS is the most memory-hungry thing a typical ESP32 sketch does. During the handshake, mbedTLS allocates buffers for incoming and outgoing records, the certificate chain being parsed, and the key exchange. The sketch prints free heap before and after connecting so you can see the effect on your own board and core version.
| Factor | Effect | What to do |
|---|---|---|
| TLS record buffers | Tens of kilobytes held for the life of the connection | Budget for it; avoid a second simultaneous TLS client (for example HTTPS OTA while MQTT is connected) on tight boards |
| Certificate chain parsing | Temporary peak during handshake | Embed only the root you need instead of a large bundle when RAM is tight |
| Heap fragmentation | Long uptime plus many String allocations can leave no large contiguous block |
Prefer fixed char buffers, reconnect less often |
| PSRAM boards (WROVER, many S3) | More headroom | Large buffers can be placed in PSRAM by the core's allocator |
| PubSubClient buffer | setBufferSize(512) costs 512 bytes |
Size to your largest message, see PubSubClient buffer size |
A practical warning sign: if connects start failing after hours or days with memory-allocation errors in lastError(), while a reboot fixes it, you are looking at fragmentation. Logging ESP.getMaxAllocHeap() alongside free heap tells you the largest block still available.
Troubleshooting Checklist
X509 - Certificate verification failed. Wrong root certificate, a typo in the PEM, or the clock is not set. Re-copy the PEM including theBEGINandENDlines and confirm the NTP time printed on the serial monitor.- Handshake times out. The network blocks port 8883, common on corporate and guest Wi-Fi. Test with the OpenSSL command above from a laptop on the same network.
state=4orstate=5after a successful handshake. TLS is fine; the credentials or the client ID are wrong. Check for trailing spaces copied from the portal.- Memory allocation failures in
lastError(). Not enough contiguous heap. Free memory before connecting, avoid concurrent TLS sessions, reduceStringchurn. - Works on 1883 but not 8883. You are still using
WiFiClientinstead ofWiFiClientSecure, orsetServer()points at port 1883. - Connects, then drops every minute or so. Usually a keep-alive or duplicate client ID problem rather than TLS; the reconnect patterns in ESP32 MQTT reconnect logic help here.
Repeated failed logins can trigger IP blocking on the broker, so fix credential errors before leaving a device in a fast retry loop.
Alternatives to Embedding One Root
Embedding ISRG Root X1 is the simplest robust option for a single broker. Two alternatives exist. The Arduino core supports a CA bundle via setCACertBundle(), which lets one firmware trust many public roots at the cost of flash and some setup; this is useful when the same device talks to several HTTPS and MQTTS services. Alternatively, you can store the PEM in a filesystem partition so it can be updated without reflashing code. Avoid certificate fingerprint pinning for any broker that uses automatically renewed certificates.
Production Notes
Encryption protects the link; operating the broker securely is the other half. A fully managed MQTT service handles certificate renewal, multi-tenant isolation and brute-force protection for you, so the firmware only needs the root certificate and its credentials. ConnectMQ serves TLS on port 8883 and Secure WebSocket on 8084 with Let's Encrypt certificates, and the same credentials work from your ESP32, a Python backend or a browser dashboard. If you are weighing self-hosting against a cloud MQTT broker, certificate handling is one of the biggest day-two differences, and the guide to what an MQTT broker does explains the rest of the moving parts.
Get Started with ConnectMQ
Create a free Serverless broker at app.connectmq.com, no credit card required. Copy the generated credentials into the sketch above and your ESP32 is talking TLS on port 8883 within minutes.
Frequently Asked Questions
Is setInsecure() acceptable for ESP32 MQTT over TLS?
Only for quick tests. It encrypts traffic but skips server verification, so an attacker who can redirect your traffic can capture credentials. Use setCACert() with the broker's root certificate in anything you deploy.
Which certificate do I use for broker.connectmq.com?
Use the ISRG Root X1 root certificate, because the broker's certificate is issued by Let's Encrypt. Do not embed the server certificate itself; it renews automatically and your device would stop connecting.
Do I need NTP for TLS on the ESP32?
You should sync time before connecting. Without it, certificate dates are either compared against a 1970 clock and fail, or not checked at all, depending on the core configuration.
How much RAM does TLS need on an ESP32?
Expect tens of kilobytes of heap while a TLS connection is open, with a higher temporary peak during the handshake. Measure it on your board by printing ESP.getFreeHeap() before and after connecting, as the sketch does.



