IoT Bootloader Design: Secure Boot, A/B Partitions, and Reliable OTA Recovery
Every IoT device that ships to a customer carries an implicit guarantee: it will keep working, and it will not be hijacked. The bootloader is responsible for both. It validates firmware integrity before execution and manages the OTA update lifecycle that keeps devices current without bricking them.
A poorly designed bootloader — or no bootloader security at all — is a vulnerability that attackers can exploit to run arbitrary code on your device, and an operational risk that turns a failed OTA update into a permanently bricked unit.
Bootloader Responsibilities
A production bootloader does three things:
On ESP32, the first-stage bootloader is immutable ROM code. The second-stage bootloader (which you can configure and extend) handles validation and boot selection.
ESP32 Secure Boot V2: RSA-PSS Signature Verification
Secure Boot V2, available on ESP32-S2, S3, C3, and C6, uses RSA-3072 with PSS padding to sign firmware binaries. The public key hash is burned into eFuses — one-time programmable bits that cannot be changed after provisioning.
Generating signing keys:
Generate RSA-3072 private key (keep this SECRET — offline, HSM ideally)
espsecure.py generate_signing_key --version 2 secure_boot_signing_key.pemExtract and display public key hash (burn this to eFuse)
espsecure.py digest_sbv2_public_key --keyfile secure_boot_signing_key.pem
Signing a firmware binary:
Sign during build (automated in CI/CD)
espsecure.py sign_data --version 2 --keyfile secure_boot_signing_key.pem --output firmware_signed.bin firmware.bin
Enable in sdkconfig:
CONFIG_SECURE_BOOT=y
CONFIG_SECURE_BOOT_V2_ENABLED=y
CONFIG_SECURE_BOOT_SIGNING_KEY="secure_boot_signing_key.pem"
CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES=y
Once enabled and eFuses burned, the bootloader verifies the RSA-PSS signature of every firmware image before loading it. An unsigned or tampered binary is rejected — the device will not boot it under any circumstances.
Critical operational note: Burn secure boot eFuses in a controlled manufacturing environment. Once burned, they cannot be undone. Test extensively with a non-fused development device before production.
Flash Encryption: Protecting Firmware at Rest
Secure boot prevents unsigned firmware from running. Flash encryption prevents firmware from being read off the flash chip via SPI bus sniffing or physical chip extraction — important for IP protection.
CONFIG_FLASH_ENCRYPTION_ENABLED=y
CONFIG_FLASH_ENCRYPTION_MODE_RELEASE=y # Development mode allows plaintext; release does not
In release mode, flash encryption uses AES-256-XTS. The encryption key is generated on-device during first boot and stored in eFuse. It never leaves the chip. OTA updates are transmitted in plaintext over TLS (encrypted in transit) and written to flash, where they are transparently encrypted by the hardware.
Important: Once flash encryption is enabled in release mode, you cannot use esptool.py to reflash the device with a plain binary. All production firmware updates must go through OTA. Plan your manufacturing test and failure recovery process accordingly.
A/B OTA Partition Scheme
The A/B partition scheme is the backbone of reliable OTA updates. Two application partitions exist (ota_0 and ota_1). The otadata partition tracks which slot is active and which is pending.
Partition table: see our partition table guide for full details
ota_0, app, ota_0, 0x110000, 1M,
ota_1, app, ota_1, 0x210000, 1M,
otadata, data, ota, 0xf000, 0x2000,
OTA update flow:
otadata is updated to mark ota_1 as pendingotadata, loads ota_1esp_ota_mark_app_valid_cancel_rollback()otadata marks ota_1 as confirmed activeIf step 7 never happens (new firmware crashes on boot), the bootloader automatically rolls back to ota_0 on next boot.
#include "esp_ota_ops.h"
#include "esp_https_ota.h"void confirm_running_firmware(void) {
// Call this early in app_main after basic sanity checks pass
esp_err_t err = esp_ota_mark_app_valid_cancel_rollback();
if (err != ESP_OK) {
ESP_LOGE("OTA", "Failed to mark firmware valid: %s", esp_err_to_name(err));
// This should never happen — log and continue
}
ESP_LOGI("OTA", "Firmware confirmed valid — rollback cancelled");
}
void app_main(void) {
// Initialize hardware
hardware_init();
// Confirm firmware is working (do this BEFORE connecting to WiFi)
confirm_running_firmware();
// Now start normal operation
wifi_connect();
mqtt_start();
start_sensor_tasks();
}
Critical timing: Call esp_ota_mark_app_valid_cancel_rollback() only after confirming basic functionality — hardware init, watchdog setup. Do not call it before you are sure the device is working. If your application crashes before calling this, the bootloader will roll back to the previous firmware on the next boot, which is exactly the behavior you want.
Anti-Rollback Version Counter
Rollback protection prevents a downgrade attack: an attacker intercepts OTA traffic and replaces a new, patched firmware with an old, vulnerable version. The ESP32 eFuse contains a 32-bit rollback counter. Each bit represents one increment, and bits can only be set (never cleared).
// In your firmware, set the security version
// This is burned into eFuse during OTA confirmation if the new version is higher
CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK=y
CONFIG_BOOTLOADER_APP_SEC_VER=3 // Current security version
When the bootloader loads firmware, it checks the firmware's declared security version against the eFuse counter. If the firmware version is lower than the eFuse counter, the bootloader refuses to load it.
To release a firmware with a new anti-rollback version, increment CONFIG_BOOTLOADER_APP_SEC_VER and recompile. After the device OTAs to the new version and calls esp_ota_mark_app_valid_cancel_rollback(), the bootloader burns the new version into eFuse automatically.
Bootloader Size Constraints
The ESP32 second-stage bootloader must fit in IRAM that is available before flash is mapped. Default limit is 128 KB. If you add significant custom code to the bootloader (custom crypto, extended signature checking), check size:
After build, check bootloader size
ls -la build/bootloader/bootloader.bin
Must be under 128 KB (131,072 bytes)
For most projects, the default bootloader with secure boot and flash encryption enabled is 50–80 KB — well within limits.
Manufacturing and Field Recovery
Design your recovery process before production:
esptool.py to flash the factory partition plus a signed OTA image into ota_0 during manufacturing test. Burn eFuses for secure boot and flash encryption as the final manufacturing step.The combination of secure boot, flash encryption, A/B OTA with rollback, and anti-rollback counters gives you a hardened, field-recoverable firmware update system suitable for security-conscious deployments. For the partition table design that supports this bootloader architecture, see our guide on [ESP32 partition table design](/esp32-partition-table-firmware).
[Contact Code Caracal](/contact) — we build production firmware for clients across 15+ countries.