iiOTESP32 Brasil
Contingency mode: public pages and downloads remain available; login, community, and server data may be unavailable.
Back to tutorial catalog
Ilustração: placa ESP32 enviando dados por conexões cifradas com cadeados até uma nuvem MQTT e um servidor Mosquitto, com um escudo de segurança e um certificado digital
Imagem ilustrativa gerada por IA
AdvancedMQTTReading: 12 min

MQTT with TLS on the ESP32: validate the broker certificate and stop using setInsecure

The setInsecure() example encrypts but accepts any server. Your own CA, Mosquitto on port 8883, NTP time, heap and the negative tests that prove validation works.

Technologies:#ESP32#MQTT#TLS#Mosquitto#WiFiClientSecure#OpenSSL

Hardware requirements:

  • Placa ESP32 ou ESP32-S3
  • Rede Wi-Fi de 2,4 GHz com acesso a um servidor NTP
  • Um broker Eclipse Mosquitto 2.x e o OpenSSL no computador que o administra

Encryption that protects nothing

Search for MQTT with TLS on the ESP32 and the first example you find ends the same way: the code creates a WiFiClientSecure, calls setInsecure() and the connection on port 8883 goes through. The Serial monitor says "connected", the traffic leaves encrypted, and it looks solved.

It is not. setInsecure() turns the cipher on but turns off the check of who is on the other end. The ESP32 accepts the certificate of any server that answers at that address. A tampered router, a rogue access point or a computer sitting in the middle presents its own certificate, your device accepts it, and the MQTT username and password reach whoever built the trap. Encrypting without validating is locking a glass door and leaving the key hanging on it.

TLS gives you three things: confidentiality (nobody reads it), integrity (nobody alters it on the way) and server authentication (you really are talking to your broker). The third is the one setInsecure() throws away, and it is exactly the one that stops a man-in-the-middle attack. This article shows how to get all three, with a certificate of your own and no shortcut.

What changes compared with port 1883

On port 1883, the MQTT CONNECT packet carries the username and password with no protection at all. Any device on the path between the ESP32 and the broker reads both, and the whole payload too. On an isolated bench that is fine. On a shared factory network, a guest Wi-Fi or the internet, it is not.

Port 8883 is MQTT over TLS. The ESP32 first performs the TLS handshake, validates the broker's certificate against a certificate authority (CA) it already knows, and only then sends CONNECT, already inside the encrypted channel.

The four traps that sink the first attempt

1. The clock. A certificate has a start date and an end date, and the ESP32 boots believing it is 1970. With that date every certificate is "not yet valid" and the handshake fails. The fix is to synchronize the time over NTP before trying TLS. With no network path to NTP there is no secure connection, and the firmware has to accept that instead of insisting.

2. The broker's name. The certificate is issued for a name, and the address the firmware connects to must match it. Connecting by IP to a certificate issued for a name fails validation. The safe way is to use a DNS name (or an entry in your router's DNS) and put it in the certificate. A certificate with the IP in its subject alternative names also exists, but support depends on the mbedTLS version in your core, so test before relying on it.

3. The memory. The TLS handshake allocates large buffers, in the tens of KB of heap. In a project that already carries a web server, JSON and libraries, the connection may fail for lack of memory, and the error does not say so clearly. Measure ESP.getFreeHeap() before and after connecting, and leave a margin.

4. What to flash into the firmware. Flash the CA that signed the broker's certificate, not the broker's own certificate. The server certificate expires and gets replaced; the CA lasts for years. If the CA is private you choose its validity, and you must plan its replacement before it expires, because it travels inside the firmware. The way to replace it without visiting the device is in the OTA update article.

Step 1: create your CA and the broker certificate

For an internal network, a private CA does the job and costs nothing. On the computer where you administer the broker, with OpenSSL installed:

code.cpp
cpp
1openssl genrsa -out ca.key 4096
2openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -subj "/CN=IIoT Lab CA" -out ca.crt
3openssl genrsa -out broker.key 2048
4openssl req -new -key broker.key -subj "/CN=broker.lab.local" -out broker.csr
5printf "subjectAltName=DNS:broker.lab.local" > san.ext
6openssl x509 -req -in broker.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out broker.crt -days 825 -sha256 -extfile san.ext

Replace broker.lab.local with the name your ESP32 will use. You now have a pile of new files. Two matter beyond this step: ca.crt, which is public and goes into the firmware, and ca.key, which signs certificates and must not stay on the broker or in a repository. Keep it offline, in a backup only you can reach.

Step 2: configure Mosquitto

Copy ca.crt, broker.crt and broker.key to the server (for example into /etc/mosquitto/certs), with the key readable only by the Mosquitto user. In the configuration file:

code.cpp
cpp
1listener 8883
2cafile /etc/mosquitto/certs/ca.crt
3certfile /etc/mosquitto/certs/broker.crt
4keyfile /etc/mosquitto/certs/broker.key
5allow_anonymous false
6password_file /etc/mosquitto/passwd
7listener 1883 127.0.0.1

The last line keeps the non-TLS port, but only for whoever is on the server itself. Restart the service and prove the certificate is right before touching the ESP32:

code.cpp
cpp
1openssl s_client -connect broker.lab.local:8883 -CAfile ca.crt

At the end of the output you should see Verify return code: 0 (ok). If you see another code, the problem is the certificate or the name, and the ESP32 will only repeat the same error with less information. A second test confirms authentication:

code.cpp
cpp
1mosquitto_pub -h broker.lab.local -p 8883 --cafile ca.crt -u esp32-lab -P SENHA_MQTT -t lab/teste -m ok

No error message means success.

Step 3: the firmware

The pattern is the same as in the MQTT automatic reconnection article: a guard function called on every pass through loop(), with no blocking loop. The difference is one extra guard, the valid-time check.

