If you work with secret keys — signing keys, API tokens, anything that must not leak — you’ve probably written this happy little line and felt good about it:

1
secret.zeroize();

Wiped! Auditor happy, you happy. And honestly, that line is doing real work. But there’s a surprising amount of distance between calling zeroize and your secret being truly, unrecoverably gone — and that distance is where things get interesting.

This post is about that distance: what zeroize actually buys you, and the copies of your secret it was never going to reach. We’ll stay inside the language for now — just Rust and the compiler. (Later posts pick up where this one leaves off: keeping the OS from copying your secret to disk, and arranging for the plaintext key to never be in your process at all.)

First, a problem C taught us years ago

If you’ve done any security-sensitive C or C++, this one will feel familiar. Clearing a buffer with a plain memset looks responsible but can quietly do nothing:

1
2
3
4
5
6
void use_key(void) {
    uint8_t key[32];
    load_key(key);
    crypto_op(key);
    memset(key, 0, sizeof(key));  // dead store — may be deleted
}

After that memset, nobody reads key again. The optimizer notices, decides the write is pointless, and deletes it. Your wipe compiles to nothing.

This is such a classic footgun that every platform grew a special function whose only job is to do a write the compiler isn’t allowed to remove: explicit_bzero (BSD, glibc), memset_s (C11 Annex K), SecureZeroMemory (Windows).

Here’s the thing: Rust inherited the exact same problem. LLVM will happily delete a dead store in Rust too. So our very first fix isn’t fancy architecture — it’s just reaching for the right crate.

zeroize

zeroize is Rust’s answer to explicit_bzero, and the best part is there’s no magic to be afraid of. Under the hood it’s a volatile write the compiler can’t skip, plus a compiler_fence so it can’t be reordered with the code around it. (A compiler fence, note — not a CPU memory barrier; for wiping a secret on a single thread, that’s exactly the right tool.) That’s the whole trick.

1
2
3
4
5
use zeroize::Zeroize;

let mut key = load_key();          // [u8; 32]
crypto_op(&key);
key.zeroize();                     // volatile-write zeros + compiler_fence

It’s pure Rust, no_std-friendly (with an optional alloc feature for Vec and String), and it works on arrays, slices, and your own types via #[derive(Zeroize)]. After it runs, the bytes at that spot really are zero, and the compiler can’t undo it. Lovely.

The rest of this post is really just exploring that little phrase, “at that spot.”

Let it wipe itself: Zeroizing<T> and ZeroizeOnDrop

Remembering to call zeroize() by hand is the kind of thing that’s easy to forget at 6pm on a Friday, so zeroize lets you tie the wipe to the value being dropped:

1
2
3
4
5
use zeroize::Zeroizing;

let key = Zeroizing::new(load_key());  // wiped automatically when it drops
crypto_op(&key);
// nothing else to remember

#[derive(ZeroizeOnDrop)] gives your own structs the same treatment. These are a great default — let the type system do the remembering.

A gentle catch: Drop doesn’t always run

Zeroizing<T> does its job on the normal path. The thing to know is that Drop can be skipped entirely in a few situations:

  • mem::forget (directly, or via a leak — e.g. an Rc/Arc reference cycle),
  • the process aborting (panic = "abort", a panic during unwinding, or a hard abort),
  • a stack overflow that takes the process down.

None of this means ZeroizeOnDrop is bad — it just means it’s a best-effort wipe rather than an ironclad guarantee like the borrow checker. Good to keep in the back of your mind when you write down your threat model.

Another catch: moving and copyable types

This one trips up almost everyone, so let’s go slow. A move in Rust is, down at the machine level, a bitwise copy of the bytes — and then the compiler statically tracks the source as “moved-from” so you can’t use or drop it again. That second half is the subtle part: the old location never gets wiped, and its destructor is intentionally skipped (so you don’t double-free). (This tracking is a compile-time analysis, not a runtime flag — a runtime drop flag only appears when the compiler can’t decide statically whether a value still needs dropping.)

1
2
3
4
5
6
use zeroize::Zeroizing;

let a = Zeroizing::new([0x42u8; 32]); // secret at stack slot A
let b = a;                            // moved to slot B; A is now moved-from
drop(b);                              // wipes B
// slot A still holds the old bytes. Its Drop is skipped, so they're never zeroized.

You can’t name slot A in Rust anymore, and whether it still holds those bytes depends on whether the optimizer reused the storage — that’s the compiler’s call, not yours. In fact, with optimizations on, the compiler will often construct a directly in b’s slot and elide the move entirely, so this snippet is really a conceptual illustration — which is also why the demo below has to use black_box to force a real, separate copy into existence. But you have to assume the worst: the bytes might still be sitting there for anything that can read your process’s memory — a debugger, a core dump, or the OS paging to swap. The same situation quietly shows up every time you return a secret by value, pass it to a function, or tuck it into a struct field.

You can see this for yourself. Here’s a small program that creates a Zeroizing secret, moves it, and drops the destination:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use std::hint::black_box;
use zeroize::Zeroizing;

