esp32 advanced 30 min

ESP32: sign OTA firmware updates so attackers cannot push their own

The MITM attack on unsigned OTA, why HTTPS alone does not save you, and the signed-update pattern that closes the loop. With anti-rollback and key rotation.

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

I had OTA working on a product for about a year before I thought about signing. The setup was straightforward: ESP32 hits my update server over HTTPS, downloads a new firmware.bin, verifies the SHA-256 hash matches what the server told it to expect, writes to the OTA partition, reboots into the new image. The HTTPS connection is encrypted. The hash check catches tampering. What is the problem?

The problem is that both ends of that conversation trust my update server, and “trust my update server” means trusting whatever server URL I baked into the firmware. If an attacker can swap that URL (e.g. by reflashing the ESP32, by DNS poisoning on the local network, by compromising the server itself), they can serve their own firmware.bin, their own SHA-256, and the device will install it. HTTPS protects the bytes on the wire. It does not protect you from a malicious server you have already decided to trust.

Signing closes the gap. The device holds a public key burned into the firmware at build time; the update payload is signed by the matching private key on the build machine; the device verifies the signature before installing. An attacker who controls the network or the server still cannot produce a valid signature, so the device rejects the update. This tutorial is the workflow I use.

Why unsigned OTA is dangerous (the MITM attack)

Walk through the attack with me:

  1. You ship an ESP32 that connects to https://updates.example.com/firmware.bin.
  2. An attacker on the same WiFi (coffee shop, hotel, conference) runs a DNS spoof or ARP spoof and redirects that hostname to their own server.
  3. Their server returns a firmware.bin that is a modified version of yours, with a backdoor in the MQTT handler that exfiltrates WiFi credentials.
  4. They also return a SHA-256 hash that matches their modified image.
  5. Your device compares the hash it computed against the hash it received from the server. They match. Update installs.

HTTPS protects steps 1-3 from a network eavesdropper. It does not protect step 5 from an attacker who is also the server. The device has no way to know whether the hash it was told to expect came from you or from them.

A signed update makes step 4 impossible. The device holds your public key. The update payload must contain a valid signature from your private key. The attacker does not have your private key, so their payload is rejected at signature-check time, regardless of what HTTPS did or did not do.

Generating a signing key

Use the same espsecure.py tool from the OTA ecosystem:

espsecure.py generate_signing_key --version 2 ota_signing_key.pem

The key is a 3072-bit RSA key (default). Save it as ota_signing_key.pem somewhere outside the repo (a secrets manager, an encrypted USB stick, never git). Treat it like a TLS private key.

You can use the same key for secure boot and OTA, or keep them separate. I keep them separate. Secure boot keys rarely need to rotate; OTA keys may rotate once a year or once a quarter as the device fleet grows. Separate keys means a leaked OTA key does not compromise your ability to ship new secure boot images.

Distributing the public key

The device needs the public half of the key to verify signatures. Two ways to ship it:

  1. Bundle as a C array in the firmware. Compile the PEM into a byte array at build time. The device carries the public key for its whole life. To rotate, you have to push a firmware update that contains the new public key (signed by the old one, of course, otherwise the update is rejected).

  2. Store in NVS / flash. Write the public key to a non-volatile storage region on first boot. Future updates read it from there. Easier to rotate, harder to lose.

For most projects, option 1 (bundled) is fine. Rotation only matters if you suspect the key was leaked, and by then you should be doing a full recall-and-rebuild, not just rotating keys.

#include <pgmspace.h>

// Generated with: openssl rsa -in ota_signing_key.pem -pubout -outform DER | xxd -i
// Replace this with your actual key bytes (2048 bytes for RSA-2048,
// 4224 bytes for RSA-3072).
const uint8_t ota_public_key[] PROGMEM = {
  0x30, 0x82, 0x01, 0x0a, 0x02, 0x82, 0x01, 0x01, 0x00, 0xc4, 0x77, ...
};

const size_t ota_public_key_len = sizeof(ota_public_key);

The ArduinoOTA library (with signing hooks)

The standard ArduinoOTA library does not do signed updates out of the box. You have two options.

Option A: Use esp_https_ota with a signed payload.

The IDF-style esp_https_ota API can verify a signature on the received image. The Arduino IDE exposes this through HTTPUpdate (the HTTPClient ecosystem) with a callback:

#include <WiFi.h>
#include <HTTPUpdate.h>
#include <Update.h>

// Signature verification callback. Return true to install the image,
// false to reject it.
bool verify_signature(uint8_t* payload, size_t len) {
  // Compute SHA-256 of the payload
  uint8_t hash[32];
  mbedtls_sha256_context ctx;
  mbedtls_sha256_init(&ctx);
  mbedtls_sha256_starts(&ctx, 0);   // 0 = SHA-256, not SHA-224
  mbedtls_sha256_update(&ctx, payload, len);
  mbedtls_sha256_finish(&ctx, hash);

  // Verify the signature (assume it was appended to the payload,
  // 384 bytes for RSA-3072)
  const size_t sig_len = 384;
  if (len < sig_len) return false;
  const uint8_t* signature = payload + (len - sig_len);
  const size_t image_len  = len - sig_len;

  // Re-hash just the image portion
  mbedtls_sha256_init(&ctx);
  mbedtls_sha256_starts(&ctx, 0);
  mbedtls_sha256_update(&ctx, payload, image_len);
  mbedtls_sha256_finish(&ctx, hash);

  return mbedtls_rsa_pkcs1_verify(
    &ota_public_key_rsa_ctx,         // populated elsewhere
    MBEDTLS_MD_SHA256,
    hash, 0,
    signature
  ) == 0;
}

