Your Secrets Survive in Memory: Go's runtime/secret Changes That
Go's new runtime/secret package addresses a blind spot in secret management — what happens to sensitive data after it's loaded into memory. A deep dive into when and how to use it.
You’ve Secured the Vault. What About the Memory?
You’ve done everything right. Secrets are in HashiCorp Vault. They’re injected at runtime through environment variables or mounted secret files. Your Kubernetes pods use RBAC-scoped service accounts. Rotation policies are in place.
And then your application loads that database password into a Go string, and it sits in memory — in plaintext — for the entire lifetime of the process. Maybe longer, if the garbage collector hasn’t gotten around to it.
Most of us stop thinking about secrets the moment they leave the vault. Go 1.26 introduces a package that picks up exactly where that thinking ends.
The Gap Nobody Talks About
Here’s the mental model most teams operate with:
Secrets at rest — encrypted in a vault, KMS, or sealed secret. Solved.
Secrets in transit — TLS everywhere, mTLS between services. Solved.
Secrets in use — loaded into process memory as plaintext strings, byte slices, or struct fields. Accessible via memory dumps, core dumps, /proc/<pid>/mem, or swap files. Not solved.
This isn’t theoretical. Memory-scraping attacks are how some of the most damaging breaches have worked. If an attacker gets read access to your process memory — through a container escape, a debug endpoint left open, or a compromised sidecar — every secret your application has loaded is right there.
The industry has largely accepted this as an unavoidable trade-off. Go’s runtime/secret package says: it doesn’t have to be.
What runtime/secret Actually Does
The package is small. Two functions:
package secret
func Do(f func())
func Enabled() bool
secret.Do executes a function and guarantees that after it returns:
- Registers used by the function are zeroed
- Stack memory used by the function is zeroed
- Heap allocations made inside the function are zeroed as soon as the garbage collector determines they’re unreachable
This works even if the function panics or calls runtime.Goexit.
import "runtime/secret"
func decryptPayload(ciphertext []byte, key []byte) []byte {
var plaintext []byte
secret.Do(func() {
// key material, intermediate buffers, expanded key
// schedules — all of this gets erased after Do returns
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
plaintext, _ = gcm.Open(nil, nonce, ciphertext, nil)
})
// At this point: registers zeroed, stack zeroed,
// internal AES key schedule will be zeroed by GC
return plaintext
}
secret.Enabled() reports whether the current goroutine is executing inside a Do call. This lets library authors adapt behavior — for example, a crypto library could use more aggressive memory management when it knows erasure guarantees are active.
As of Go 1.26, this is experimental. You must build with GOEXPERIMENT=runtimesecret to enable it. On unsupported platforms (anything other than linux/amd64 and linux/arm64), Do invokes the function directly without erasure guarantees.
When to Use It — And When Not To
This is where most articles would stop. But the practical question is: should you wrap every secret operation in secret.Do?
No.
Use It For
Cryptographic key operations — This is the primary use case. TLS handshakes, encryption/decryption, HMAC computation, key derivation. Anywhere ephemeral key material exists that enables forward secrecy.
Short-lived credential processing — Parsing a JWT, validating an API key, performing an OAuth token exchange. The sensitive material should exist in memory for the minimum time possible.
Secret rotation handlers — When your application receives a new secret and decrypts or processes the old one, both values are briefly in memory. secret.Do ensures the old material is cleaned up.
Don’t Use It For
Long-lived configuration — If your database connection pool holds a password for the lifetime of the process, wrapping the initial load in secret.Do doesn’t help. The password lives on in the connection pool’s memory anyway.
Application-level business logic — secret.Do has performance implications. Heap allocations inside it increase GC sweep times. Don’t wrap your entire request handler because it touches a user’s email address.
As a replacement for proper secret management — This is defense in depth, not a substitute. If your secrets aren’t encrypted at rest and in transit, memory erasure is the least of your problems.
The Go team explicitly states this package is “mainly for developers of cryptographic libraries, not for application developers.” If you’re building on top of crypto/tls or crypto/aes, those libraries will eventually use secret.Do internally — and you’ll get the benefits without changing your code.
The Performance Trade-Off
There’s no free lunch. Here’s what secret.Do costs:
- Register and stack zeroing — happens synchronously before
Doreturns. Microseconds, but not zero. - Heap allocation tracking — the runtime must track which allocations were made inside
Doso the GC can zero them. This increases memory overhead. - GC sweep time — zeroing heap memory during garbage collection takes longer than simply reclaiming it.
For crypto operations that already take milliseconds, this overhead is negligible. For hot paths processing thousands of requests per second, benchmark before adopting.
// Good: crypto operation, called infrequently
secret.Do(func() {
privateKey := loadKeyMaterial()
signature := sign(payload, privateKey)
resultChan <- signature
})
// Bad: wrapping a hot path unnecessarily
for _, req := range requests {
secret.Do(func() { // Don't do this
processRequest(req)
})
}
Limitations to Understand
Before you adopt this, know what it cannot protect:
- Global variables written inside
Do— if you assign to a package-level var, that memory isn’t tracked - New goroutines spawned inside
Do— erasure guarantees don’t follow goroutine boundaries, and this will panic - Pointer addresses — the GC’s internal data structures may retain pointer values even after the pointed-to memory is zeroed
- Swap and core dumps — if the OS swaps your process memory to disk,
secret.Docan’t help. Usemlockor disable swap for high-security workloads
Heap allocations inside Do are only zeroed when the garbage collector runs. If you need immediate erasure, explicitly zero the slice (for i := range buf { buf[i] = 0 }) and then let the GC handle what you can’t reach.
Practical Integration
Here’s a realistic pattern for a service that decrypts secrets from a vault at startup:
func loadSecrets(vaultClient *vault.Client) (*AppConfig, error) {
var config AppConfig
secret.Do(func() {
// Fetch and decrypt — all intermediate materials
// (vault token, decryption key, raw secret bytes)
// will be erased after this block
raw, err := vaultClient.Logical().Read("secret/data/myapp")
if err != nil {
panic(fmt.Errorf("vault read: %w", err))
}
// Copy only what we need into caller-owned memory
config.DBHost = raw.Data["db_host"].(string)
config.DBPort = raw.Data["db_port"].(string)
// The raw vault response, auth tokens, and any
// intermediate decryption buffers get erased
})
return &config, nil
}
Notice the pattern: copy results out, let everything else be erased. The vault response object, any intermediate decryption buffers, temporary token materials — all zeroed. Only the final values you explicitly copy survive.
The Bigger Picture: Defense in Depth
Secret management has always been about layers:
- At rest — Vault, KMS, sealed secrets
- In transit — TLS, mTLS
- At the boundary — environment variables, mounted files, sidecar injection
- In memory —
runtime/secret(you are here)
Most teams have layers 1-3 locked down. Layer 4 has been the accepted gap. It’s the reason security auditors ask about core dump policies and swap encryption — they know that once a secret is in process memory, it’s a liability.
runtime/secret doesn’t eliminate that liability entirely. But it shrinks the window from “lifetime of the process” to “duration of the cryptographic operation.” For forward secrecy protocols, that’s the difference between “compromise the server and decrypt all past traffic” and “compromise the server and decrypt nothing.”
That’s a meaningful difference. Not a silver bullet — but a real, tangible reduction in attack surface for Go applications handling sensitive cryptographic material.
If you’re building or maintaining Go services that handle encryption keys, auth tokens, or any form of secret material — keep an eye on this package. It’s experimental today, but the problem it solves has been an open wound in every garbage-collected language for decades.
Other posts
See all posts
Why Your Laptop GPU Will Never Work with Proxmox Passthrough
Six attempts, three days, one stubborn MX330. A post-mortem on laptop GPU passthrough in Proxmox — what failed, why it's architecturally impossible, and exactly what to buy instead.
Your Old Laptop Is a Linux Server: A Complete Proxmox Home Lab Setup Guide
Turn an old laptop into a full Proxmox home server — Wi-Fi NAT routing, DHCP, MTU fixes, and every gotcha you'll actually hit. A practical guide that also teaches you how AWS works.
Quantum Computing for the Curious Developer: Building Your First Circuits with Qiskit
A developer's hands-on guide to quantum computing fundamentals — from qubits and gates to building a quantum teleportation circuit, no physics degree required.