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. anRc/Arcreference 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
-O2with 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 againWhen the function returns,
rspmoves 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/Stringgrowth. Reallocating moves the bytes to a new buffer and frees the old one without wiping it. The fix is to reserve up front withZeroizing::new(Vec::with_capacity(MAX))and never let it grow. This actually closes the gap, becausezeroize’sVec/Stringimpls 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>orSecretBox<T>— don’t rely on manualzeroize()calls. - Never derive
Copyon a secret type. - Pre-allocate
Vec/Stringwithwith_capacity()and never let them grow. - Be aware that
mem::forget, aborts, and stack overflows skipDrop. - 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:
zeroize— https://docs.rs/zeroizesecrecy— https://docs.rs/secrecy