🔑 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,
.envchecked in by accident. Add them to.gitignoreand scan with tools likegitleaks. - 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):
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
- Attacking weak crypto — why hardcoded keys are a crypto failure.
- Authentication & authorization — the tokens and signing keys you’re protecting.
- Supply-chain security — secrets in CI/CD and dependencies.
- Structured logging — slog.LogValuer for redaction.
Next: securing everything your code depends on — supply-chain security.
Related topics
How good algorithms get broken by bad usage — dictionary attacks on fast hashes, ECB pattern leakage, nonce reuse, hardcoded keys, weak randomness, and timing side-channels — and how to avoid each.
defenseAuthentication & AuthorizationProving who you are and deciding what you may do — sessions vs tokens, secure token generation and constant-time checks, password verification, and least-privilege authorization (RBAC).
defenseSupply-Chain SecuritySecuring everything your code depends on — module integrity via go.sum and the checksum database, govulncheck for known CVEs, minimizing dependencies, pinning, and defending against typosquatting and build-time attacks.
Check your understanding
Score: 0 / 51. 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.