systemref

OTA Firmware Updates on ESP32 — Architecture and Implementation

H. Maqsood Aug 25, 2026 3 min read esp ota firmware embedded iot
ESP32 OTA uses a dual-partition scheme. The running firmware downloads a new image into the inactive partition, validates it, then sets the boot flag and resets. A failed update rolls back to the previous partition automatically.

OTA on the ESP32 is not a single feature — it is a partition layout decision, a download mechanism, and a rollback policy. Get one of the three wrong and you ship devices that brick themselves on a bad update.


Partition layout

The ESP32 flash is partitioned. For OTA to work, the partition table must include two app partitions (factory or OTA slots) and an OTA data partition. The default OTA table:

# Name,   Type, SubType, Offset,   Size
nvs,      data, nvs,     0x9000,   0x5000
otadata,  data, ota,     0xe000,   0x2000
app0,     app,  ota_0,   0x10000,  0x200000
app1,     app,  ota_1,   0x210000, 0x200000
spiffs,   data, spiffs,  0x410000, 0x1F0000

otadata tracks which partition was last written and whether the boot was verified. app0 and app1 alternate as the active and pending slot. The bootloader reads otadata on every reset to decide which partition to boot.

Set this in menuconfig or sdkconfig:

CONFIG_PARTITION_TABLE_TWO_OTA=y

Each OTA slot must be large enough to hold the compiled firmware binary. If the app grows beyond the slot size, OTA silently fails at the write stage. Check with idf.py size.


Download and write

ESP-IDF provides esp_https_ota for the common case — HTTPS download into the inactive partition:

#include "esp_https_ota.h"

esp_err_t do_firmware_upgrade(const char *url) {
    esp_http_client_config_t http_config = {
        .url = url,
        .cert_pem = server_cert_pem_start, // Root CA for TLS verification
    };

    esp_https_ota_config_t ota_config = {
        .http_config = &http_config,
    };

    esp_err_t ret = esp_https_ota(&ota_config);
    if (ret == ESP_OK) {
        esp_restart();
    }
    return ret;
}

Never skip cert_pem. An OTA mechanism that accepts firmware over unverified HTTPS is a remote code execution vulnerability. The device must authenticate the server, not just encrypt the channel.


Validation and rollback

The ESP32 bootloader implements a three-state boot tracking system per OTA slot:

State Meaning
ESP_OTA_IMG_NEW Written, not yet booted
ESP_OTA_IMG_PENDING_VERIFY Currently booting — not yet confirmed valid
ESP_OTA_IMG_VALID Application confirmed it is healthy
ESP_OTA_IMG_INVALID Failed to confirm — bootloader will not boot this slot again

The application must call esp_ota_mark_app_valid_cancel_rollback() after it has verified successful startup. If the device resets before this call is made, the bootloader marks the slot invalid and boots the previous partition.

#include "esp_ota_ops.h"

void app_main(void) {
    // ... initialize everything ...

    // Confirm connection to server, config load, or any app-level health check
    if (connectivity_check() == OK) {
        esp_ota_mark_app_valid_cancel_rollback();
    } else {
        // Do NOT call mark_valid — next reset will roll back
        ESP_LOGE(TAG, "Health check failed, rollback on reset");
    }
}

This is the rollback mechanism. Call esp_ota_mark_app_valid_cancel_rollback() only after your application has demonstrated it is functional — not at the top of app_main.


Version checking

Reject downgrades at the device. Parse the incoming firmware version before writing:

esp_app_desc_t new_app_info;
esp_https_ota_handle_t ota_handle;

esp_https_ota_begin(&ota_config, &ota_handle);
esp_https_ota_get_img_desc(ota_handle, &new_app_info);

const esp_app_desc_t *running = esp_app_get_description();

if (memcmp(new_app_info.version, running->version, sizeof(new_app_info.version)) == 0) {
    ESP_LOGW(TAG, "Same version — skipping");
    esp_https_ota_abort(ota_handle);
    return ESP_FAIL;
}

Store version as a semver string in version (set in CMakeLists.txt via project_version). Compare numerically, not lexicographically, if you enforce downgrade prevention.


Update server

For a minimal self-hosted update server, serve the .bin file with the correct Content-Type:

# Python — minimal OTA server
from http.server import HTTPServer, BaseHTTPRequestHandler

class OTAHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/firmware.bin':
            with open('build/firmware.bin', 'rb') as f:
                data = f.read()
            self.send_response(200)
            self.send_header('Content-Type', 'application/octet-stream')
            self.send_header('Content-Length', len(data))
            self.end_headers()
            self.wfile.write(data)

HTTPServer(('0.0.0.0', 8080), OTAHandler).serve_forever()

For production: serve from S3 or Cloudflare R2 behind HTTPS. Generate a signed URL per device per update cycle to prevent version rollback or unauthorized firmware injection.