ESP32: TLS in depth, root CAs, and validating real certificates
Move past setInsecure() and the WiFiClientSecure default that ships in every HTTPS example. Bundle root CAs, validate chains, and pin fingerprints on the ESP32.
The first time I got an ESP32 to talk to a real HTTPS endpoint, I used
wifiClient.setInsecure() because the example did, and the example
worked, so I shipped it. Six months later someone pointed out that the
“secure” in HTTPS was doing nothing on my board, and that an attacker on
the same WiFi could swap the server’s certificate for their own and read
every payload I sent. That was the day I learned how root CAs actually
work on a microcontroller.
This tutorial is what I wish someone had handed me that morning: how
to bundle a root CA, how the ESP32 validates a chain, what
setInsecure() really means, and the patterns I use now (proper
CA bundle, fingerprint pinning, certificate rotation for OTA).
What root CAs are and why they matter
When your browser visits https://example.com, the server hands back
a certificate. That certificate is not signed by itself; it is signed
by an intermediate CA, and that intermediate is signed by a root CA
that your browser already trusts (e.g. ISRG Root X1 for Let’s Encrypt).
The chain looks like this:
Root CA (in your trust store)
-> signs -> Intermediate CA (in the server's cert)
-> signs -> Leaf certificate (example.com)
The browser walks up the chain until it finds a root it trusts. If the chain breaks anywhere (expired cert, wrong intermediate, untrusted root), the connection fails.
The ESP32 has no built-in trust store. Every HTTPS example you’ve ever
copied either uses setInsecure() (accept anything) or hands the
library a single root CA certificate. The right pattern is to bundle the
specific CA you need and let WiFiClientSecure do the chain walk.
The setInsecure() pattern (and when it is wrong)
The minimum-viable HTTPS example looks like this:
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
void setup() {
Serial.begin(115200);
WiFi.begin("ssid", "password");
while (WiFi.status() != WL_CONNECTED) delay(500);
WiFiClientSecure client;
client.setInsecure(); // <-- the footgun
HTTPClient http;
http.begin(client, "https://example.com/api/temperature");
int code = http.GET();
Serial.println(code);
http.end();
}
void loop() {}
setInsecure() tells WiFiClientSecure to skip certificate validation
entirely. Any cert from any server is accepted, including a self-signed
one an attacker generates on the spot. On your home WiFi this might be
fine for a hobby project. The day your device is on a coffee shop
network, an airport network, or any network someone else controls, it
is not fine.
The acceptable cases for setInsecure() are short: prototyping,
hobby builds that never leave your house, and “I need to test if this
endpoint even works before I bother with certs.” If you are shipping a
product, do not ship setInsecure().
The setCACert() pattern (the right way)
The clean fix is to bundle the root CA as a PEM string in your sketch and hand it to the library:
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
// ISRG Root X1 (Let's Encrypt's root). The PEM is the entire thing
// between the BEGIN and END markers, including newlines.
const char* root_ca = R"EOF(
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANbOLggKv+IxTdGNs8/TGFy
0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LI
gAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGW
ynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJw
PmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89T
dexHjKdoKpuSpaoE1opQ5Oj0i3LpkgB2HOaXQ+9O5cQivQ9j5+i6VMWvXlJzr8pxr
Vn0n6h8pUMb8DAw98oB7RGGJKyEXJbuJOiJlvwxJtTsCAwEAAaOB/DCB+TAdBgNVHQ4E
FgQU9Yj02a4XSXN1M7bjWRLKEIxlbnEwgckGA1UdIwSBwTCBvoAU9Yj02a4XSXN1M7bj
WRLKEIxlbnGheKR2MHQxCzAJBgNVBAYTAlVTMSkwJwYDVQQKEyBJbnRlcm5ldCBTZWN1
cml0eSBSZXNlYXJjaCBHcm91cDEVMBMGA1UEAxMMSVNSRyBSb290IFgxghBEAiByVS3t
o6BnLcydXaZWCObfwmCJE3cjB/EyQqfr5rOorJKSGI8NeKYmDnLXItIuZm+o4=
-----END CERTIFICATE-----
)EOF";
WiFiClientSecure client;
void setup() {
Serial.begin(115200);
WiFi.begin("ssid", "password");
while (WiFi.status() != WL_CONNECTED) delay(500);
client.setCACert(root_ca);
// Optional: also verify the hostname matches the cert (off by default!)
client.setHandshakeTimeout(30); // seconds; default is too short
HTTPClient http;
http.begin(client, "https://example.com/api/temperature");
int code = http.GET();
Serial.println(code);
http.end();
}
void loop() {}
Three things to notice:
- The PEM includes the
-----BEGIN CERTIFICATE-----and-----END CERTIFICATE-----markers. The library needs them. setCACert()accepts the root you trust. The intermediate that the server sends is validated against this root automatically.setHandshakeTimeout()defaults to 30 seconds in newer cores but was 5 seconds in older ones. The handshake can take a while on a slow network. Setting it explicitly is a habit worth keeping.
Where do you get the root CA PEM? Open the site in a desktop browser, click the padlock, view the certificate, walk the chain to the root, and export the root as Base64-encoded PEM. Or grab it from the CA’s website (e.g.
https://letsencrypt.org/certs/isrgrootx1.pem).
Fingerprint pinning (the belt and suspenders)
CA validation is good. Pinning the certificate’s SHA-256 fingerprint is better, because if your CA gets compromised (it happens) the attacker still cannot impersonate your server. The pattern is:
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include "cert_pin.h" // const char* expected_pin = "AA:BB:CC:...";
// Use the bundled CA
client.setCACert(root_ca);
// After the handshake, verify the peer cert's fingerprint manually
// (the helper lives in the BearSSL callbacks; see esp32-https-notes
// for the full snippet).
For most projects I stop at setCACert(). For products that ship at
scale or that handle credentials (API tokens, user data), I add a
fingerprint check on top. The cost is one more line of code in a
custom callback; the upside is that the entire CA compromise class of
attack goes away.
Common certificate errors and what they mean
When the handshake fails, the ESP32 prints something like one of these. Here is what they actually mean:
unable to get local issuer certificate(X509_V_ERR_UNABLE_TO_GET_ISSUER_CERT_LOCALLY = 20): the server’s cert chain ends in a root your store does not know. Fix: bundle the right root CA.certificate has expired(X509_V_ERR_CERT_HAS_EXPIRED = 10): the clock on the ESP32 is wrong, or the cert is genuinely past itsnotAfterdate. The ESP32 has no RTC by default, so you need an NTP sync or a battery-backed RTC for cert checks to work at all.hostname mismatch(X509_V_ERR_HOSTNAME_MISMATCH = 62): the cert is forapi.example.combut you connected toexample.com. The ESP32 does not check hostnames by default. Callclient.setHandshakeTimeout(30)and verify in your code.self-signed certificate in certificate chain(X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN = 19): the server’s chain ends in a self-signed root that you did not trust. Either bundle that root, or the server is misconfigured.certificate verify failed(catch-all): the library refuses to print the actual reason by default. Callclient.setHandshakeTimeout(30)and turn on verbose logs to see why (see below).
Time is the silent killer here. An ESP32 that just booted has no idea
what year it is. If you have not called configTime() and synced with
an NTP server, every cert check fails with “expired” or “not yet
valid.” Set the time before doing TLS, or use a battery-backed RTC.
Debugging TLS with verbose logs
When the handshake fails and you do not know why, turn on BearSSL verbose logging:
#include "esp_log.h"
WiFiClientSecure client;
client.setCACert(root_ca);
// Enable verbose SSL logging (the call is on the underlying SSL_CTX,
// not on the WiFiClientSecure object directly)
Serial.setDebugOutput(true); // ESP-IDF verbose logs over the serial port
Then watch the serial output. You will see the exact X509 error code
and which step in the chain walk failed. The codes I listed above
(starting at X509_V_ERR_*) all show up in this output.
Certificate rotation for OTA
A 90-day Let’s Encrypt cert means your device’s trust store has to
re-validate every quarter. With setCACert(root_ca) and a single root
that does not change (ISRG Root X1 has been around for years and
Let’s Encrypt has committed to keeping it around through 2035), this
is not a problem you have to solve. But for self-hosted servers, or
for services that rotate intermediates, plan for it.
The pattern I use: bundle the root, not the intermediate. The intermediate can rotate every quarter; the root is the anchor that does not change. When the intermediate does rotate, your code does not need to change.
When to use ESP-IDF TLS instead
The Arduino WiFiClientSecure wraps mbedTLS (older cores) or BearSSL
(newer cores). It works, it ships with the IDE, and it is what 95% of
projects should use. If you need ECDSA certificates, TLS 1.3, mutual
TLS (client certificates), or hardware crypto acceleration that the
Arduino wrapper does not expose, drop down to ESP-IDF’s esp_tls.
That is a 200-line idf.py setup for what Arduino does in five lines.
Reach for it only when the wrapper is in the way.
What to build next
- A simple HTTPS client that posts JSON to your own server with a pinned fingerprint.
- A mutual-TLS setup where the ESP32 presents a client certificate (covered in the IoT with ESP32 book).
- An OTA update that downloads signed firmware over HTTPS and verifies
it before flashing. The
esp32-ota-signingtutorial is the next step.