The Direct Answer: ESP32 JWT Authentication
To implement robust ESP32 JWT authentication, use the ESP32's native mbedtls library to generate an HMAC-SHA256 signature directly on the hardware. Do not rely on third-party Arduino JWT libraries that perform software-based crypto; they cause severe heap fragmentation and stack overflows on memory-constrained microcontrollers. The code below targets the ESP32-WROOM-32 DevKit V1 (and compatible ESP32-S3 variants) running the ESP32 Arduino Core v2.0.14 or newer.
JSON Web Tokens (RFC 7519) require three base64url-encoded parts: a header, a payload, and a signature. By leveraging the ESP32's hardware cryptographic accelerator via Espressif's mbedTLS API, you can sign tokens in under 15 milliseconds without crashing the watchdog timer.
Time to Complete: 45 minutes
Core Concept: HMAC-SHA256 signing, NTP time synchronization, Base64URL encoding.
Hardware & Software Bill of Materials
Before flashing the firmware, ensure your bench matches these exact specifications. Using an older ESP32 core version will result in missing mbedtls headers.
| Component | Exact Variant / Version | Notes |
|---|---|---|
| Microcontroller | ESP32-WROOM-32 DevKit V1 | 38-pin or 30-pin layout. 4MB Flash minimum. |
| Arduino Core | ESP32 by Espressif v2.0.14+ | Required for stable mbedtls integration. |
| Backend / Broker | Node.js / EMQX / AWS IoT | Must support HS256 (HMAC-SHA256) verification. |
| Status LED | Onboard GPIO 2 | Used for connection and signing status. |
Pin Mapping Table
| Function | GPIO Pin | Direction |
|---|---|---|
| Onboard Status LED | GPIO 2 | OUTPUT |
| UART TX (Serial Debug) | GPIO 1 | OUTPUT |
| UART RX (Serial Debug) | GPIO 3 | INPUT |
Step-by-Step: Generating and Sending the JWT
Follow this numbered sequence to ensure your token is generated correctly and accepted by the remote server.
- Sync the RTC via NTP: JWTs rely on
iat(issued at) andexp(expiration) claims. If your ESP32's internal clock is off by more than a few seconds, the backend will reject the token immediately. UseconfigTime()to sync withpool.ntp.org. - Construct the Header and Payload: Format these as strict JSON strings. The header must specify
{"alg":"HS256","typ":"JWT"}. - Base64URL Encode: Standard Base64 uses
+and/, and pads with=. JWT specifications require URL-safe Base64: replace+with-,/with_, and strip all=padding. - Sign with HMAC-SHA256: Pass the concatenated
header.payloadstring and your pre-shared secret key intombedtls_md_hmac(). - Assemble the Final Token: Append the Base64URL-encoded signature to the header and payload, separated by a period (
.).
ESP.getFreeHeap() to ensure you have at least 40KB of contiguous RAM available. Crypto buffers allocated on the stack will trigger a watchdog reset.
The Complete Compilable Code
This code is fully self-contained. It connects to WiFi, syncs NTP, generates a valid HS256 JWT using mbedtls, and prints it to the serial monitor. Copy and paste this directly into your Arduino IDE.
#include <WiFi.h>
#include <time.h>
#include <mbedtls/md.h>
#include <mbedtls/base64.h>
// --- Pin Definitions ---
#define LED_PIN 2
// --- Network & Auth Config ---
const char* ssid = "YourNetworkSSID";
const char* password = "YourNetworkPassword";
const char* jwt_secret = "super_secret_pre_shared_key_12345";
const char* device_id = "esp32_sensor_01";
// NTP Config
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = -5 * 3600; // EST offset
const int daylightOffset_sec = 3600;
// --- Base64URL Helper ---
String base64urlEncode(const unsigned char* data, size_t len) {
size_t b64_len;
mbedtls_base64_encode(NULL, 0, &b64_len, data, len);
unsigned char* b64_buf = (unsigned char*)malloc(b64_len + 1);
if (!b64_buf) return "";
mbedtls_base64_encode(b64_buf, b64_len + 1, &b64_len, data, len);
b64_buf[b64_len] = '\0';
String b64_str = String((char*)b64_buf);
free(b64_buf);
b64_str.replace("+", "-");
b64_str.replace("/", "_");
while (b64_str.endsWith("=")) {
b64_str.remove(b64_str.length() - 1);
}
return b64_str;
}
// --- JWT Generation Function ---
String generateJWT() {
time_t now;
time(&now);
// 1. Header
String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
String b64_header = base64urlEncode((const unsigned char*)header.c_str(), header.length());
// 2. Payload
char payload[256];
snprintf(payload, sizeof(payload), "{\"sub\":\"%s\",\"iat\":%ld,\"exp\":%ld}",
device_id, now, now + 3600); // 1 hour expiration
String b64_payload = base64urlEncode((const unsigned char*)payload, strlen(payload));
// 3. Signature
String signing_input = b64_header + "." + b64_payload;
unsigned char hmac_out[32]; // SHA256 outputs 32 bytes
const mbedtls_md_info_t* md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
int ret = mbedtls_md_hmac(md_info,
(const unsigned char*)jwt_secret, strlen(jwt_secret),
(const unsigned char*)signing_input.c_str(), signing_input.length(),
hmac_out);
if (ret != 0) {
Serial.printf("JWT generation failed: mbedtls_md_hmac returned -0x%04x\n", -ret);
return "";
}
String b64_sig = base64urlEncode(hmac_out, 32);
return signing_input + "." + b64_sig;
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
digitalWrite(LED_PIN, LOW);
Serial.printf("Free heap before WiFi: %d bytes\n", ESP.getFreeHeap());
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWiFi Connected.");
// Sync NTP
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
Serial.print("Waiting for NTP sync");
struct tm timeinfo;
while (!getLocalTime(&timeinfo)) {
delay(500);
Serial.print(".");
}
Serial.println("\nNTP Synced.");
digitalWrite(LED_PIN, HIGH); // LED ON indicates ready
// Generate and print JWT
Serial.printf("Free heap before crypto: %d bytes\n", ESP.getFreeHeap());
String token = generateJWT();
if (token.length() > 0) {
Serial.println("\n--- GENERATED JWT ---");
Serial.println(token);
Serial.println("---------------------");
} else {
Serial.println("Failed to generate JWT.");
}
}
void loop() {
// In a real application, you would regenerate the token
// when it approaches expiration or upon reconnect.
delay(10000);
}
Debugging: Exact Errors and Ranked Causes
When ESP32 JWT authentication fails, the serial monitor or the backend server will throw specific errors. Here is how to diagnose them.
The Exact Error Strings
Device-Side Error: JWT generation failed: mbedtls_md_hmac returned -0x52 (which maps to MBEDTLS_ERR_MD_BAD_INPUT_DATA).
Device-Side Crash: Guru Meditation Error: Core 1 panic'ed (StoreProhibited). Exception was unhandled.
Server-Side Error: JsonWebTokenError: invalid signature or TokenExpiredError: jwt expired.
The First Three Things to Check When It Fails
- NTP Sync Status (Server-Side Expiration): If the server rejects the token immediately with an expiration or "not yet valid" error, your ESP32's RTC is drifting. Verify that
getLocalTime(&timeinfo)returnstruebefore calling the signing function. A missing NTP sync is the #1 cause of JWT rejection. - Base64URL Padding (Server-Side Invalid Signature): Standard Base64 libraries append
=or==padding. JWT strictly forbids this. If your backend throws an invalid signature error, check your encoding helper to ensure all trailing=characters are stripped and+//are replaced. - Heap Fragmentation (Device-Side Crash): The
Guru Meditation Error: StoreProhibitedduring crypto operations almost always means you allocated the Base64 buffer or the HMAC context on the stack instead of the heap, or the heap is too fragmented to allocate the required contiguous block. Always usemalloc()for crypto buffers and check forNULLreturns.
Extending and Simplifying the Build
Depending on your production environment, you may need to scale this architecture up or strip it down.
How to Simplify: If your ESP32 is acting as a dumb sensor in a closed local network and you don't need per-message authentication, generate a single long-lived JWT (e.g., 10-year expiration) on your PC using JWT.io, hardcode the resulting string into your ESP32's PROGMEM, and skip the mbedtls signing entirely. This saves ~40KB of flash and eliminates runtime crypto overhead, though it sacrifices token rotation security.
How to Extend: For enterprise IoT deployments (like AWS IoT Core), HMAC-SHA256 with a shared secret is insufficient. You must extend the build to use ECDSA with a hardware Secure Element like the Microchip ATECC608A. In this architecture, the private key never leaves the secure chip. You would replace the mbedtls_md_hmac call with an I2C command to the ATECC608A, which signs the JWT payload internally and returns the signature over the I2C bus. This prevents key extraction even if the ESP32 firmware is dumped.
Frequently Asked Questions
How to handle ESP32 JWT token expiration automatically?
Do not rely on delay() or simple counters. Store the exp timestamp returned in your payload generation. In your loop() or RTOS task, check the current epoch time against the stored exp value. If current_time >= (exp_time - 300) (refreshing 5 minutes before expiration), trigger the generateJWT() function again and update your MQTT client or HTTP authorization header. Always ensure NTP is still synced before regenerating.
Can I use RSA instead of HMAC-SHA256 for ESP32 JWT authentication?
Yes, but it is highly discouraged for standard ESP32-WROOM modules. RSA-2048 signing requires significantly more RAM and CPU cycles than HMAC-SHA256, often taking over 500ms and causing watchdog resets if not handled in a dedicated FreeRTOS task with an 8KB+ stack. If you must use asymmetric cryptography (RS256 or ES256), use an ESP32-S3 with PSRAM, or offload the signing to a hardware secure element as mentioned in the extension section.
Why does my ESP32 JWT fail verification on the Node.js backend?
The most common culprit is a mismatch in the secret key encoding. In the ESP32 C++ code, the jwt_secret is treated as a raw ASCII string. If your Node.js backend (using jsonwebtoken) is expecting a Base64-encoded secret, or if it's reading the secret from an environment variable that includes a hidden trailing newline character (\n), the HMAC hashes will not match. Ensure the exact byte-for-byte secret is used on both sides, and trim all whitespace from your backend environment variables.






