Back to Blog
Embedded Systems

IoT Bootloader Design: Secure Boot, A/B Partitions, and Reliable OTA Recovery

The bootloader is the first code that runs on your IoT device and the last line of defense against bricked devices and firmware tampering. Here is how to design a production bootloader with secure boot, flash encryption, A/B OTA, and anti-rollback protection.

June 25, 2024
14 min read
BootloaderSecure BootOTAESP32

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:

  • 1. Hardware initialization: Clock setup, memory controller configuration, essential peripheral init
  • 2. Firmware validation: Verify that the firmware to be loaded has not been tampered with and is authorized
  • 3. Boot selection: Decide which firmware image to load (factory, OTA slot A, OTA slot B) and handle rollback
  • 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.pem

    Extract 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:

  • 1. Device is running from ota_0 (active)
  • 2. OTA server pushes new firmware
  • 3. Firmware writes to ota_1 (inactive slot)
  • 4. On completion, otadata is updated to mark ota_1 as pending
  • 5. Device reboots
  • 6. Bootloader reads otadata, loads ota_1
  • 7. New firmware runs and calls esp_ota_mark_app_valid_cancel_rollback()
  • 8. otadata marks ota_1 as confirmed active
  • If 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:

  • 1. Manufacturing flash: Use 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.
  • 2. Field recovery: If both OTA partitions are corrupt (two consecutive failed updates with power loss), the bootloader falls back to the factory partition. The factory image must be a minimal, stable release that can bootstrap a fresh OTA download.
  • 3. Debug bypass: During development, maintain a set of devices without eFuses burned. Debug workflow requires JTAG access to the secure boot key material — impossible on production-fused devices.
  • 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.

    ⚡
    Written by CodeCaracal Engineering

    We write from production experience — every technique in our articles has been deployed to real clients. No academic theory.

    Let's Build Together

    Got an IoT challenge?
    We've shipped it.

    Whether you need a fleet to track, a factory to monitor, or a farm to automate — our team has done it before and we'd love to build it with you. Typical response time: under 24 hours.

    No upfront commitment99.9% uptime SLANDA on requestFixed-price options