ESP32 OTA updates without bricking: two slots, app rollback, signed images
Safe OTA on ESP-IDF: ota_0/ota_1 plus otadata, app rollback that makes new firmware pass a self-test, HTTPS download, signature checks, and a 4 MB flash layout.
- Safe OTA needs two app slots; write the new firmware to the slot that is not running and switch only once it is complete and verified
- Enable CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE and call esp_ota_mark_app_valid_cancel_rollback() only after a self-test passes
- If new firmware reboots before confirming itself, the bootloader falls back to the previous version on its own
- On 4 MB flash you do not need a factory slot: drop it and each OTA slot grows from 1 MB to 1,920 KB
Once you have tens or hundreds of devices installed, nobody wants to walk around with a USB cable. OTA (over-the-air) updates firmware over Wi-Fi, but done naively, one image that fails to boot or cannot reach the network leaves the whole fleet stuck until someone collects and reflashes them. This article walks through the structure that lets ESP-IDF OTA fail safely: two slots, rollback, HTTPS, signatures, and the flash layout.
Facts checked against the ESP-IDF v6.1 documentation (stable as of October 2026) for the ESP32. Other chips in the family follow the same model, but the bootloader location and some security options differ.
Why two slots
The rule is never overwrite the program that is running. ESP-IDF therefore uses at least two app slots, ota_0 and ota_1, plus a small otadata partition that records which one to boot.
- The device runs from
ota_0 - It downloads new firmware and writes it to
ota_1chunk by chunk - When complete, the image is validated (checksum, and the signature if enabled), then
otadatais updated to point atota_1 - Reboot into
ota_1
Power can fail any number of times during steps 1-3: otadata still points at the old slot and the device comes back on the old version. otadata is 0x2000 bytes, two sectors holding two copies, so a power cut while writing it is survivable too. If otadata is blank (all 0xFF), the bootloader boots the factory app if there is one, otherwise ota_0.
The remaining hole is step 4: the new image boots but cannot join Wi-Fi, or crashes in a loop from the first second. Two slots alone do not help. You need rollback.
App rollback: new firmware has to prove itself
Enable CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE in menuconfig (off by default), and a freshly updated slot moves through these states:
| State | When | Will the bootloader boot it? |
|---|---|---|
ESP_OTA_IMG_NEW | esp_ota_set_boot_partition() just selected it | once, then it becomes PENDING_VERIFY |
ESP_OTA_IMG_PENDING_VERIFY | first boot, waiting for the app to confirm | a reboot in this state turns it ABORTED and falls back |
ESP_OTA_IMG_VALID | the app called esp_ota_mark_app_valid_cancel_rollback() | yes |
ESP_OTA_IMG_INVALID / ABORTED | self-test failed, or rebooted before confirming | never again |
So new firmware that crashes before confirming, gets reset by the watchdog, or loses power during its first boot is rolled back automatically, without any code of yours running.
Boot-time code, using the functions from the ESP-IDF OTA docs:
#include "esp_ota_ops.h"
static bool self_test(void)
{
// Test what this device MUST be able to do: join Wi-Fi, reach the update server, read its main sensor
return wifi_connected_within_ms(20000) && update_server_reachable();
}
void check_new_firmware(void)
{
const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_img_states_t state;
if (esp_ota_get_state_partition(running, &state) == ESP_OK &&
state == ESP_OTA_IMG_PENDING_VERIFY) {
if (self_test()) {
esp_ota_mark_app_valid_cancel_rollback(); // confirmed, keep this one
} else {
esp_ota_mark_app_invalid_rollback_and_reboot(); // go back to the previous one
}
}
}
wifi_connected_within_ms() and update_server_reachable() are your own application functions.
The best self-test is “can I still receive the next update?” If the device reaches the update server, any other bug can be fixed with another release.
Download over HTTPS, check the version before writing
The esp_https_ota component downloads, writes the next slot and sets the boot partition. The one-call version is esp_https_ota(), but we want to read the image header first so we do not rewrite flash with the version already running, so we use the step-by-step API as in advanced_https_ota_example:
#include <string.h>
#include "esp_https_ota.h"
#include "esp_crt_bundle.h"
#include "esp_ota_ops.h"
#include "esp_app_desc.h"
esp_err_t ota_update(const char *url)
{
esp_http_client_config_t http = {
.url = url, // e.g. https://fw.example.com/sensor.bin
.crt_bundle_attach = esp_crt_bundle_attach, // or .cert_pem = your own server's CA
};
esp_https_ota_config_t cfg = { .http_config = &http };
esp_https_ota_handle_t h = NULL;
esp_err_t err = esp_https_ota_begin(&cfg, &h);
if (err != ESP_OK) return err;
esp_app_desc_t incoming;
err = esp_https_ota_get_img_desc(h, &incoming); // read the header before perform
if (err == ESP_OK &&
strncmp(incoming.version, esp_app_get_description()->version,
sizeof(incoming.version)) == 0) {
esp_https_ota_abort(h); // same version, nothing to write
return ESP_OK;
}
do {
err = esp_https_ota_perform(h); // writes the next slot chunk by chunk
} while (err == ESP_ERR_HTTPS_OTA_IN_PROGRESS);
if (err != ESP_OK || !esp_https_ota_is_complete_data_received(h)) {
esp_https_ota_abort(h);
return ESP_FAIL;
}
err = esp_https_ota_finish(h); // validates the image, sets the boot slot
if (err == ESP_OK) esp_restart();
return err;
}
The version comes from PROJECT_VER at build time. Set it in CMakeLists.txt with set(PROJECT_VER "1.4.2") before including project.cmake; if you do not, the build looks for version.txt, then falls back to git describe. The version field holds up to 32 characters. This example only checks “is it the same?”; to refuse downgrades, parse and compare the numbers yourself, or use anti-rollback (below).
If your server can answer 304 Not Modified, esp_https_ota_begin() returns ESP_ERR_HTTP_NOT_MODIFIED, which is another way to skip a pointless update.
Signed images: HTTPS is not enough
HTTPS only proves you reached the right server. If the server, or the account that uploads files to it, is compromised, a malicious image arrives over HTTPS without complaint. A signature closes that gap because the private key lives on the build machine, not on the server (separating credentials by job applies to signing keys too).
ESP-IDF can verify signatures on OTA without enabling hardware secure boot:
- menuconfig → Security features → App Signing Scheme = RSA
- Enable
CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT(andCONFIG_SECURE_SIGNED_ON_UPDATE_NO_SECURE_BOOT) - Leave “Sign binaries during build” on, and generate a key:
# on the build machine; never commit this file
idf.py secure-generate-signing-key --version 2 secure_boot_signing_key.pem
What the docs say you need to know:
- On ESP32 the RSA (Secure Boot v2) option requires chip revision v3.0 or later; set
CONFIG_ESP32_REV_MINto v3.0 before the option appears - This mode verifies signatures on update, not on every boot, and does not stop someone with physical access writing flash directly. For that you need hardware secure boot
- Anti-rollback (
CONFIG_BOOTLOADER_APP_ANTI_ROLLBACK) blocks installing an older, vulnerable version by storing a security version in eFuse. On ESP32 it can be raised at most 32 times and can never be lowered. Use it when you close a serious hole, not on every release
Worked example: laying out 4 MB of flash
The “Factory app, two OTA definitions” table that ships with ESP-IDF:
| Name | Offset | Size |
|---|---|---|
| nvs | 0x9000 | 0x4000 (16 KB) |
| otadata | 0xD000 | 0x2000 (8 KB) |
| phy_init | 0xF000 | 0x1000 (4 KB) |
| factory | 0x10000 | 1 MB |
| ota_0 | 0x110000 | 1 MB |
| ota_1 | 0x210000 | 1 MB |
The last slot ends at 0x210000 + 0x100000 = 0x310000. A 4 MB chip is 0x400000, leaving 0x400000 − 0x310000 = 0xF0000 = 960 KB that nothing uses, while your app is capped at 1 MB. If idf.py size reports 1.3 MB for your Wi-Fi + TLS + MQTT firmware, this table does not work.
Drop the factory slot and let two slots share the rest. Constraints from the docs: the partition table sits at 0x8000 and takes one sector, so the first partition can start at 0x9000, and app partitions must start at an offset aligned to 0x10000 (64 KB).
# partitions.csv for 4 MB flash
# Name, Type, SubType, Offset, Size
nvs, data, nvs, 0x9000, 0x4000
otadata, data, ota, 0xd000, 0x2000
phy_init, data, phy, 0xf000, 0x1000
ota_0, app, ota_0, 0x10000, 0x1E0000
ota_1, app, ota_1, 0x1F0000, 0x1E0000
storage, data, spiffs, 0x3D0000, 0x30000
Step by step:
- Space from the first app slot to the end of flash: 0x400000 − 0x10000 = 0x3F0000 (4,032 KB)
- Reserve 0x30000 = 192 KB for storage, leaving 0x3C0000 = 3,840 KB
- Split in two: 0x1E0000 = 1,920 KB per slot, almost double the 1 MB default
- Check alignment:
ota_1starts at 0x10000 + 0x1E0000 = 0x1F0000, divisible by 0x10000. OK - Check the end: 0x1F0000 + 0x1E0000 = 0x3D0000, and storage ends at 0x3D0000 + 0x30000 = 0x400000 exactly
In menuconfig, set Partition Table → Custom partition table CSV to this file and Serial flasher config → Flash size to 4 MB, then run idf.py partition-table to print the result and check it once more.
Size every OTA slot for the firmware you will ship in the future, not today’s. The partition table cannot be changed over OTA; that takes a cable.
Common mistakes
- Confirming too early. Calling
esp_ota_mark_app_valid_cancel_rollback()on the first line ofapp_mainswitches rollback off for nothing. Call it after the network and the update server are reachable - A slow self-test. While the slot is PENDING_VERIFY, a watchdog reset or reboot rolls it back immediately. The docs recommend running the test as quickly as possible
- Forgetting that factory is not rolled back. Rollback only moves between OTA slots. If you want a last-resort recovery image, design it yourself
- Updating the whole fleet at once. Ship to one or two devices, watch them pass the self-test and report the new version, then widen. Devices must always be able to phone home (when device IPs change helps there)
- The app outgrowing its slot. Have CI run
idf.py sizeand warn above 80-90% of the slot size, well before the build stops fitting - Keeping the signing key in the repo. A leaked key makes signatures meaningless; a lost key means devices that verify signatures can never accept another update. Back it up somewhere safe, away from the repo
Sources
- Over The Air Updates (OTA) — ESP-IDF Programming Guide
- ESP HTTPS OTA — ESP-IDF Programming Guide
- Partition Tables — ESP-IDF Programming Guide
- Secure Boot v2 (Signed App Verification Without Hardware Secure Boot) — ESP-IDF
- App Version (PROJECT_VER) — ESP-IDF Miscellaneous System APIs
- advanced_https_ota example — espressif/esp-idf on GitHub
FAQ
Do I need a factory partition?
No. With ota_0 and ota_1 present, the bootloader boots the first OTA slot when otadata is empty. A factory image gives you a known-good fallback, but it costs a whole slot, and rollback never returns to it; rollback only moves between OTA slots.
What happens if power fails mid-download?
Nothing bad. The download goes to the slot that is not running and otadata has not changed yet, so the next boot runs the old version. otadata itself is 0x2000 bytes, two sectors holding two copies, so a power cut while it is being written does not corrupt both.
How is a signed image different from HTTPS?
HTTPS proves you are talking to the right server. If that server is compromised, a malicious file arrives over HTTPS just fine. A signature proves the firmware came from whoever holds the private key, which never lives on the server. You want both layers.
Commands and settings were tested on sample systems. Try them on a test machine before production.