fn load_key() -> [u8; 32] {
    let mut key = [0u8; 32];
    for (i, chunk) in key.chunks_exact_mut(4).enumerate() {
        chunk.copy_from_slice(&[0xDE, 0xAD, 0xBE, 0xEF]);
        black_box(i); // keep the loop from collapsing into one constant store
    }
    key
}

fn main() {
    let a = Zeroizing::new(load_key());
    let b = black_box(a);   // force a real move
    drop(b);                 // wipes B

    eprintln!("PID {} — scan memory now", std::process::id());
    std::thread::sleep(std::time::Duration::from_secs(30));
}

While it sleeps, a simple script can scan /proc/<pid>/mem for the DEADBEEF pattern:

1
2
3
4
5
6
7
$ sudo python3 scan_mem.py 523524
  hit at 0x7ffed3b5d388 in [stack]
  hit at 0x7ffed3b5d3a8 in [stack]
  hit at 0x7ffed3b5d3c8 in [stack]
  hit at 0x7ffed3b5d428 in [stack]

Found 4 occurrence(s) of the 32-byte DEADBEEF pattern

drop(b) zeroed exactly one location — the four that remain are the stale copies the secret left along the way to get there. The exact count is the optimizer’s call, but each survivor is a fossil of a by-value hop: load_key built the array in its own frame and returned it by value (a copy into the caller’s slot), Zeroizing::new wrapped it (another move), and black_box(a) took it by value and handed it back (in, then out). zeroize only ever reached the last of these. This is the “returning by value / passing to a function” warning from a moment ago made concrete: one logical secret, several physical copies, all but one past zeroize’s reach.

And Copy types make it trickier still: there’s no move-tracking at all, so every use just duplicates the bytes somewhere you can’t name. That’s the real reason a secret type should never be Copy — and why Zeroizing and secrecy::SecretBox deliberately aren’t.

In practice, minimize how often the value moves: keep the secret behind a reference, or in a heap-allocated wrapper like SecretBox where the pointer moves but the secret bytes stay put.

A friendly upgrade: secrecy

secrecy’s SecretBox<T> builds two nice guardrails on top of zeroize:

1
2
3
4
5
use secrecy::{SecretBox, ExposeSecret};

let key = SecretBox::new(Box::new(load_key()));
crypto_op(key.expose_secret());
println!("{:?}", key);  // SecretBox<[u8; 32]>([REDACTED]) — not your key

You can only reach the value through expose_secret()/expose_secret_mut() — so every access is easy to spot in code review — and it won’t accidentally show up in Debug output or get serde-serialized. It’s forbid(unsafe_code) and intentionally doesn’t do mlock/mprotect — which, as we’ll see next time, is a whole separate layer of the problem.

What no crate can quite reach

Even with all of the above in place, a couple of copies can still slip past the language itself:

  • Register spills. Your CPU has a limited number of registers. When the compiler needs more live values than registers available, it “spills” some to the stack — unnamed slots that don’t correspond to any variable in your source code.

    You can see this happen at -O2 with a function that keeps several secret-derived values alive at once (source, Compiler Explorer):

    movzx eax, byte ptr [rdi + 1]     ; load secret[1] from input
    mov dword ptr [rsp - 28], eax     ; spill to stack — no variable name for this
    
    movzx eax, byte ptr [rdi + 2]     ; load secret[2]
    mov dword ptr [rsp - 48], eax     ; spill again
    

    When the function returns, rsp moves back up, but the bytes stay in physical memory. Nothing zeroes them, and you can’t zero them — there’s no variable to call .zeroize() on. An attacker who can read your process’s memory (via a core dump, swap file, ptrace, or a buffer over-read like Heartbleed) picks them up for free.

  • Vec/String growth. Reallocating moves the bytes to a new buffer and frees the old one without wiping it. The fix is to reserve up front with Zeroizing::new(Vec::with_capacity(MAX)) and never let it grow. This actually closes the gap, because zeroize’s Vec/String impls wipe the buffer’s entire capacity, not just the live length — so as long as no reallocation ever happens, no stale copy is left behind. (zeroize’s own docs say exactly this: size to the right capacity, then take care to prevent subsequent reallocation.)

Those leftover copies live in your process’s memory — and your OS is allowed to move that memory to places zeroize was never going to follow: out to the swap file, into a core dump, or straight into another process that asks nicely. That’s a problem the language simply can’t solve, because it’s not the language doing the copying.

Where this leaves us

zeroize is genuinely good, and you should reach for it on every secret you touch. Just hold an accurate picture of what it does: a compiler-proof wipe of a location you can name, on the path where destructors actually run. Spills, moves, and reallocation already leak past that — and the OS is about to make things more interesting.

Quick checklist:

  • Wrap secrets in Zeroizing<T> or SecretBox<T> — don’t rely on manual zeroize() calls.
  • Never derive Copy on a secret type.
  • Pre-allocate Vec/String with with_capacity() and never let them grow.
  • Be aware that mem::forget, aborts, and stack overflows skip Drop.
  • Moves leave stale bytes behind — keep secrets behind references or on the heap.

That’s where we go next: the operating-system knobs (mlock, MADV_DONTDUMP, dump and ptrace hardening) that stop the kernel from quietly copying your secret somewhere you can’t reach.


Further reading: