Most “secure firmware update” tutorials stop at the part that was never hard. They show you the upload — image transfers over BLE or HTTP, the device reboots, the new firmware runs — and then they stop. The upload was never the problem. The hard part is everything that happens around the upload: proving the image is authentic before it ever executes, surviving a bad build without bricking the fleet, and making sure an attacker (or a careless release engineer) can’t roll the device back to a version with a known, exploitable bug.
This post is the deep version. It’s written for engineers who are going to ship a connected product and be on the hook for it for the next five-plus years. We’ll separate three concepts that get blurred together constantly — secure boot, TrustZone, and secure firmware update — then build a production-grade chain of trust on real silicon: STM32U5 and nRF5340 for the TrustZone (Cortex-M33) path, STM32H7 for the no-TrustZone (Cortex-M7) path. The bootloader throughout is MCUboot, because in 2026 rolling your own image-validation and swap machinery is no longer a defensible engineering decision.
Why this matters in 2026: the CRA changed the economics
The EU Cyber Resilience Act entered into force on 10 December 2024. Two dates matter:
- 11 September 2026 — mandatory reporting of actively exploited vulnerabilities and severe incidents begins, including for products already on the market. You get 24 hours for an early warning, 72 hours for a fuller notification.
- 11 December 2027 — full compliance applies to any new product placed on the EU market: secure-by-design, no known exploitable vulnerabilities at ship, and the ability to deliver security fixes across a defined support period.
Non-compliance tops out at the greater of €15 million or 2.5% of global annual turnover. You cannot satisfy “ship security fixes across the support period” without a working OTA path, and you cannot do that safely without secure boot and rollback protection. The CRA didn’t invent any of the engineering below — it just turned it from “good practice” into “the cost of selling into Europe.”
Part 1: Three concepts people conflate
Secure boot (a.k.a. verified boot)
Secure boot answers one question at reset: is the code I’m about to run authentic and unmodified? It’s a chain that starts from an immutable root of trust and verifies a cryptographic signature over each image before transferring control to it. If verification fails, the device refuses to run the image. Secure boot is a boot-time, one-shot property. It says nothing about what happens after main() starts.
TrustZone for Armv8-M
TrustZone is a runtime isolation mechanism. On a Cortex-M33/M23/M55/M85, the silicon partitions memory and peripherals into a Secure and a Non-secure world, with hardware-enforced boundaries (the SAU and IDAU decide which addresses are secure). Non-secure code physically cannot read secure memory; it can only call into the secure world through a narrow, controlled set of entry points.
TrustZone is about protecting secrets and critical services while the device is running — keeping your device private key, your crypto engine, and your secure storage out of reach of a compromised application.
Secure boot and TrustZone are orthogonal. You can have one without the other:
- An STM32H7 (Cortex-M7) has no TrustZone-M. You can still build a perfectly solid secure boot chain on it. What you don’t get is hardware runtime isolation of your keys from your application.
- An STM32U5 / nRF5340 (Cortex-M33) gives you both. Secure boot brings up a Secure world that then keeps your keys isolated for the entire runtime.
If you remember one thing from this section: secure boot proves the code is genuine; TrustZone keeps the code honest after it starts. They solve different problems.
Secure firmware update (OTA)
This is the lifecycle piece: getting a new, authentic image onto the device, switching to it atomically, and recovering automatically if it’s bad. Secure boot is a prerequisite — it’s what verifies the downloaded image — but the update problem adds its own hard parts: partitioning, atomic swap, confirm/revert, and rollback protection.
Part 2: The chain of trust
Secure boot is a chain because each link verifies the next. The chain is only as strong as its anchor, and the anchor has to be something an attacker with physical access and a debugger cannot rewrite.
┌─────────────────────────────────────────────────────────────┐
│ Immutable Root of Trust │
│ (bootROM / locked first-stage bootloader, in OTP or │
│ write-protected flash; holds or hashes the public key) │
└───────────────────────────┬─────────────────────────────────┘
│ verifies signature of ↓
┌───────────────────────────▼─────────────────────────────────┐
│ MCUboot (BL2) — the secure bootloader │
│ Validates the application image's hash + signature, │
│ enforces rollback counter, selects/swaps slots │
└───────────────────────────┬─────────────────────────────────┘
│ boots only if verification passes ↓
┌───────────────────────────▼─────────────────────────────────┐
│ Application (and, on Armv8-M, the Secure/Non-secure split) │
└─────────────────────────────────────────────────────────────┘
Three properties make the anchor trustworthy: Immutability (the root cannot be modified in the field), a hardware-anchored public key (only the hash needs to be burned into OTP; the private key never touches the device), and failure is fatal (a verification failure must halt or recover — never “log a warning and boot anyway”).
⚠️ The single most common catastrophic mistake: shipping with the default MCUboot signing key. That private key is in every copy of the MCUboot repository on Earth. Run imgtool keygen and generate your own. Keep the private half in an HSM or locked-down signing service — not in your Git repo.
Image authenticity vs. transport security
The channel you download over is not what makes the image trustworthy. TLS, certificate pinning, a signed manifest — all good, all worth doing, none of them sufficient. The image must be verified by the immutable bootloader against your key, regardless of how it arrived. Treat every byte that lands in the staging slot as hostile until MCUboot has checked the signature. Transport security protects the image in flight; the bootloader signature check is the one gate they cannot walk around without your private key.
Part 3: TrustZone-M architecture (the Cortex-M33 path)
Secure, Non-secure, and Non-secure Callable
Memory is attributed into three classes:
- Secure (S): only reachable from secure code. Your device key, crypto engine, secure storage, and the immutable bootloader live here.
- Non-secure (NS): your application. Cannot read or even probe secure addresses — an illegal access faults.
- Non-secure Callable (NSC): a narrow strip of secure memory containing secure gateway veneers — the only legal entry points from NS into S. Each veneer begins with the
SG(Secure Gateway) instruction; jumping into secure code anywhere else faults.
The non-secure-callable veneer, in real C
Here’s a secure service — a message signing operation that keeps the private key in the secure world — exposed through an NSC veneer. The cmse_check_address_range() calls are not optional hygiene — they’re the whole point. Without them, a non-secure caller can pass a secure address as the output buffer and use your trusted signing routine as a write primitive into protected memory. This is the embedded equivalent of the classic confused-deputy bug.
/* secure_services.c — compiled with arm-none-eabi-gcc -mcmse */
#include <arm_cmse.h>
#include "psa/crypto.h"
extern const psa_key_id_t g_device_key; /* lives in secure key storage, never exported */
__attribute__((cmse_nonsecure_entry))
psa_status_t secure_sign_message(const uint8_t *ns_msg, size_t msg_len,
uint8_t *ns_sig, size_t sig_cap, size_t *ns_sig_len)
{
/* CRITICAL: validate every pointer handed to us by non-secure code.
* A compromised NS app will pass a pointer INTO secure memory to try to
* trick us into reading or writing it (confused-deputy attack). */
if (cmse_check_address_range((void *)ns_msg, msg_len,
CMSE_NONSECURE | CMSE_MPU_READ) == NULL)
return PSA_ERROR_INVALID_ARGUMENT;
if (cmse_check_address_range(ns_sig, sig_cap,
CMSE_NONSECURE | CMSE_MPU_READWRITE) == NULL)
return PSA_ERROR_INVALID_ARGUMENT;
if (cmse_check_address_range(ns_sig_len, sizeof(*ns_sig_len),
CMSE_NONSECURE | CMSE_MPU_READWRITE) == NULL)
return PSA_ERROR_INVALID_ARGUMENT;
/* Sign inside the secure world. The private key never crosses the boundary;
* only the resulting signature is written back to the non-secure buffer. */
return psa_sign_message(g_device_key, PSA_ALG_ECDSA(PSA_ALG_SHA_256),
ns_msg, msg_len, ns_sig, sig_cap, ns_sig_len);
}
Where MCUboot fits under TF-M
Reset ──► MCUboot (BL2, PSA immutable Root of Trust)
│ verifies + selects image
▼
TF-M Secure image (SPE) ◄── isolated, holds keys & secure services
│ (PSA API via NSC veneers)
▼
Non-secure application (NSPE) ◄── your app, FreeRTOS/Zephyr, etc.
MCUboot is the PSA immutable Root of Trust here — it’s the first code to run and the thing that verifies everything downstream. Silicon gotcha: TF-M needs the extended hardware crypto/security block. On STM32, the STM32L562 / STM32U585 variants get full TF-M, while the STM32L552 / STM32U575 (TrustZone but no extended crypto HW) fall back to ST’s X-CUBE-SBSFU solution. Check the exact part number against the TF-M support matrix before you commit a BOM.
Part 4: Image format and signing with MCUboot
MCUboot wraps your raw application binary in a structure it can verify and manage:
┌──────────────┐ ← Image header (version, sizes, flags)
│ Header │
├──────────────┤
│ Application │ ← your actual firmware
│ binary │
├──────────────┤
│ Protected │ ← TLVs covered by the signature
│ TLVs │ (e.g. the security counter for rollback protection)
├──────────────┤
│ Unprotected │ ← TLVs not covered by the signature
│ TLVs │ (the signature itself, key hash, image hash)
└──────────────┘
Integrity is a SHA-256 hash over header + image + protected TLVs; authenticity is a signature over that hash. For Cortex-M, ECDSA-P256 is the sensible default — far smaller keys and signatures than RSA, and the verification cost is reasonable.
Generating keys and signing an image
# ── One-time key generation. Do this ONCE, store the private key in an HSM. ──
imgtool keygen -k device-signing-ec-p256.pem -t ecdsa-p256
# Extract the public key in a form the bootloader embeds / OTP-hashes:
imgtool getpub -k device-signing-ec-p256.pem > signing_pubkey.c
# ── Per-release signing ──
imgtool sign --key device-signing-ec-p256.pem --header-size 0x200 --align 8 --version 1.4.0 --security-counter 5 # hardware rollback counter — see Part 6
--slot-size 0x60000 --pad-header app.bin app.signed.bin
Two flags carry real semantics: --version 1.4.0 is the image version, used for software downgrade prevention and for humans. --security-counter 5 is the rollback counter burned into the protected TLVs — not the same as the version. You bump it only when you want to permanently close the door on every prior firmware, typically when fixing a security bug serious enough that you never want a device to run the vulnerable version again.
Part 5: OTA partitioning and the swap lifecycle
Slots
MCUboot works with two slots: Primary (slot 0) — the image that’s currently running, and Secondary (slot 1) — where a new, downloaded image is staged.
A minimal Zephyr partition layout
&flash0 {
partitions {
compatible = "fixed-partitions";
#address-cells = <1>;
#size-cells = <1>;
boot_partition: partition@0 { /* MCUboot — immutable, write-protected */
label = "mcuboot";
reg = <0x00000000 0x10000>; /* 64 KB */
};
slot0_partition: partition@10000 { /* primary: the running image */
label = "image-0";
reg = <0x00010000 0x60000>; /* 384 KB */
};
slot1_partition: partition@70000 { /* secondary: staged update */
label = "image-1";
reg = <0x00070000 0x60000>; /* 384 KB */
};
/* swap-move needs NO scratch partition — the sensible default. */
};
};
Choosing an upgrade strategy
| Strategy | Rollback? | Scratch needed? | Notes |
|---|---|---|---|
| overwrite-only | No (one-way) | No | Smallest, simplest. No automatic revert — a bad image stays. |
| swap-scratch | Yes | Yes | Classic swap via dedicated scratch region. More flash wear. |
| swap-move | Yes | No | Sensible default. Flash-efficient swap, no scratch partition. |
| swap-using-offset | Yes | No | Newer variant; avoids the up-shift swap-move does. |
| direct-xip | Yes (with revert) | No | Runs in-place; both slots must be XIP-capable and linked for their address. |
| ram-load | Yes (with revert) | No | Copies the selected image into RAM and runs it there. |
The confirm/revert lifecycle — the part that actually matters
This is the mechanism that keeps a bad build from bricking your fleet, and it’s where most pipelines are silently broken because they were never tested:
- App downloads new image → writes it to slot 1 (secondary).
- App requests an upgrade (marks slot 1 as “test”), then reboots.
- MCUboot verifies slot 1’s signature + rollback counter. If it fails → discards it, boots the old image. If it passes → swaps the slots and boots the new image in an UNCONFIRMED (“test”) state.
- The new image runs its self-test. If self-test passes → app calls
boot_write_img_confirmed(). Image is permanent. Done. If self-test fails or the app crashes/hangs before confirming → the watchdog fires, the device resets, and MCUboot sees an unconfirmed image and reverts to the previous known-good one.
The key insight: confirmation is something the new firmware earns by proving it works, not something granted at upload time. If you confirm immediately on boot, you’ve thrown away the entire safety net. In Zephyr this needs CONFIG_BOOTLOADER_MCUBOOT plus CONFIG_MCUBOOT_IMG_MANAGER.
Part 6: Rollback protection (anti-downgrade)
Rollback protection defends against a specific attack: you ship v1.4 to fix a remote-code-execution bug in v1.2; an attacker re-flashes the legitimately signed v1.2, re-opening the hole. Every image is properly signed — signature verification alone won’t stop this.
Hardware rollback protection (the real thing)
Compares the security counter from the image’s protected TLVs against a monotonic counter stored in a non-volatile, hardware-backed location — OTP fuses, a TF-M-protected counter, or a secure element:
boot image only if: image.security_counter >= device.security_counter
on confirm: device.security_counter = max(device.security_counter,
image.security_counter)
Because the device-side counter only ever goes up and lives somewhere an attacker can’t rewind, once a device has run v1.4 (counter = 5) it will physically refuse to boot any image signed with counter < 5 — even a perfectly valid signature on the old binary. The downgrade door is welded shut.
⚠️ The trade-off nobody mentions: the hardware counter is monotonic and usually fuse-backed — irreversible. Bump it casually on every release and you’ll burn through your OTP budget. Reserve security-counter increments for security-relevant releases. Use the ordinary image version for everything else.
MCUboot also ships Fault Injection Hardening (FIH) — its CI deliberately skips instructions during boot to confirm a corrupted image still can’t slip through. If your threat model includes physical attackers with a glitching rig, build with FIH enabled.
Part 7: Modeling the update manager as a state machine
The application’s side of the OTA lifecycle is a state machine. This connects directly to the event-driven architecture and HSM posts in this series — the update manager is a textbook event-driven component. Here’s a host-testable manager in modern C++ — no heap, no exceptions, no RTTI, with platform dependencies injected through an interface so the whole thing runs in a unit test on your laptop:
// ota_update_manager.hpp — host-testable; no heap, no exceptions, no RTTI.
#pragma once
#include <cstdint>
#include <cstddef>
#include <span>
namespace ota {
enum class State : uint8_t {
Idle, Receiving, Verifying, AwaitingReset,
SelfTest, // running as an UNCONFIRMED test image after the swap
Confirmed, Failed,
};
enum class Event : uint8_t {
StartDownload, DownloadComplete, VerifyOk, VerifyFailed,
BootedAsTest, // set at startup when MCUboot reports an unconfirmed image
SelfTestPassed, SelfTestFailed,
};
struct Platform {
virtual bool flash_write(uint32_t off, std::span<const uint8_t> data) = 0;
virtual bool request_upgrade(bool permanent) = 0;
virtual bool confirm_image() = 0;
virtual void reboot() = 0;
protected:
~Platform() = default;
};
class UpdateManager {
public:
explicit UpdateManager(Platform& plat) noexcept : plat_{plat} {}
State state() const noexcept { return state_; }
void dispatch(Event ev) noexcept {
switch (state_) {
case State::Idle:
if (ev == Event::StartDownload) { offset_ = 0; state_ = State::Receiving; }
else if (ev == Event::BootedAsTest) { state_ = State::SelfTest; }
break;
case State::Receiving:
if (ev == Event::DownloadComplete) state_ = State::Verifying;
break;
case State::Verifying:
// NOTE: this app-side verify is fail-fast defense in depth ONLY.
// The REAL authenticity gate is MCUboot in the bootloader.
// Never rely on the application to be the security boundary.
if (ev == Event::VerifyOk) {
state_ = plat_.request_upgrade(/*permanent=*/false)
? State::AwaitingReset : State::Failed;
if (state_ == State::AwaitingReset) plat_.reboot();
} else if (ev == Event::VerifyFailed) {
state_ = State::Failed;
}
break;
case State::SelfTest:
if (ev == Event::SelfTestPassed) {
// ONLY now do we make the new image permanent.
state_ = plat_.confirm_image() ? State::Confirmed : State::Failed;
} else if (ev == Event::SelfTestFailed) {
// Deliberately do nothing else: starve the watchdog and let
// MCUboot revert to the known-good image on the next reset.
state_ = State::Failed;
}
break;
default: break;
}
}
bool on_chunk(std::span<const uint8_t> chunk) noexcept {
if (state_ != State::Receiving) return false;
if (!plat_.flash_write(offset_, chunk)) { state_ = State::Failed; return false; }
offset_ += static_cast<uint32_t>(chunk.size());
return true;
}
private:
Platform& plat_;
State state_{State::Idle};
uint32_t offset_{0};
};
} // namespace ota
SelfTestFailed deliberately does nothing. It doesn’t try to “undo” the update from the application — which would be fragile and could itself be interrupted by a power cut. It simply lets the watchdog reset the device, at which point MCUboot’s revert machinery does the recovery. The safest recovery action is often inaction plus a reset.
Part 8: Testing strategy
Host-based tests (run in CI on every commit)
struct FakePlatform : ota::Platform {
bool upgrade_requested = false;
bool upgrade_permanent = true;
bool image_confirmed = false;
bool rebooted = false;
bool flash_write(uint32_t, std::span<const uint8_t>) override { return true; }
bool request_upgrade(bool permanent) override {
upgrade_requested = true; upgrade_permanent = permanent; return true;
}
bool confirm_image() override { image_confirmed = true; return true; }
void reboot() override { rebooted = true; }
};
// The test that most teams never write: a self-test failure must NOT confirm.
void test_failed_selftest_does_not_confirm() {
FakePlatform p;
ota::UpdateManager m{p};
m.dispatch(ota::Event::BootedAsTest); // came up as an unconfirmed image
assert(m.state() == ota::State::SelfTest);
m.dispatch(ota::Event::SelfTestFailed);
assert(p.image_confirmed == false); // <-- the whole point: NO confirm
}
// Staging path must request a TEST upgrade, never a permanent one.
void test_upgrade_is_staged_as_test() {
FakePlatform p;
ota::UpdateManager m{p};
m.dispatch(ota::Event::StartDownload);
m.dispatch(ota::Event::DownloadComplete);
m.dispatch(ota::Event::VerifyOk);
assert(p.upgrade_requested == true);
assert(p.upgrade_permanent == false); // test image, NOT permanent
assert(p.rebooted == true);
}
On-target tests (the one mandatory manual test)
- Flash v1.0 + MCUboot.
- Build and sign v1.1, push it OTA, reboot. Confirm the device runs v1.1 and the image is confirmed.
- Build a v1.2 that deliberately omits the confirm call. Push it, reboot, then reboot again.
- Verify the device reverted to v1.1.
Step 4 is the test everyone skips and the only one that proves your safety net exists. If revert doesn’t work on the bench, it will not work in the field — and in the field there’s no JTAG to bail you out. Also worth scripting: a power-fail-during-swap test. Cut power mid-swap and confirm the device comes up on some valid image every time.
Part 9: Anti-patterns
- Shipping the default MCUboot key. Most total and most common failure. Your own key, private half in an HSM.
- Confirming the image at boot instead of after a self-test. Turns swap mode into an expensive overwrite mode with no rollback.
- Treating TLS as authentication. Transport security protects the download; the bootloader signature check is the gate. Both, always.
- Verifying the signature in application code. On a TrustZone part, a check that a compromised application can patch out is not a check.
- Bumping the security counter every release. Monotonic, fuse-backed, irreversible. Burn it only for security releases.
- Skipping the NS pointer validation in secure veneers. Every pointer from the non-secure world is hostile. No
cmse_check_address_range()means a confused-deputy write primitive into secure memory. - No watchdog on the test-boot path. The revert mechanism depends on a hung or crashed test image triggering a reset. No watchdog, no automatic recovery.
- Setting RDP to its maximum level during development. On STM32, the highest readout-protection level can be irreversible. Understand the lifecycle states before you write option bytes you can’t take back.
Part 10: Choosing your stack
| Your part | Runtime isolation | Secure boot stack | Notes |
|---|---|---|---|
| Cortex-M0/M3/M4/M7 (e.g. STM32F4, STM32H7) | None (no TrustZone-M) | MCUboot standalone, or X-CUBE-SBSFU | Full secure boot + OTA + rollback available. Keys not hardware-isolated from app at runtime. |
| Cortex-M33 with full crypto HW (STM32U585, STM32L562, nRF5340) | TrustZone-M | TF-M (MCUboot as BL2 + secure services) | Full PSA story: secure boot, isolated keys, secure storage, attestation. |
| Cortex-M33, TrustZone but no extended crypto (STM32U575, STM32L552) | TrustZone-M | X-CUBE-SBSFU | TF-M needs the extended crypto block. Check the exact part number. |
Closing
The mechanics here aren’t exotic, but the discipline is unforgiving, and the failure modes are the kind you discover in the field at fleet scale rather than on the bench:
- Secure boot proves authenticity from an immutable, hardware-anchored root — and the root is only as good as your key management.
- TrustZone keeps your secrets isolated at runtime on Armv8-M, but it’s orthogonal to secure boot and only as good as your veneer pointer-checks.
- OTA with confirm/revert is what keeps a bad build from bricking the fleet — and confirmation must be earned by a self-test, not granted at upload.
- Rollback protection closes the downgrade door, and the hardware counter that does it is irreversible, so spend it carefully.
- Test the revert path on real silicon before you ship. It’s the one test that proves the whole edifice works, and it’s the one almost everyone skips.
Related posts in this series
- Getting Started with Zephyr on STM32 — west, Kconfig, device tree, and sysbuild, which is exactly how you integrate MCUboot as a child image.
- Multicore Microcontrollers: AMP vs SMP — the inter-core boot and IPC discipline that the nRF5340 secure-boot example builds on.
- Event-Driven Firmware Architecture — the dispatch model the OTA update manager plugs into.
- Hierarchical State Machines in C — promoting the flat update state machine above into a proper HSM.