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.
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:
- You ship an ESP32 that connects to
https://updates.example.com/firmware.bin. - 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.
- Their server returns a
firmware.binthat is a modified version of yours, with a backdoor in the MQTT handler that exfiltrates WiFi credentials. - They also return a SHA-256 hash that matches their modified image.
- 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:
-
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).
-
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 -iand 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_dataon 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-boottutorial covers the chip side; this one covers the payload side.