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.

go security secrets cryptography runtime
7 min read 1,345 words
Your Secrets Survive in Memory: Go's runtime/secret Changes That

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.

Important

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 logicsecret.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.

Note

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:

  1. Register and stack zeroing — happens synchronously before Do returns. Microseconds, but not zero.
  2. Heap allocation tracking — the runtime must track which allocations were made inside Do so the GC can zero them. This increases memory overhead.
  3. 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.Do can’t help. Use mlock or disable swap for high-security workloads
Warning

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:

  1. At rest — Vault, KMS, sealed secrets
  2. In transit — TLS, mTLS
  3. At the boundary — environment variables, mounted files, sidecar injection
  4. In memoryruntime/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.

Dipankar Das

Dipankar Das

Designing & Building Scalable, Reliable Systems