Go 1.26’s runtime/secret: The Erasure Guarantee Only Covers Half Your Memory
Go 1.26 shipped an experimental package built specifically to solve one problem: after your code handles a cryptographic key, that key’s bytes shouldn’t keep sitting in memory once you’re done with them. runtime/secret wraps a function call so that when it returns, the registers and stack it touched get wiped immediately. That’s the pitch, and for the stack half of it, the pitch is accurate. What almost nobody explains — English-language coverage of this package treats it as a one-line feature bullet, not a mechanism worth interrogating — is that the guarantee splits cleanly into two halves with two different timelines, and the half most developers will hit without realizing it is the slower one.
What secret.Do actually guarantees, and on what schedule
The core API is a single function: you hand secret.Do a closure, it runs that closure, and the moment it returns, the runtime clears every register and every byte of stack frame the closure used. This is immediate and unconditional — there’s no garbage collector cycle to wait for, no scheduling delay. If your entire secret-handling operation — deriving a key, running a comparison, computing a hash — stays within fixed-size local variables that the compiler keeps on the stack, the erasure happens at the exact moment your function returns, deterministically, every time.
That immediacy is the entire value proposition for the use case the package targets: forward secrecy. Protocols like TLS and WireGuard depend on ephemeral keys existing only long enough to negotiate a session and then genuinely disappearing — if an attacker later compromises your long-term private key, past sessions should still be unrecoverable, and that only holds if the ephemeral material is actually gone, not just unreferenced. A Go program that generates an ephemeral key, uses it, and then simply lets the variable go out of scope hasn’t erased anything — it’s handed the bytes to the garbage collector, which reclaims memory on its own schedule, with no promise about when or whether that memory gets overwritten before something else reuses the page. secret.Do exists to close that gap for the stack case specifically.
Where the guarantee quietly changes shape: heap allocations
Here’s the part that the immediate-erasure framing obscures. Not everything your closure touches necessarily lives on the stack — Go’s compiler decides that through escape analysis, the same mechanism that decides whether any value in your program gets stack-allocated or heap-allocated based on whether a reference to it could outlive the function. A `make([]byte, n)` call with a size that isn’t a compile-time constant, a value captured by a closure that escapes, or a slice that gets passed somewhere the compiler can’t fully trace — any of these can push an allocation onto the heap even inside a secret.Do block, and the moment that happens, the erasure guarantee stops being immediate. Heap memory used inside secret.Do gets cleared only once the garbage collector determines the object is unreachable and processes it — which means the sensitive bytes can legitimately still be sitting in memory, readable by anything that can inspect the process, for however long it takes until the next GC cycle actually reaches that object.
Why Golang Production Mistakes Keep Killing Systems That "Should Work" Go ships with a reputation for simplicity. Clean syntax, fast builds, garbage collected — what could go wrong? Plenty. The language is simple to write...
This isn’t a bug or an oversight in the design — it follows directly from what the Go runtime can and can’t control. The runtime can guarantee stack erasure because it owns the stack frame completely and knows precisely when a function returns. It can’t make the same promise about heap memory, because heap objects are managed by a separate, asynchronous subsystem that reclaims memory based on reachability, not based on which function last touched it. The package documentation is honest about this if you read it closely — it describes the guarantee in terms of what happens when the GC decides an object is unreachable, not in terms of a fixed deadline — but “GC-timing-dependent” and “immediate” read as the same promise to anyone skimming a feature announcement rather than the actual API description.
Seeing the boundary yourself
You don’t need cryptographic code to observe this split — it’s a direct consequence of escape analysis, and you can watch the compiler make the decision with a flag it already ships:
package main
import "runtime/secret"
func stackBound() {
secret.Do(func() {
var key [32]byte // fixed size, stays on the stack
fill(key[:])
use(key[:])
})
}
func heapBound(n int) {
secret.Do(func() {
key := make([]byte, n) // size not known at compile time
fill(key)
use(key)
})
}
Build either function with go build -gcflags="-m" and the compiler will tell you directly which variables it decided to put on the heap and why — look for a line naming key with escapes to heap next to it. In stackBound, the fixed-size array typically stays exactly where secret.Do‘s immediate-erasure guarantee applies. In heapBound, a size determined at runtime is a textbook case the compiler frequently can’t prove safe to keep on the stack, and once it escapes, you’ve silently downgraded from “erased the instant this function returns” to “erased whenever the collector gets to it.” The API surface looks identical in both cases — same function, same call pattern — which is exactly why this is easy to miss without deliberately checking.
Why this needed real assembly work, and what that implies about trust
The stack-and-register half of this guarantee sounds simple stated as a sentence — “clear the memory you used” — but implementing it correctly required architecture-specific work, not a generic memory-zeroing loop. Registers that held sensitive values during the closure’s execution have to be explicitly cleared, and which registers were live, and how the calling convention preserves or spills them, differs by CPU architecture. A cleanup routine that’s correct on one instruction set doesn’t automatically transfer to another — this is why the package currently only supports linux/amd64 and linux/arm64, not the full set of platforms Go normally targets. If your build pipeline cross-compiles for other operating systems or architectures, you’ll need a fallback path, because the feature gate simply isn’t available there yet.
That platform restriction is worth treating as a signal, not just a limitation to work around. It tells you the Go team is deliberately not promising this guarantee anywhere they haven’t done the architecture-specific verification work, rather than shipping a generic best-effort implementation everywhere and hoping it holds. That’s a reasonable posture for a security primitive, but it means “my code compiles for this platform” and “my code gets the erasure guarantee on this platform” are two separate questions you have to check independently — the former doesn’t imply the latter for every target you might build against.
Who this is actually for
The package documentation is explicit that this is aimed at people building cryptographic libraries, not application developers writing business logic that happens to touch a password. That distinction matters practically: if you’re an application developer, the correct move is almost never to reach for secret.Do directly — it’s to use a higher-level library that already calls it internally where appropriate, the same way you’d rely on a TLS library rather than hand-rolling a handshake. Calling secret.Do yourself means you’ve taken on the responsibility of understanding exactly what does and doesn’t escape to the heap inside your closure, which is precisely the kind of compiler-behavior-dependent reasoning that’s easy to get wrong once and never notice, because nothing about a heap-erasure delay produces a visible bug. Your program behaves identically whether the erasure was instant or delayed by a GC cycle — the only difference is a window of exposure that doesn’t show up in tests.
Your Go Map Isn't Thread-Safe — and Goroutines Will Prove It Most Go services don't blow up on day one. They blow up on day 90, under real load, with a fatal error: concurrent map...
What this doesn’t solve, stated plainly
Even a perfectly-scoped secret.Do call with everything staying on the stack doesn’t protect against everything adjacent to the problem it targets. Any copy of the sensitive data made before entering secret.Do — a buffer it was read from, a log statement that captured it before you started scoping it correctly — is untouched by this mechanism entirely; the package only clears what happened inside the closure it wraps. Memory that gets swapped to disk under memory pressure, or captured in a core dump generated at the wrong moment, isn’t addressed by this API either — those are operating-system-level concerns that a userspace erasure guarantee can’t reach into. And because the package is explicitly experimental and exempt from the Go 1 compatibility promise, the exact API — the function signature, the guarantee’s precise wording — can still change before it stabilizes, which is worth factoring in if you’re building something you intend to maintain past the current release.
How other ecosystems have handled the same problem
Go isn’t the first language to run into this split between stack and heap erasure — it’s just the first to ship a standard-library answer for the stack half specifically. Rust’s zeroize crate takes a different approach: it doesn’t distinguish stack from heap at the API level, instead giving you a trait you implement on your own types so that dropping the value explicitly overwrites its bytes, regardless of where the allocator put them. That works because Rust’s ownership model guarantees a deterministic drop point for both stack and heap values — there’s no separate garbage collector deciding when cleanup happens, so the language doesn’t have the same two-speed problem Go does. C’s explicit_bzero and the more recent memset_s solve a narrower version of the same issue: they guarantee the compiler won’t optimize away a memory-clearing call the way it might with a plain memset on memory that’s about to go out of scope — but C has no garbage collector at all, so heap-timing ambiguity isn’t part of the problem in the first place.
The comparison is instructive precisely because it shows the heap-erasure gap isn’t a Go-specific design flaw — it’s a direct consequence of having a tracing garbage collector manage your heap. Any garbage-collected language building an equivalent primitive would face the same choice Go’s designers faced: either block until a full collection cycle confirms an object unreachable (correct, but a real performance cost paid on every call), or document the gap and give developers a way to reason about which path their code takes (what Go actually shipped). Neither choice is free, and knowing which one your language picked changes what you need to verify in your own code.
Does the new default garbage collector change any of this?
Go 1.26 shipped two changes to memory management in the same release: this package, and Green Tea GC becoming the default collector, replacing the previous mark-and-sweep implementation with one organized around memory-locality and block-based scanning. It’s worth asking directly whether the new collector changes the heap-erasure timing story — and the honest answer is that it doesn’t change the fundamental shape of the problem. Green Tea GC is a redesign of how the collector scans and organizes memory for better cache behavior; it isn’t a redesign of when collection cycles run relative to your code’s execution. Heap objects inside a secret.Do closure still wait for a collection cycle to reach them under Green Tea GC exactly as they did under the previous collector — the pacer that decides when a GC cycle starts is still driven primarily by heap growth relative to the last cycle, not by anything aware that a particular object holds sensitive data. A faster, more cache-friendly collector might mean that cycle arrives sooner in wall-clock terms under some allocation patterns, but that’s an incidental side effect of general performance work, not a guarantee anyone should build security reasoning on top of.
Hidden Performance Traps in Go That Mid-level Devs Keep Hitting Most Go codebases that end up slow weren't written by juniors who didn't know what they were doing — they were written by competent developers...
If you need the collection to happen sooner than the pacer would naturally trigger it, Go exposes runtime.GC() as a blocking call that forces a full collection cycle immediately, including processing objects that have become unreachable. Calling it right after a secret.Do block that you know allocated to the heap will shrink the exposure window down to roughly the cost of that forced cycle, rather than however long the pacer would otherwise wait. This isn’t free — a forced full GC cycle is real CPU work, and calling it on every sensitive operation in a hot path will show up in your latency numbers — but for code paths that run rarely and handle genuinely sensitive material, trading that cost for a bounded exposure window is a reasonable, deliberate choice rather than hoping the pacer happens to run soon.
A practical checklist if you’re adopting this now
Treat every allocation inside a secret.Do closure as heap-bound until you’ve confirmed otherwise with -gcflags="-m" — don’t assume fixed-looking code stays on the stack just because it doesn’t obviously need dynamic sizing; closures capturing variables, interfaces, and slices passed to functions the compiler can’t fully inline are common escape triggers that aren’t visually obvious. Prefer fixed-size arrays over slices for key material wherever the size is genuinely known ahead of time, since arrays are far more likely to stay stack-resident than a `make`-allocated slice. Confirm your deployment targets are actually linux/amd64 or linux/arm64 before relying on this for anything cross-platform, and have an explicit fallback — even if that fallback is simply documenting that the guarantee doesn’t apply on other platforms — rather than silently shipping code that assumes protection it doesn’t have everywhere it runs.
Quick answers
Does secret.Do slow down my code? The package documentation doesn’t publish specific overhead numbers as of this experimental release, and the cost will depend heavily on how much of your closure’s data stays stack-resident versus escapes to the heap. Given the feature is gated behind an explicit build flag and framed for narrow cryptographic use, treat any performance claim you see elsewhere as unverified until the package stabilizes and ships benchmarks.
Can I use this on Windows or macOS today? No — as of Go 1.26, the platform support is limited to linux/amd64 and linux/arm64. Code that needs this guarantee on other platforms has no equivalent yet.
Should my application code call secret.Do directly? Generally no, unless you’re specifically building a cryptographic library. Application developers are better served relying on higher-level libraries that adopt this internally once it stabilizes, rather than taking on the escape-analysis reasoning this API requires to use correctly.
Title tag
Go 1.26 runtime/secret: The Heap Erasure Gap Explained
Meta description
Go 1.26’s runtime/secret erases stack memory instantly, but heap allocations inside secret.Do wait for garbage collection — here’s how to spot the difference in your own code.
Slug suggestion
go-runtime-secret-heap-erasure-gap
Written by: