ESP32: secure boot and flash encryption, the real product build
Burn efuses, sign firmware, and lock the flash so a stolen ESP32 cannot be cloned. The right way to ship an ESP32 product instead of a hobby board.
I shipped an ESP32 product for two years before turning on secure boot. The reason I put it off was simple: the documentation made it sound like a one-way ticket to a bricked board. It kind of is, but “one-way” is the point. If someone can reflash your board, the firmware you spent six months writing is also theirs. This tutorial is the workflow I use now, including the part where you brick a board on purpose so you know what recovery looks like before it happens on a customer’s desk.
This is for products you ship. If your ESP32 sits on your desk and never leaves, skip the whole thing.
What secure boot actually guarantees
Secure boot does one thing: it makes the chip refuse to boot any firmware that is not signed by a key whose public half is burned into efuse (a one-time programmable region of the chip). If you do not have the private key, you cannot produce a firmware image that the chip will accept. Period.
It does not encrypt your firmware (that is flash encryption, see below). It does not protect the firmware at rest (an attacker with the chip in hand can read the signed image; they just cannot replace it with their own). It does not protect the data your firmware handles (you still need application-layer crypto for that).
What it does do: stop anyone with physical access from putting their own firmware on your device. For most products that is the main threat model, and it is the one secure boot addresses.
The efuse approach
Efuses are bits in the chip that can be flipped from 0 to 1 one time. There is no un-flip. The ESP32 has several efuse blocks:
- BLOCK0: system config (flash voltage, boot mode, etc.)
- BLOCK1: secure boot key (the SHA-256 hash of your signing key)
- BLOCK2: flash encryption key
- BLOCK3: user data (you can use this for anything)
For secure boot, you generate a key pair, compute the SHA-256 hash of the public key, and burn that hash into BLOCK1. The chip then checks every firmware image’s signature against that hash on every boot.
The key pair lives on your build machine (or in CI). It is not stored on the chip. If you lose it, you cannot sign new firmware. Treat it like a TLS private key: versioned, backed up, never checked into git.
Generating keys
The official tool is espsecure.py from the ESP-IDF toolchain.
Arduino users get it when they install the ESP32 board package; the
binary lives in ~/.arduino15/packages/esp32/tools/esp32-arduino-libs-*/
somewhere under tools/espsecure/.
Generate the key:
espsecure.py generate_signing_key --version 2 secure_boot_signing_key.pem
The --version 2 flag is the RSA-PSS scheme. Use it. Version 1
(the legacy scheme) is deprecated.
Back up the resulting secure_boot_signing_key.pem file to three
places (your laptop, an encrypted USB stick, an offline backup).
You cannot rotate this key on a device that is already in the field;
that is what the rollback protection in the OTA tutorial is for.
Flashing via espefuse
With the key in hand, you compute the digest and burn it:
espsecure.py digest_signing_key --key secure_boot_signing_key.pem \
--output signing_key_digest.bin
espefuse.py --port /dev/ttyUSB0 burn_key BLOCK_KEY0 \
signing_key_digest.bin
Two things to notice:
--portpoints to the USB serial port your dev board exposes. On Windows that isCOM3or similar; on macOS it is/dev/cu.usbserial-*; on Linux it is/dev/ttyUSB0.- The
--portargument uses=style here for clarity. The actual flag is--port, which can be-pfor short.
After this command runs, BLOCK1 has the key digest and it cannot be unset. Read back the efuses to confirm:
espefuse.py --port /dev/ttyUSB0 summary
You should see ABS_DONE_0 = 1 (the secure boot “abstract” bit)
and BLOCK_KEY0 = (the hash). Once ABS_DONE_0 is 1, the chip will
not boot unsigned images, ever. If your signing key is wrong or
lost, the only recovery is physical replacement of the chip.
Test on a board you are willing to lose first. I keep a dedicated “secure boot test” board for exactly this. The first time you burn a key and the board refuses to boot, you do not want that moment to be on the unit you need for a demo.
The build flags
In the Arduino IDE, secure boot is enabled per-board via the
“Tools >> Secure Boot” menu. Select “Enabled.” In PlatformIO, set
board_build.secure_boot = enabled in platformio.ini.
For ESP-IDF, set CONFIG_SECURE_BOOT=y and
CONFIG_SECURE_BOOT_V2_ENABLED=y in sdkconfig (or via
idf.py menuconfig).
When you compile, the linker embeds the signature into the boot image. The output binary is what you flash. The ESP32 checks the signature on every boot; if it does not match the burned digest, boot fails.
For OTA updates, the signature check still applies. Each OTA image
needs to be signed with the same key (see the esp32-ota-signing
tutorial). There is no separate OTA key path; it is the same
signing key.
What flash encryption buys you
Secure boot stops tampering. Flash encryption stops reading. With flash encryption enabled, the contents of the flash chip are stored as ciphertext, and the decryption key is also burned into efuse (BLOCK2, separate from the signing key). An attacker who desolders the flash and reads it on a programmer sees encrypted blobs, not your firmware.
The cost: every read of program data has to decrypt on the fly, which is fast on the ESP32 (hardware AES accelerator) but does cost a few CPU cycles per access. For most code it is invisible. For tight inner loops that touch flash constantly, profile.
Flash encryption comes in two flavors:
- Development mode (release): the encryption key is readable
by the firmware, so
espefuse.pycan re-encrypt new images during development. You can keep flashing over the air without re-burning efuses. - Production mode (release): the encryption key is hidden even from the firmware. Once you flash in production mode, the chip will only boot images that were pre-encrypted with that key. Switching from development to production is a one-way efuse burn.
For most products, you develop in development mode, then do one
final “production flash” before shipping each unit. The CI build
that produces release binaries for shipping should set
CONFIG_SECURE_FLASH_ENC_ENABLED=y and
CONFIG_SECURE_FLASH_ENCRYPTION_MODE_RELEASE=y.
OT vs non-OT chips (the hardware distinction)
Older ESP32 modules (the original ESP-WROOM-32) have “non-OT” (non-one-time) flash chips. These have a quirk: the flash itself can be reprogrammed in-circuit, which means an attacker with a flash programmer can swap your encrypted image for their own. The chip will still try to decrypt and run it, but it does not have to be your firmware.
Newer “OT” chips (ESP32-WROVER, ESP32-S2, ESP32-S3, ESP32-C3, and later) have one-time-programmable flash that physically cannot be re-written once encrypted. This is what you want for a product.
If you are sourcing modules for a new design, pay attention to the datasheet’s flash type. If it says “non-OT,” either upgrade the module or accept that flash encryption is a partial defense (still useful, still recommended, but it does not stop a determined attacker with a hot air station).
Production workflow vs development workflow
The workflows are different because efuse burns are permanent.
Development:
- Develop normally on a board without secure boot enabled.
- Test with the secure boot test board before merging to main.
- OTA images get signed; secure boot verifies them on boot.
Production per-unit (the first time):
- Flash a fresh, never-used module.
- Burn the signing key digest into BLOCK1 (once per module type).
- Burn the flash encryption key into BLOCK2 (once per module type).
- Burn ABS_DONE_0 (the secure boot “abstract” bit) and FLASH_CRYPT_CNT to the production maximum.
- Flash the encrypted + signed firmware.
- Verify the board boots and connects to your server.
Per-unit costs after the first one: zero key burning. Just flash the encrypted firmware.
Production for OTA updates:
- Sign the new image with the same signing key.
- Encrypt the new image with the same flash encryption key.
- Push over the OTA channel.
- The chip verifies the signature, decrypts the image, writes to the OTA partition, sets the boot flag, and reboots.
“I bricked my board” recovery
There are three failure modes:
-
Wrong signing key burned, no firmware image is accepted. The chip will not boot. You cannot recover over the serial port because secure boot is doing exactly what it should. The chip has to be replaced.
-
efuse burned with the right key but the flash is empty or corrupt. The chip will not boot because no signed image exists. Reflash the signed image over serial. The image gets verified, decryption happens, the chip boots. Recoverable.
-
Flash encryption mode set to production, but the key is mismatched with the image. Same as case 1: chip will not boot and you cannot recover over the wire. Physical replacement.
The lesson: before you burn efuses, make sure the signed firmware image is on the board. The order matters:
- Flash the signed firmware.
- Verify it boots.
- Burn the efuses.
Not the other way around.
When NOT to use secure boot
If any of these apply, skip it:
- You are building a hobby project that stays on your desk.
- You are still iterating daily on the firmware and want to be able to flash without re-signing every time.
- You do not have a backup of the signing key (you will lose the device the first time efuses get burned wrong).
- Your production run is fewer than 10 units and you are fine accepting the support cost of “anyone can flash this.”
Secure boot is for products. For everything else, plain OTA is plenty.
What to build next
- An OTA pipeline where the firmware image is signed before it goes
to the OTA server. See the
esp32-ota-signingtutorial. - A CI build that runs
espsecure.py sign_dataon every release artifact and refuses to publish if the signature step fails. - A small “secure boot cheat sheet” laminated card for your build station with the efuse order on it. Sounds dumb, saves you at 11pm the first time you forget.