|

10 min read

ESP32 Secure Boot and Flash Encryption: A Practical Setup Guide

In the first post we made the case for the two locks (secure boot for integrity, flash encryption for confidentiality) and introduced the one idea that governs everything else: these protections only move forward. You can turn them on, but never turn them off.

Now let’s actually turn them on. We’ll use the ESP32 (specifically the ESP-IDF toolchain) as a working example, because it’s common and its security model is well documented. The reassuring news is that enabling both features comes down to a handful of config flags. The less-fun news is that because every step is permanent, the order you do things in could mean the difference between a sealed device and an expensive paperweight.

eFuses in sixty seconds

The enforcement doesn’t live in software. It lives in eFuses: one-time-programmable bits inside the chip. Think of each as a switch that starts at zero and can be flipped to one exactly once, forever. Burn it and it stays burned through every power cycle, reflash, and factory reset.

A few eFuse regions matter for our purposes:

  • A block holding the digest of your signing key: the chip’s record of “this is the public key whose signatures I trust.”
  • A block holding the flash encryption key, generated on-device and, ideally, never leaving it.
  • The enforcement flags: the bits that say “from now on, require a valid signature” and “from now on, treat flash as encrypted.”

Because they’re one-way, eFuses are what make the whole scheme trustworthy: an attacker with the board in hand can’t roll it back to an open state. And it’s also why the process deserves respect: there’s no undo.

The build side: signing your firmware

On the ESP-IDF side, opting into secure boot and building signed images is mostly configuration. In sdkconfig (or via menuconfig) it looks roughly like this:

CONFIG_SECURE_BOOT=y
CONFIG_SECURE_BOOT_V2_ENABLED=y
CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES=y
CONFIG_SECURE_BOOT_SIGNING_KEY="keys/signing_key.pem"

With that in place, a normal idf.py build produces a signed bootloader and application. Your private key generates the signature, and the chip will later verify it against the key digest burned into its eFuses. That private key is the one thing in this whole system you genuinely cannot afford to mishandle. Lose it and you can’t ship trusted updates ever again; leak it and the trust it represents is gone. (Keeping it safe inside an automated build is its own problem, which we’ll get to at the end of this post.)

Flash encryption is a similar story: a config flag opts in, and the device generates its own encryption key on first boot so the key material never has to exist outside the chip.

What happens on first boot

The first time a properly configured device boots, the bootloader does the irreversible work:

  1. Verifies the signed image against the key you provided.
  2. Burns your signing-key digest into an eFuse and flips the “require valid signature” flag.
  3. Generates a flash encryption key, encrypts the relevant flash regions, and flips the “flash is encrypted” flag.

From that boot forward, the device is sealed: only your signed firmware runs, and its flash is ciphertext. You can confirm it worked by watching the boot log (more on that below).

The sharp edge: the first-boot encryption window

Here’s the part worth slowing down for, because it’s a genuine platform sharp edge. It isn’t obvious from the docs, and it’s one every team should know about before it touches real units.

Why the first-boot window is dangerous

There are two ways to end up with an encrypted device:

  • Encrypt on first boot: flash the device with plaintext, let it encrypt itself the first time it powers up.
  • Pre-encrypt: enable encryption first, then flash the images already encrypted (or let the device encrypt them during the flashing download), so it powers up already sealed.

The first approach has a hidden vulnerability: a window. Encrypting all of flash takes real time, and the “flash is encrypted” enforcement bit gets set at the end of that process. If power drops in the middle, maybe from a bumped cable or a flaky supply, you can land in the worst state. The flash now holds ciphertext, but the bootloader never set the enforcement bit. On the next boot, the ROM reads that ciphertext as if it were plaintext. It sees garbage where a valid header should be, and the device won’t boot. That’s your paperweight.

Closing the window with pre-encryption

The fix is to close the window entirely by pre-encrypting: enable encryption first so the device encrypts as it receives each image during flashing, and powers up already-sealed. There’s no in-between state to get caught in. Same end result, no window.

The broader lesson is about process, not heroics: because these operations are irreversible, characterize them on development kits before you ever run them on production hardware. Keep spare boards on hand specifically to burn, brick, and learn on. That’s not a sign something went wrong. It’s the discipline that lets you find an edge like the first-boot window and design it out before a single production unit is at risk. Cheap dev boards are the least expensive insurance in embedded security work.

DEVELOPMENT vs. RELEASE, and the download-mode question

One more decision that’s easy to skim past and expensive to get wrong: flash encryption runs in one of two modes.

  • Development mode leaves you an escape hatch: the chip keeps serial reflashing available and allows a limited number of plaintext reflashes. It’s the right choice while you’re bringing a design up, because a mistake is recoverable.
  • Release mode is the real lockdown: release mode disables serial download and closes the recovery paths. It’s what you want on shipping units, and it means a mistake is not recoverable over the wire.

