{} The Go Reference

Defense · Security · Intermediate

Secrets Management

Keeping API keys, passwords, and signing keys out of your code, repo, logs, and binary — config from the environment, secret managers, redaction, and rotation.

Defense Intermediate ⏱ 4 min read Complete

🔑 Analogy

A secret in your source code is a house key taped to the front door. “It’s a private repo” is taping it under the mat — marginally less obvious, still the first place anyone looks, and still there after you move out (git history). The right place for keys is a lockbox you open at runtime: the environment hands the running process its keys, and they never live in the blueprint everyone can read.

Where secrets must not be

The first rule is subtractive — keep secrets out of:

  • Source code — committed to git history forever, recoverable from the binary with strings.
  • The repo at all — config files, .env checked in by accident. Add them to .gitignore and scan with tools like gitleaks.
  • The container image — baked-in secrets ship to every registry and layer.
  • Logs and error messages — aggregated, retained, and widely readable.

Where secrets should come from

graph TD
CODE["your code<br/>(committed)"] -.->|NEVER| SECRET
ENV["environment vars"] --> APP["running process"]
FILE["mounted secret file"] --> APP
MGR["secrets manager<br/>(Vault, cloud)"] --> APP
APP --> SECRET["secret in memory only"]

The 12-factor principle: config — including secrets — is injected from the environment at runtime, never committed. The same built artifact runs everywhere with different config. A secrets manager (Vault, AWS/GCP Secrets Manager) adds access control, audit logs, rotation, and short-lived dynamic credentials on top.

See it: load config from the environment, fail fast, redact

Read secrets from the environment, fail loudly if a required one is missing, and redact secrets so they can’t leak through logs. This runs here (it sets env vars itself for the demo):

config.go — editable & runnable
package main

import (
"fmt"
"os"
)

// Secret is a string that refuses to print itself in logs.
type Secret string

func (Secret) String() string { return "[REDACTED]" }
func (s Secret) Reveal() string { return string(s) }

type Config struct {
DBHost   string
APIKey   Secret
}

func load() (Config, error) {
host, ok := os.LookupEnv("DB_HOST")
if !ok {
	return Config{}, fmt.Errorf("missing required env DB_HOST")
}
key, ok := os.LookupEnv("API_KEY")
if !ok {
	return Config{}, fmt.Errorf("missing required env API_KEY")
}
return Config{DBHost: host, APIKey: Secret(key)}, nil
}

func main() {
// Pretend the platform injected these at runtime.
os.Setenv("DB_HOST", "db.internal:5432")
os.Setenv("API_KEY", "sk_live_supersecret")

cfg, err := load()
if err != nil {
	fmt.Println("startup error:", err)
	return
}
// Logging the whole config does NOT leak the key:
fmt.Printf("config: %+v\n", cfg)
// The code that needs it calls Reveal() explicitly:
fmt.Println("key length:", len(cfg.APIKey.Reveal()))
}

The Secret type prints [REDACTED] even when the whole struct is logged — so an accidental log.Printf("%+v", cfg) can’t leak the key, while code that genuinely needs the value calls Reveal() explicitly. Failing fast on a missing variable beats discovering it at the first request.

Rotation and dynamic secrets

Assume every secret will eventually leak; rotation caps how long a leaked one is useful. The payoff of loading secrets from the environment or a manager (not code) is that rotation becomes a config change, not a redeploy. The strongest version is dynamic secrets: a manager issues a database credential valid for an hour, so a leak expires almost immediately.

🐹 The secrets hygiene checklist

Never commit secrets (use .gitignore + a scanner like gitleaks in CI). Inject from env/mounted files/a manager at runtime. Redact in logs with a custom type or slog.LogValuer. Scope each secret to least privilege and rotate on a schedule. Fail fast at startup if a required secret is missing. And remember: base64 and “it’s in a private repo” are not protection — only keeping the secret out of the artifact is.

⚠️ A committed secret is leaked — rotate, don't just delete

If a secret lands in git, removing it in a later commit does not fix it: it’s still in history, and history is cloned and forked. The only real remediation is to rotate the secret (invalidate the old one) — and then optionally scrub history. The same applies to a secret printed in logs or pasted in a ticket: treat it as compromised the moment it leaves the lockbox. Build the assumption “this will leak someday” into your design so rotation is routine, not an emergency.

See also

Next: securing everything your code depends on — supply-chain security.

Check your understanding

Score: 0 / 5

1. Why is a secret hardcoded in source code (even a private repo) dangerous?

A committed secret is in git history even after you 'delete' it (and history gets cloned, forked, leaked). It's also recoverable from the binary with `strings`. Private repos are routinely exposed via misconfig, insider access, or laptop theft. And changing a hardcoded secret means editing code and redeploying. Secrets belong outside the code entirely.

2. What's the standard place to inject secrets into a 12-factor app?

The 12-factor principle is strict separation of config from code: secrets come from the environment at runtime — env vars, a mounted file, or a secrets manager injecting them — so the same artifact runs in dev/staging/prod with different config and no secret is ever committed. base64 is encoding, not protection; it's just as exposed.

3. What does a dedicated secrets manager (Vault, AWS Secrets Manager, etc.) add over plain env vars?

Env vars are a fine baseline but static and unaudited. A secrets manager stores secrets encrypted, controls who/what can read each one, logs every access, rotates them on a schedule, and can issue dynamic short-lived credentials (e.g. a DB password valid for an hour). That shrinks the blast radius of any leak and gives you an audit trail.

4. Why must you keep secrets out of logs and error messages?

Logs flow to aggregation systems, get retained for months, and are visible to far more people than your database. Accidentally logging a token, a full request with an Authorization header, or a connection string with a password leaks it broadly and persistently. Redact secrets before logging (custom String()/LogValue, a redacting type) and never log raw requests/configs that contain them.

5. Why does secret rotation matter, and what makes it easy?

Assume any secret will eventually leak. Regular rotation caps the window a leaked credential works, and a manager that issues short-lived dynamic secrets shrinks it to near zero. Rotation is painful only when secrets are baked into code/images (every change = rebuild); when they come from the environment or a manager, rotating is a config update the app picks up — design for it from day one.

Comments

Sign in with GitHub to join the discussion.