code.cpp
cpp
1#include <WiFi.h>
2#include <WiFiClientSecure.h>
3#include <PubSubClient.h>
4#include <time.h>
5const char* WIFI_SSID = "REDE_WIFI";
6const char* WIFI_PASSWORD = "SENHA_WIFI";
7const char* MQTT_HOST = "broker.lab.local";
8const uint16_t MQTT_PORT = 8883;
9const char* MQTT_USER = "esp32-lab";
10const char* MQTT_PASSWORD = "SENHA_MQTT";
11const time_t MIN_VALID_EPOCH = 1700000000;
12const char CA_CERT[] = R"EOF(
13-----BEGIN CERTIFICATE-----
14PASTE THE CONTENTS OF ca.crt HERE
15-----END CERTIFICATE-----
16)EOF";
17WiFiClientSecure tlsClient;
18PubSubClient mqtt(tlsClient);
19uint32_t lastAttemptMs = 0;

MIN_VALID_EPOCH is any instant later than the date you wrote the firmware. Any time earlier than that means NTP has not answered yet. Now the connection function:

code.cpp
cpp
1void connectMqtt() {
2 if (mqtt.connected() || WiFi.status() != WL_CONNECTED) return;
3 if (time(nullptr) < MIN_VALID_EPOCH) return;
4 if (millis() - lastAttemptMs < 5000) return;
5 lastAttemptMs = millis();
6 char clientId[24];
7 snprintf(clientId, sizeof(clientId), "esp32-%06X", (uint32_t)(ESP.getEfuseMac() >> 24));
8 if (mqtt.connect(clientId, MQTT_USER, MQTT_PASSWORD, "lab/esp32/status", 1, true, "offline")) {
9 mqtt.publish("lab/esp32/status", "online", true);
10 } else {
11 char tlsErr[100] = "";
12 tlsClient.lastError(tlsErr, sizeof(tlsErr));
13 Serial.printf("MQTT rc=%d tls=[%s] heap=%u\n", mqtt.state(), tlsErr, ESP.getFreeHeap());
14 }
15}

The failure log is the part that saves hours. PubSubClient's rc=-2 only says the network connection failed; the secure client's lastError() adds the mbedTLS reason (certificate verification, out of memory, timeout), and the remaining heap shows whether memory was the cause. The rest of the program:

code.cpp
cpp
1void setup() {
2 Serial.begin(115200);
3 tlsClient.setCACert(CA_CERT);
4 mqtt.setServer(MQTT_HOST, MQTT_PORT);
5 WiFi.mode(WIFI_STA);
6 WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
7 configTime(0, 0, "pool.ntp.org", "time.google.com");
8}
9void loop() {
10 connectMqtt();
11 mqtt.loop();
12}

An honest caveat: the TLS handshake is blocking, and can take from hundreds of milliseconds to a few seconds, depending on the signal and the broker. It is a short, bounded block, very different from the endless loop, but if your project has a time-critical task, run MQTT in a separate FreeRTOS task. And with the watchdog enabled, check that it tolerates that time.

Step 4: what you should see

In the serial monitor, once the time is synchronized, the connection happens on its own and the lab/esp32/status topic becomes "online". To watch it, in another terminal:

code.cpp
cpp
1mosquitto_sub -h broker.lab.local -p 8883 --cafile ca.crt -u esp32-lab -P SENHA_MQTT -t "lab/esp32/#" -v

Then run the test that matters, the negative one. If real validation is on, it must reject what is not yours:

  • Replace the broker name in the firmware with an IP address that is not in the certificate: the connection must fail with a verification error.
  • Change one character of the ca.crt contents in the firmware: the connection must fail.
  • Block NTP (port 123 on the router) and reboot: the firmware must keep waiting without opening a connection, and never connect with the 1970 clock.

If any of these three connects, validation is not active. That is worth more than watching "online" appear.

When something does not work

rc=-2 and an X.509 verification error in lastError(). The certificate was rejected: the flashed CA is not the one that signed the broker's, the name does not match, or the certificate expired. Repeat the openssl s_client check from step 2.

rc=-2 with no text in lastError() and low heap. Not enough memory for the handshake. Remove libraries, shrink buffers or use a module with more RAM.

No attempt shows up in the Serial monitor. The time guard is blocking. Check access to NTP, or point to an NTP server on your own network.

It worked on the bench and failed at the customer. The broker's DNS name does not resolve on their network, or port 8883 is blocked. The firewall is the most common suspect.

It ran for months and stopped. Check the validity of the broker certificate and the CA. The broker certificate expires first and can be renewed on the server without touching the devices, as long as the CA is the same.

What this article does not cover

  • Client certificate (mutual TLS). It gives each ESP32 its own identity instead of a shared password. In Mosquitto these are the require_certificate and use_identity_as_username options; on the ESP32, setCertificate() and setPrivateKey(). It adds work to issue and revoke, and is worth the cost when there are many devices.
  • Per-topic permissions. TLS proves who connects but does not limit what each one publishes. That is the broker's ACL.
  • Protecting the key on the device. The CA is public, but the MQTT password flashed into the firmware is not. The ESP32's flash encryption and secure boot belong here, and deserve an article of their own.

A note on honesty

The code in this article follows the WiFiClientSecure and PubSubClient API of the Arduino-ESP32 core, and the OpenSSL and Mosquitto commands are the standard ones. Even so, it was not compiled or tested on a board at this time. Before taking it to the field, compile it in your environment, run the three negative tests and check the heap with your complete application.

To publish data once you have the secure connection, the starting point is the ESP32 publishing data to Mosquitto article. If the network drops, the local buffer keeps the readings until the connection returns.

See more technical tutorialsIIoT ESP32 Brasil — Practical documentation