How to Implement Secure Boot on ARM Cortex-M

Most Cortex-M “secure boot” implementations I’ve reviewed in production devices aren’t secure. They compute a hash, maybe check a signature, and then jump to the application regardless of the result. The verify-but-don’t-enforce pattern is so common it has a name, and it passes code review because the crypto looks right. The branch logic is where things fall apart.
If you’ve been tasked with adding secure boot to a Cortex-M product, whether for IEC 62443 compliance, FDA premarket guidance, or a customer mandate, you need more than a conceptual overview. You need to know exactly how to structure the flash, which crypto to use on a device with 32 KB of RAM, where to store keys, and which mistakes will silently undermine everything.
This guide covers the bootloader verification stage of secure boot on ARM Cortex-M, applicable across M0 through M7, vendor-agnostic, with pseudocode you can adapt. It doesn’t cover TrustZone-M (M23/M33), secure element integration, or OTA update mechanisms. Those are related but distinct problems.
The Chain of Trust That Makes Secure Boot Work
Secure boot on ARM Cortex-M is a chain of trust. Each link verifies the next before handing off execution:
Root of Trust → Immutable Bootloader → Application Firmware
The Root of Trust (RoT) is a small, immutable code region: either mask ROM burned into silicon or a write-protected flash sector that nothing on the device can modify at runtime. This code does exactly one thing: verify the next stage.
The critical distinction: we’re implementing verified boot, where a failed verification halts execution. This is different from measured boot (which logs measurements but doesn’t enforce) or trusted boot (which may allow recovery paths). On failure, the device does not run untrusted code. Period.
Keep Cortex-M constraints in mind throughout. There’s no MMU, only an MPU on M0+, M3, M4, and M7 (and it’s optional on M0+). Flash and RAM are limited. Crypto acceleration is absent on M0/M0+ and variable on M3 through M7. These constraints shape every decision that follows.
Choosing Your Cryptographic Approach
Use ECDSA P-256 with SHA-256. This is the right default for firmware signature verification on Cortex-M. The public key lives on the device, but the private signing key never leaves your build server. Compromising one device in the field doesn’t give an attacker the ability to sign firmware for your entire fleet.
The symmetric alternative, HMAC-SHA256, requires a shared secret on every device. Extract that secret from one unit via a decapped chip or a debug port you forgot to lock, and an attacker can forge firmware for every device you’ve ever shipped. HMAC is acceptable only in fully closed systems where physical access is controlled and cost constraints on M0 devices make asymmetric verification impractical.
Performance across the Cortex-M family for ECDSA-P256 signature verification:
- M0/M0+ (software-only): 1–3 seconds. Acceptable at boot; this isn’t a hot path.
- M3/M4: Sub-second with software crypto. Some parts include hardware accelerators that bring it under 100 ms.
- M7: Typically has hardware crypto. Verification completes in milliseconds.
Use an audited library. Mbed TLS, micro-ecc, and tinycrypt are proven starting points. Do not implement your own ECDSA or SHA-256. A subtle bug in your field arithmetic won’t show up in functional testing. It’ll show up in a CVE.
Designing Your Flash Memory Layout
A secure boot memory layout needs five distinct regions, each with explicit protection:
0x0800_0000 ┌─────────────────────────────┐
│ Immutable Bootloader │ ← Write-protected flash sector(s)
│ (Root of Trust code) │
0x0800_4000 ├─────────────────────────────┤
│ Public Key Storage │ ← OTP fuses or write-protected flash
│ (ECDSA P-256 public key) │
0x0800_4100 ├─────────────────────────────┤
│ Rollback Counter │ ← OTP or protected flash
0x0800_4200 ├─────────────────────────────┤
│ Firmware Header │ ← Magic, version, size, signature
0x0800_4280 ├─────────────────────────────┤
│ Application Firmware │ ← Verified before execution
│ │
0x080X_XXXX └─────────────────────────────┘The non-negotiable rule: the bootloader must reside in a region it cannot modify. Use your silicon’s flash sector write-protection bits (WRP on STM32, PFLASH protection on Infineon, etc.) to make the bootloader region permanently read-only. On parts with OTP fuses, store the public key there. On M0 devices without OTP, use write-protected flash.
The M0 complication: base Cortex-M0 lacks an MPU entirely. You cannot enforce memory protection at runtime. Your defense is hardware flash write-protection and readout protection (RDP/CRP). M0+ has an optional MPU; check your specific part’s datasheet.
Configure the MPU (where available) to enforce read-only access to the bootloader and key storage regions at runtime, even before jumping to application code.
Step-by-Step Bootloader Verification
This is the core implementation. Every step includes error handling because the failure path is the security boundary.
Step 1: Minimal Hardware Initialization
void bootloader_entry(void) {
// Initialize only what's necessary: clocks, flash wait states
system_clock_init();
flash_set_wait_states();
// SECURITY NOTE: Do NOT initialize UART, SPI, USB, or any
// peripheral. Every active peripheral is attack surface.
// No interrupts enabled. Polling only.
boot_verify_and_jump();
}Step 2: Read and Validate the Firmware Header
typedef struct {
uint32_t magic; // e.g., 0x53424F4F ("SBOO")
uint32_t version; // Monotonic firmware version
uint32_t image_size; // Bytes, not including header
uint32_t reserved;
uint8_t signature[64]; // ECDSA P-256 signature (r || s)
} firmware_header_t;
int validate_header(const firmware_header_t *hdr) {
if (hdr->magic != EXPECTED_MAGIC)
return ERROR_INVALID_HEADER;
// SECURITY NOTE: Bounds-check image_size against physical flash.
// An unchecked size field lets an attacker control what gets hashed.
if (hdr->image_size == 0 || hdr->image_size > MAX_APP_SIZE)
return ERROR_INVALID_SIZE;
// Check rollback counter
uint32_t stored_version = read_rollback_counter();
if (hdr->version < stored_version)
return ERROR_ROLLBACK_DETECTED;
return SUCCESS;
}Step 3: Compute the Hash of the Firmware Image
int compute_firmware_hash(const uint8_t *fw_start, uint32_t fw_size,
uint8_t *hash_out) {
sha256_context ctx;
sha256_init(&ctx);
// SECURITY NOTE: Hash in chunks to limit stack usage.
// On an M0 with 4 KB RAM, a 512-byte chunk is reasonable.
uint32_t offset = 0;
while (offset < fw_size) {
uint32_t chunk = MIN(HASH_CHUNK_SIZE, fw_size - offset);
sha256_update(&ctx, fw_start + offset, chunk);
offset += chunk;
}
sha256_finish(&ctx, hash_out); // 32-byte digest
return SUCCESS;
}Step 4: Verify the Signature
int verify_firmware_signature(const uint8_t *hash,
const uint8_t *signature) {
// Load public key from protected storage (OTP or write-protected flash)
const uint8_t *public_key = (const uint8_t *)PUBLIC_KEY_ADDRESS;
int result = ecdsa_verify_p256(public_key, hash, 32, signature, 64);
// SECURITY NOTE: Double-check the result to resist single-glitch
// fault injection that flips a return value.
int result2 = ecdsa_verify_p256(public_key, hash, 32, signature, 64);
if (result != ECDSA_VALID || result2 != ECDSA_VALID)
return ERROR_SIGNATURE_INVALID;
return SUCCESS;
}Step 5: Enforce the Decision
void boot_verify_and_jump(void) {
const firmware_header_t *hdr = (firmware_header_t *)FW_HEADER_ADDRESS;
const uint8_t *app_start = (uint8_t *)(FW_HEADER_ADDRESS + sizeof(*hdr));
uint8_t hash[32];
if (validate_header(hdr) != SUCCESS)
boot_failure_handler(); // Does NOT return
if (compute_firmware_hash(app_start, hdr->image_size, hash) != SUCCESS)
boot_failure_handler();
if (verify_firmware_signature(hash, hdr->signature) != SUCCESS)
boot_failure_handler();
// Update rollback counter if firmware version is newer
if (hdr->version > read_rollback_counter())
write_rollback_counter(hdr->version);
// SECURITY NOTE: jump_to_application must never be reachable
// without passing all three checks above.
jump_to_application((uint32_t)app_start);
}
_Noreturn void boot_failure_handler(void) {
// Safe state: infinite loop, or signal error via LED/GPIO
// SECURITY NOTE: NEVER fall through to application.
while (1) {
__WFI(); // Low-power wait; device is bricked until re-flashed
}
}Step 6: Lock Down Before Jumping
void jump_to_application(uint32_t app_address) {
// Configure MPU to write-protect bootloader region (if MPU available)
mpu_configure_bootloader_readonly();
// Set vector table offset to application's vector table
SCB->VTOR = app_address;
// Read initial stack pointer and reset handler from app vector table
uint32_t app_sp = *(volatile uint32_t *)(app_address);
uint32_t app_reset = *(volatile uint32_t *)(app_address + 4);
__set_MSP(app_sp);
// Jump to application reset handler
void (*app_entry)(void) = (void (*)(void))app_reset;
app_entry();
}Key Storage Strategies That Actually Protect Your Fleet
The public key is the linchpin. If an attacker can substitute it, they can sign arbitrary firmware that your bootloader will happily accept.
OTP fuses (best option): Many M4 and M7 parts provide one-time-programmable memory. Write the public key (or its SHA-256 hash) during manufacturing. It cannot be altered. If OTP space is limited, store the hash of the key in OTP and the full key in write-protected flash. The bootloader verifies the key’s hash against OTP before using it.
Write-protected flash (acceptable fallback): For M0/M3 parts without OTP, store the key in the same write-protected sector as the bootloader. The protection here is only as strong as the flash write-protection mechanism, which varies by vendor. Some parts allow permanent lock; others can be unlocked with a mass erase (which also destroys the firmware, an acceptable trade-off).
Production provisioning matters. Program keys in a secure manufacturing environment. Define a key injection procedure, track which key pairs correspond to which product lines, and plan for key revocation before you ship the first unit.
Five Mistakes That Undermine Otherwise Sound Implementations
1. No rollback protection. Without a monotonic version counter, an attacker with physical access can flash an older, vulnerable firmware version that passes signature verification. Store a counter in OTP or protected flash and reject any firmware with a version below it.
2. Debug ports left open. JTAG/SWD gives direct memory access, bypassing every software check. Permanently disable or password-protect debug access via option bytes during production programming. Test this on every production unit.
3. Verify-but-don’t-enforce. The verification runs, but the branch on failure is missing or unreachable. Test the failure path explicitly: sign with the wrong key, corrupt one byte, downgrade the version. Confirm the device halts.
4. TOC/TOU on external flash. If firmware lives in external SPI flash, an attacker can swap content between verification and execution. For external flash, copy the image to internal RAM or flash, then verify, then execute from the internal copy. Never verify-in-place from external memory.
5. Unbounded header fields. Trusting the image_size field without checking it against physical flash boundaries lets an attacker control what region gets hashed, potentially hashing across a region they’ve injected content into.
Validating That Your Secure Boot Actually Works
Test the failure path more than the happy path. Your bootloader should reject:
- Firmware signed with an incorrect key
- Firmware with a single corrupted byte
- Firmware with a version number below the rollback counter
- Firmware with an out-of-bounds size field
For higher-security applications, consider fault injection resistance. The double-verification in Step 4 is a starting point, but dedicated glitching attacks require deeper countermeasures: instruction redundancy, control flow integrity checks, voltage/clock monitoring. This is a full topic on its own and may be required for PSA Certified Level 2+ or IEC 62443 SL-3.
If your product requires certification, look at Arm’s PSA Certified framework, IEC 62443-4-2, or SESIP as evaluation benchmarks. These standards expect documented evidence that your secure boot works, not just that it exists.
Building This Into Your Product
Secure boot doesn’t solve every firmware security problem, but nothing else works without it. If your bootloader can’t be trusted, your application encryption, your secure communication, and your access controls all rest on a foundation that an attacker can replace.
Lock these decisions down early. They’re hard to change after silicon selection and PCB layout:
- ECDSA P-256 + SHA-256 for signature verification
- Immutable bootloader in write-protected flash
- Public key in OTP (or write-protected flash with hash verification)
- Monotonic rollback counter in OTP or protected storage
- Debug ports disabled in production option bytes
Once your boot path is verified and enforced, the natural next steps are secure firmware update (OTA) and, if you’re targeting Cortex-M23/M33, TrustZone-M isolation for runtime security boundaries.
The bootloader is the smallest piece of code in your system and the most consequential. Get it right.
Hubble Network enables secure, authenticated firmware updates over Bluetooth — directly from satellite to device, no gateway required. See how it works →