void performOTA() {
  WiFiClientSecure client;
  client.setCACert(root_ca);          // TLS validation (see esp32-tls-in-depth)
  // ... fetch the firmware, then before installing:
  if (!verify_signature(firmware_buffer, firmware_len)) {
    Serial.println("Signature check failed, refusing update");
    return;
  }
  Update.write(firmware_buffer, firmware_len);
}

This is more code than ArduinoOTA but it gives you the actual security guarantee.

Option B: Use ArduinoOTA + a wrapper that checks the signature out of band.

For simpler projects, keep ArduinoOTA for the transfer and add a second HTTP call to a separate endpoint that returns just the signature for the version you are about to install. The device fetches firmware.bin, fetches firmware.bin.sig, verifies locally, then installs. The signature endpoint has to be on a different origin (different URL, different TLS cert) from the firmware endpoint, so a compromise of one does not give the attacker both pieces.

I use option B for hobby projects and option A for products.

The partition table for OTA

OTA needs two app partitions so the new image can be written while the old one is still running. The default ESP32 partition scheme already has this. Tools >> Partition Scheme >> “Default 4MB with OTA” gives you:

nvs      0x9000   0x5000
otadata  0xe000   0x2000
app0     0x10000  0x180000
app1     0x190000 0x180000
spiffs   0x310000 0xF0000

otadata is the partition that tracks which app slot to boot next. After a successful OTA, the bootloader flips a flag there. If the new image fails to boot (crash loop within the first few seconds), the bootloader reverts to the old slot.

To see the current partition layout, hold the boot button while plugging in, or check Tools >> Partition Scheme in the IDE.

Rollback protection (the “stay on the working image” feature)

Update has a magic byte you can set in the OTA image to mark it as “valid.” If the new image never sets that byte, the bootloader rolls back on next boot. The pattern is:

void setup() {
  Serial.begin(115200);
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) delay(500);

  // Do your setup. If everything is good and the device is talking
  // to its cloud endpoint, mark this image as valid.
  if (everythingWorks()) {
    Update.setBootValid();   // <-- the line that prevents rollback
  }
}

void loop() {
  // ...
}

If the new image crashes before reaching Update.setBootValid(), the bootloader rolls back on the next boot. The device ends up on the previous image, not stuck in a crash loop. This is the safety net that lets you ship OTA updates without bricking the field.

Anti-rollback bit (the secure boot companion)

If you are using secure boot (see esp32-secure-boot), the chip also supports an anti-rollback counter stored in efuse. Each firmware image can declare “I require firmware version N or later.” The chip will refuse to boot any image whose declared version is lower than what is burned in efuse.

This means an attacker cannot:

  • Capture an old (vulnerable) image from a device in the field
  • Reflash that old image onto an updated device
  • Hope the chip accepts it

The anti-rollback counter increments each time you ship a new version. Burn it via:

espefuse.py --port /dev/ttyUSB0 burn_anti_rollback --max 1

This sets the chip to refuse any image older than version 1. Each new firmware release increments the version number, and a CI step burns the new value into efuse before shipping. For OTA-only flows (no physical access to the device), you can do the same trick by including the version number in the signed payload and having the bootloader check it on every boot.

When something breaks

  • “Signature verification failed” every time. Your key bytes are wrong. Re-export with openssl rsa -in ota_signing_key.pem -pubout -outform DER | xxd -i and confirm the output matches what is in the source. Off-by-one byte truncation will cause this every time.

  • OTA succeeds but the device crashes in a loop. Your rollback protection is working. The new image has a bug. Fix the bug, re-sign, re-push. The device should auto-revert to the old image.

  • The bootloader is stuck on the old slot. You forgot Update.setBootValid() somewhere reachable in the new image, or the new image crashes before reaching it. Reflash over serial to recover.

  • Updating the signing key failed. You cannot push a key rotation over the air unless the new key is signed by the old one. Plan rotations as “ship a firmware update with both old and new public keys, the update prefers the new one, the next OTA uses the new one.” It is doable but not trivial.

What to build next

  • A CI build that runs espsecure.py sign_data on every release artifact and fails the build if signing fails. The signed binary is what you push to the OTA server.
  • A monitoring endpoint on your OTA server that tracks which firmware version each device is on, with alerts for “more than X% of devices failed to apply update N.”
  • The full secure boot + signed OTA pipeline for products shipping into customer hands. The esp32-secure-boot tutorial covers the chip side; this one covers the payload side.