esp32 intermediate 30 min

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.

Code available for: ESP32 ArduinoArduino C
Published Aug 26, 2026

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:

  1. The PEM includes the -----BEGIN CERTIFICATE----- and -----END CERTIFICATE----- markers. The library needs them.
  2. setCACert() accepts the root you trust. The intermediate that the server sends is validated against this root automatically.
  3. 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 its notAfter date. 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 for api.example.com but you connected to example.com. The ESP32 does not check hostnames by default. Call client.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. Call client.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-signing tutorial is the next step.