ESP32 certificate pinning, embedding a root CA, and using the ESP-IDF certificate bundle are three different ways to make sure your device talks to the real MQTT broker and not an impostor. This tutorial compares them honestly, explains which one survives certificate renewals, and gives you working Arduino and ESP-IDF code that connects to an MQTT broker on port 8883.
Why Validation Matters on Microcontrollers
Many ESP32 tutorials call client.setInsecure() to "make TLS work". That encrypts the traffic but skips server authentication entirely. An attacker on the network, for example a rogue access point with the same SSID, can present any certificate, receive the device's MQTT username and password, and relay or alter every message. Encryption without authentication protects you only from passive eavesdroppers.
Unlike a laptop, an ESP32 has no operating system CA store. You must decide what the device trusts and put it in firmware. That decision also determines how the device behaves years later when the broker's certificates change, which is why it deserves more thought than copying the first snippet you find.
Three Validation Strategies Compared
| Strategy | What the device trusts | Survives leaf renewal | Survives CA change | Flash cost | Effort |
|---|---|---|---|---|---|
| Embedded root CA | One or two root certificates | Yes | Only if the root stays the same | Small (about 2 KB per PEM) | Low |
| ESP-IDF certificate bundle | Many common public roots | Yes | Usually yes | Larger (tens of KB) | Low |
| Leaf certificate or key pinning | One exact server certificate or key | Often no | No | Tiny | High (needs OTA discipline) |
Embedded Root CA (Recommended Default)
You embed the root certificate that anchors the broker's chain. Any valid certificate issued under that root for the right hostname is accepted, so routine renewals are invisible to the device. This is the right choice for most projects. Strictly speaking it is also a form of pinning: you pin trust to one CA instead of the hundreds a browser trusts.
ESP-IDF Certificate Bundle
ESP-IDF ships a bundle of common root certificates (derived from Mozilla's list) that can be compiled into firmware. The device trusts the same public CAs a browser does. You give up some strictness, but if the broker ever moves to a different public CA, devices keep working.
Leaf or Public Key Pinning
You hard-code the fingerprint of the server's exact certificate or public key. It offers the narrowest trust, but it breaks whenever the server certificate is replaced. With Let's Encrypt certificates, which are short-lived and renewed automatically, the leaf certificate is replaced every couple of months or sooner, and the key may change too. Pinning the leaf of such a broker is a reliable way to take a fleet offline. Only pin leaf keys when you control the server, keep the key stable deliberately, and can push OTA updates before any change.
ESP32 Certificate Pinning to a Root CA with Arduino
The ConnectMQ broker presents a Let's Encrypt certificate. Let's Encrypt's current chains lead to ISRG Root X1 (RSA), and it also operates ISRG Root X2 (ECDSA). Embedding both gives you a narrow trust set with headroom for a future chain change. Download the PEM files from the Let's Encrypt certificates page and paste their full contents, one after the other, into the string below. mbedTLS parses multiple concatenated PEM certificates from one buffer.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>
#include <time.h>
const char* WIFI_SSID = "your-ssid";
const char* WIFI_PASS = "your-password";
const char* MQTT_HOST = "broker.connectmq.com";
const uint16_t MQTT_PORT = 8883;
const char* CLIENT_ID = "esp32-pin-5b21"; // use YOUR_CLIENT_ID on a private broker
const char* TOPIC = "connectmq/demo/5b21/status";
// Pinned trust anchors: paste the full PEMs of ISRG Root X1 and ISRG Root X2
static const char PINNED_ROOTS[] = R"PEM(
-----BEGIN CERTIFICATE-----
...ISRG Root X1...
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
...ISRG Root X2...
-----END CERTIFICATE-----
)PEM";
WiFiClientSecure tlsClient;
PubSubClient mqtt(tlsClient);
void syncClock() {
configTime(0, 0, "pool.ntp.org", "time.google.com");
Serial.print("Waiting for NTP");
while (time(nullptr) < 1700000000) {
delay(300);
Serial.print(".");
}
Serial.println(" done");
}
void connectMqtt() {
while (!mqtt.connected()) {
Serial.print("Connecting to MQTT over TLS... ");
// Private broker: mqtt.connect(CLIENT_ID, "YOUR_USERNAME", "YOUR_PASSWORD")
if (mqtt.connect(CLIENT_ID)) {
Serial.println("connected");
mqtt.publish(TOPIC, "online", true);
} else {
char err[100];
tlsClient.lastError(err, sizeof(err));
Serial.printf("failed, state=%d, tls=%s\n", mqtt.state(), err);
delay(5000);
}
}
}
void setup() {
Serial.begin(115200);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
Serial.println("Wi-Fi connected");
syncClock();
tlsClient.setCACert(PINNED_ROOTS);
mqtt.setServer(MQTT_HOST, MQTT_PORT);
mqtt.setBufferSize(512);
}
void loop() {
if (!mqtt.connected()) connectMqtt();
mqtt.loop();
static unsigned long last = 0;
if (millis() - last > 10000) {
last = millis();
char msg[48];
snprintf(msg, sizeof(msg), "{\"uptime\":%lu}", millis() / 1000);
mqtt.publish(TOPIC, msg);
}
}
The lastError() call is worth keeping in production builds: it turns a vague state=-2 into a readable mbedTLS message such as an expired certificate or an unknown CA. Hostname verification happens automatically because WiFiClientSecure receives the hostname through setServer(), so never replace the hostname with an IP address.
Using the ESP-IDF Certificate Bundle
If you build with ESP-IDF directly, the esp-mqtt component can validate against the built-in bundle. Enable it in menuconfig under Component config, mbedTLS, Certificate Bundle (the default common-CA option is fine), then:
#include "esp_crt_bundle.h"
#include "esp_log.h"
#include "mqtt_client.h"
static const char *TAG = "mqtt_bundle";
static void mqtt_event_handler(void *args, esp_event_base_t base,
int32_t event_id, void *event_data)
{
esp_mqtt_event_handle_t event = event_data;
if (event_id == MQTT_EVENT_CONNECTED) {
ESP_LOGI(TAG, "Connected over TLS");
esp_mqtt_client_publish(event->client,
"connectmq/demo/5b21/idf", "hello", 0, 1, 0);
} else if (event_id == MQTT_EVENT_ERROR) {
ESP_LOGE(TAG, "MQTT error, check TLS and credentials");
}
}
void mqtt_start(void)
{
const esp_mqtt_client_config_t cfg = {
.broker.address.uri = "mqtts://broker.connectmq.com:8883",
.broker.verification.crt_bundle_attach = esp_crt_bundle_attach,
.credentials.client_id = "esp32-idf-5b21",
// .credentials.username = "YOUR_USERNAME",
// .credentials.authentication.password = "YOUR_PASSWORD",
};
esp_mqtt_client_handle_t client = esp_mqtt_client_init(&cfg);
esp_mqtt_client_register_event(client, ESP_EVENT_ANY_ID,
mqtt_event_handler, NULL);
esp_mqtt_client_start(client);
}
Call mqtt_start() after Wi-Fi is connected and SNTP has set the time. To pin a single root in ESP-IDF instead, set .broker.verification.certificate to your embedded PEM string and leave crt_bundle_attach unset. The Espressif ESP-MQTT documentation lists all verification options.
Avoiding a Bricked Fleet
Whatever strategy you choose, design for the day trust anchors change:
- Pin roots, not leaves, unless you own the server key lifecycle.
- Embed a backup anchor, such as a second root from the same CA, so a chain switch does not break connectivity.
- Keep OTA updates independent of MQTT trust, or at least test that the OTA path still works when MQTT TLS fails. Our ESP32 OTA update over MQTT guide shows one approach.
- Sync time before connecting. Depending on the core configuration, mbedTLS checks certificate validity dates, and a clock stuck in 1970 makes every certificate look not yet valid.
- Log TLS errors remotely when a connection eventually succeeds, so you notice validation problems in the field early.
- Track CA announcements. Public CAs announce root and intermediate changes well in advance; put a reminder in your maintenance calendar.
Troubleshooting
state=-2with "X509 - Certificate verification failed": wrong or truncated PEM, or the root you embedded does not anchor the broker's chain. Check withopenssl s_client -connect broker.connectmq.com:8883 -servername broker.connectmq.com -showcerts.- "The certificate validity starts in the future": NTP has not completed.
- Out of memory during handshake: TLS needs roughly tens of kilobytes of heap; free buffers, reduce other allocations, and avoid opening several TLS sockets at once.
- Works on 1883, fails on 8883: a firewall may block 8883. Try secure WebSocket on 8084 if your library supports it.
For more symptoms, see fixing MQTT TLS handshake failures and our ESP32 MQTT over SSL tutorial.
Choosing a Strategy for Your Project
For a hobby project or a small fleet you can reflash by hand, the embedded root CA approach is the clear winner: a few lines of code, small flash cost, and no surprises at certificate renewal. For a commercial product that will sit in the field for years, think about the CA change scenario first. Either embed two roots from the broker's CA, or use the ESP-IDF bundle so devices keep trusting any mainstream public CA, and make sure your OTA path keeps working if MQTT TLS ever fails. Leaf pinning only belongs in systems where you operate the server, deliberately keep its key stable, and have a tested process for rolling out a new pin before the key changes. Whatever you choose, write the decision down next to the firmware version, so whoever maintains the fleet later knows which trust anchors are deployed where.
Why a Managed Broker Simplifies Pinning
Pinning is only as stable as the server's certificate management. With a self-hosted broker, you own renewals, chain choices and key changes, and any mistake shows up as offline devices. A managed MQTT broker like ConnectMQ renews its publicly trusted Let's Encrypt certificate automatically, so devices that pin the Let's Encrypt roots keep connecting through routine renewals. You can prototype with the code above on the public MQTT test broker, then switch to private credentials without touching the TLS code. For the full device-side workflow, start with our ESP32 IoT tutorial.
Get Started with ConnectMQ
Sign up at app.connectmq.com, choose the free Serverless plan with no credit card, and create a broker. Copy the generated username, password and client ID into the sketch above, keep the pinned roots, and your ESP32 will connect on port 8883 with full certificate validation.
Frequently Asked Questions
Is setInsecure() acceptable for testing?
Only on a bench with throwaway credentials. It disables server authentication, so never ship firmware that uses it.
Should I pin the certificate fingerprint of broker.connectmq.com?
No. The server certificate is renewed automatically and its fingerprint changes. Pin the Let's Encrypt root certificates instead.
How much flash does the ESP-IDF certificate bundle use?
It depends on which CA list you select in menuconfig; the full common-CA list costs tens of kilobytes, while a single embedded root PEM costs around two kilobytes.
Does certificate pinning replace a username and password?
No. Pinning authenticates the broker to the device. The device still needs its own credentials so the broker can authenticate it.



