How-To-Tutorials · September 4, 2025

How to Implement Secure OTA Firmware Updates on ESP32 with HTTPS

how to implement secure ota firmware updates on esp32 with https

Secure OTA Firmware Updates on ESP32 with HTTPS and Custom Partitions

Shipping an IoT product without OTA update capability is basically shipping a product with an expiration date. Bugs happen, security vulnerabilities get discovered, and features need to be added. The ESP32's OTA system is well-designed -- you get A/B partition switching with automatic rollback, HTTPS verification, and it all fits in the ESP-IDF framework without needing external libraries.

The catch? Getting the partition table, TLS certificates, and update logic all working together takes some careful setup. Here's how to do it right.

Prerequisites

  • Solid C programming skills (you'll be working directly with ESP-IDF APIs)
  • ESP-IDF v5.3+ installed and working on your machine
  • An ESP32 development board with Wi-Fi connectivity
  • A web server to host firmware binaries (even a quick Python HTTP server works for testing, but use a real HTTPS server for production)
  • Basic understanding of TLS/HTTPS and certificate chains

Parts/Tools

  • ESP32 Development Board (any variant -- ESP32, ESP32-S3, ESP32-C6 all support OTA)
  • USB cable for initial flashing
  • Computer with ESP-IDF v5.3+ configured
  • HTTPS-capable web server (local or cloud -- AWS S3, GitHub Releases, or a simple Nginx setup all work)

Steps

  1. Create Your ESP-IDF Project
    1. Start a new project. ESP-IDF ships with an OTA example you can reference, but building from scratch helps you understand every piece:
    2. idf.py create-project secure_ota
      cd secure_ota
    3. You can also copy the example as a starting point:
    4. cp -r $IDF_PATH/examples/system/ota .
  2. Set Up a Custom Partition Table
    1. OTA requires two app partitions (so the device can run from one while writing to the other) plus an OTA data partition that tracks which slot is active. Create partitions.csv in your project root:
    2. # Name,   Type, SubType, Offset,  Size
      nvs,      data, nvs,     ,        0x4000
      otadata,  data, ota,     ,        0x2000
      phy_init, data, phy,     ,        0x1000
      app0,     app,  ota_0,   ,        1536K
      app1,     app,  ota_1,   ,        1536K
    3. Watch out for: the two app partitions must be the same size, and each one needs to be large enough for your firmware binary. If your binary is 1.2 MB and your partition is 1 MB, the OTA write will fail silently partway through. I usually leave at least 20% headroom.
    4. Tell ESP-IDF to use your custom table in menuconfig:
    5. idf.py menuconfig
    6. Go to Partition Table -> select Custom partition table CSV and set the filename to partitions.csv.
  3. Embed the Server's TLS Certificate
    1. For HTTPS verification, the ESP32 needs your server's root CA certificate. Download it and save it as server_cert.pem in your project's main directory.
    2. Embed it in the firmware binary using the ESP-IDF component CMake mechanism. In your main/CMakeLists.txt:
    3. idf_component_register(
          SRCS "main.c"
          INCLUDE_DIRS "."
          EMBED_TXTFILES "server_cert.pem"
      )
    4. This makes the certificate available in code as:
    5. extern const char server_cert_pem_start[] asm("_binary_server_cert_pem_start");
      extern const char server_cert_pem_end[] asm("_binary_server_cert_pem_end");
    6. Tip: for production, consider embedding the root CA cert (e.g., Let's Encrypt's ISRG Root X1) rather than your server's leaf certificate. That way the OTA still works when your server cert renews.
  4. Implement the OTA Update Logic
    1. Include the necessary headers in main.c:
    2. #include "esp_ota_ops.h"
      #include "esp_http_client.h"
      #include "esp_https_ota.h"
      #include "esp_log.h"
      #include "esp_wifi.h"
      #include "nvs_flash.h"
    3. The esp_https_ota API wraps the entire download-and-flash sequence into a single call. Here's the core OTA function:
    4. static const char *TAG = "OTA";
      
      esp_err_t do_ota_update(const char *url) {
          esp_http_client_config_t http_config = {
              .url = url,
              .cert_pem = server_cert_pem_start,
              .timeout_ms = 10000,
              .keep_alive_enable = true,
          };
          
          esp_https_ota_config_t ota_config = {
              .http_config = &http_config,
          };
          
          ESP_LOGI(TAG, "Starting OTA update from %s", url);
          esp_err_t err = esp_https_ota(&ota_config);
          
          if (err == ESP_OK) {
              ESP_LOGI(TAG, "OTA update successful. Rebooting...");
              esp_restart();
          } else {
              ESP_LOGE(TAG, "OTA update failed: %s", esp_err_to_name(err));
          }
          return err;
      }
    5. Wire up your Wi-Fi initialization and trigger the OTA from app_main():
    6. void app_main(void) {
          nvs_flash_init();
          wifi_init_sta();  // Your Wi-Fi connection function
          
          // Trigger OTA -- in production, you'd check a server for new versions first
          do_ota_update("https://your-server.com/firmware/v2.0.bin");
      }
  5. Add Rollback Protection
    1. After a successful OTA and reboot, the new firmware boots from the updated partition. But it's marked as "pending verification" until your code explicitly confirms it works:
    2. // Call this early in app_main() after verifying your app is working
      esp_ota_mark_app_valid_cancel_rollback();
    3. If you don't call this and the device reboots again (from a crash, watchdog, etc.), the bootloader automatically rolls back to the previous firmware. This is your safety net against bricked devices.
    4. A practical pattern: run your startup checks (Wi-Fi connects, sensors respond, etc.), and only mark the firmware as valid once those checks pass.
  6. Build, Flash, and Test
    1. Build and flash the initial firmware:
    2. idf.py build
      idf.py -p /dev/ttyUSB0 flash monitor
    3. To test OTA: make a code change (even just bumping a version string), rebuild, and host the new .bin file on your HTTPS server:
    4. idf.py build
      # Upload build/secure_ota.bin to your server
    5. Trigger the OTA update on the device. Watch the serial monitor -- you should see download progress, then a reboot into the new firmware.

Troubleshooting

  • OTA download fails immediately
    • Check that the URL is correct and the binary is actually accessible (try downloading it with curl from another machine first).
    • Certificate mismatch is the most common cause. Make sure the embedded PEM matches your server's certificate chain. Use openssl s_client -connect your-server.com:443 to inspect what the server presents.
  • Download starts but fails partway through
    • Your firmware binary is probably too large for the OTA partition. Check the binary size with ls -la build/secure_ota.bin and compare against your partition size.
    • Wi-Fi signal strength can cause timeouts on large downloads. Increase the timeout_ms value or move the device closer to the access point during testing.
  • Device boot-loops after OTA
    • This is the rollback mechanism doing its job. The new firmware is crashing before calling esp_ota_mark_app_valid_cancel_rollback(). Flash the device manually and check the crash logs.
  • Partition table errors on flash
    • Make sure your partition offsets don't overlap and the total size doesn't exceed your ESP32's flash capacity (usually 4 MB or 16 MB). Run idf.py partition-table to validate.

Production Considerations

For a shipping product, you'll also want firmware version checking (so devices don't "update" to the same or older version), a signature verification step using secure boot (ESP-IDF supports this via MCUboot or its built-in secure boot v2), and a way to report update status back to your server. The basic OTA mechanism shown here is your foundation -- build those layers on top of it.