ROM download mode is closely related. It’s the low-level path that lets a host talk to the chip’s bootloader over serial. Leaving it enabled keeps recovery and re-provisioning possible; disabling it slams that door for good. A development board typically leaves it open; a hardened production unit closes it. The trap is shipping something that’s “mostly locked down” but still has download mode wide open. The opposite mistake is locking a board so hard during development that you can no longer talk to it. Decide deliberately which units get which posture.

Reading success out of the boot log

You don’t have to guess whether it worked. For once, the device is happy to tell you. On a correctly sealed board, the boot log carries the confirmations you’re looking for:

secure_boot_v2: enabling secure boot v2...
secure_boot_v2: Secure boot permanently enabled
flash_encrypt: flash encryption is enabled (1 plaintext flashes left)
esp_image: Verifying image signature...
secure_boot_v2: Signature verified successfully!

Those lines are your acceptance test. You can see the signature verified, confirm the chip enforces secure boot, and check that encryption is on.If you’re in development mode you’ll also see how many plaintext reflashes remain, a useful reminder of which posture the board is in.

Bonus: signing firmware in CI without leaking your keys

If your builds run in CI (and they should), you hit an immediate tension. The build needs the private signing key to produce trusted firmware, but that key must never land in your source repository or leak into a log.

The pattern that works well:

  1. Store the key as a secret in your CI system, base64-encoded so a multi-line key survives storage intact.
  2. At build time, decode it to a file, build, and delete it.

The decode step is short:

printf '%s' "$SIGNING_KEY_BASE64" | tr -d '[:space:]' | base64 -d > keys/signing_key.pem
chmod 600 keys/signing_key.pem
# ... build ...
rm -f keys/signing_key.pem # cleanup (just in case on ephemeral runners)

A few things that keep this clean and safe:

  • Ephemeral runners are your friend. Hosted CI runners are throwaway VMs destroyed after the job, so the decoded key never persists, but strip it out anyway and keep your artifact upload scoped to just the build outputs.
  • Watch the single-line gotcha. Base64 output can be line-wrapped, and some secret stores mangle multi-line values into spaces on paste. Generating a single-line value (and stripping whitespace on decode, as above) sidesteps a genuinely annoying class of “why won’t it decode” failures.
  • Plan for rotation. The moment the key lives in more than one place (say, two different CI systems), rotation means updating all of them. Write that down before you need it.

The goal is simple to state and easy to get subtly wrong: the key is available to the build, invisible in logs, and gone when the job ends.

The takeaway

None of the individual pieces here are hard. The sdkconfig flags are a copy-paste; the boot log tells you plainly whether it worked. What separates a smooth secure-boot bring-up from a drawer full of bricked boards is the sequencing and the edge cases: pre-encrypting to close the first-boot window, choosing the right encryption mode and download posture per unit, and handling the signing key with care in automation.

Get one board sealed on your bench and you’ve proven the mechanism. The harder, more interesting problem is doing it a few thousand times, reliably, by people who shouldn’t have to know any of this. That’s exactly where we’re headed in the next post.

✨ AI Post Recap

Enabling ESP32 secure boot and flash encryption is mostly config flags in sdkconfig, but the process is one-way, so sequencing matters more than the features themselves. The biggest risk is the first-boot encryption window: if power drops mid-encryption, a device can end up with encrypted flash but no enforcement bit set, bricking it. Pre-encrypting before flashing closes that window entirely. Development mode keeps a recovery path open for bring-up; release mode locks it down for shipping units. A successful setup is confirmed directly in the boot log.


How do you enable secure boot and flash encryption on an ESP32? Set a handful of config flags in sdkconfig (CONFIG_SECURE_BOOT, CONFIG_SECURE_BOOT_V2_ENABLED, and the signing key path), then build normally. On first boot, the bootloader verifies the signed image, burns the key digest and enforcement flags into eFuses, and encrypts flash.

What is the ESP32 first-boot encryption window and why is it dangerous? It’s the gap while flash is being encrypted on first boot. If power drops before the process finishes, the flash can end up encrypted while the enforcement bit never got set, so the next boot reads ciphertext as plaintext and the device won’t boot. Pre-encrypting before flashing avoids this entirely.

Should ESP32 flash encryption ship in development or release mode? Development mode during bring-up, since it keeps serial reflashing available if something goes wrong. Release mode for shipping units, since it disables serial download and closes recovery paths, which is what makes the lockdown real.

One burned eFuse is forever.
Make the first one count.

We handle secure boot, flash encryption, and the tooling to provision it all at scale, so devices leave the line sealed and working.

Updated:

Published: