#Purpose
Rules for handling API keys, database passwords, signing keys and tokens.
The operating assumption: a secret in source control is already compromised. Git history is permanent, forks are uncontrolled, and scanners crawl public repositories continuously. Treat "we will remove it later" as "we have rotated it" — because removing it without rotating changes nothing.
#Where secrets live
| Location | Verdict |
|---|---|
| Secret manager (Vault, AWS Secrets Manager, GCP Secret Manager, 1Password) | Preferred — audited, rotatable, access-controlled |
| KMS / HSM for signing and encryption keys | Preferred — the key never leaves the boundary |
| Platform-injected environment variables | Acceptable — the common baseline |
| CI/CD provider secret store | Acceptable for build-time credentials |
.env file, gitignored, local development only | Tolerable — never in an image or a deployed host |
Committed .env, config file, or source constant | Never |
| Client bundle, mobile app, browser storage | Never — shipped to every user |
Never commit a secret "temporarily". Never paste one into an issue, a pull request, a chat message, or a support ticket — those systems are searchable and often exportable.
#Environment variables — the caveats
Environment variables are the common baseline, and they leak in specific ways worth knowing:
- They appear in crash dumps and error reporters. Scrub
process.envbefore sending a report to Sentry or similar. - They are readable by every process the user runs, and on Linux via
/proc/<pid>/environfor the same user. docker inspectshows them for a running container.- They land in shell history when set inline on a command.
- Child processes inherit them. A build step that shells out passes every secret along.
js// Fail fast and loudly at startup rather than sending `undefined` as a key.
const required = ["DATABASE_URL", "JWT_SIGNING_KEY", "STRIPE_SECRET_KEY"];
const missing = required.filter((k) => !process.env[k]);
if (missing.length) {
throw new Error(`Missing required secrets: ${missing.join(", ")}`);
}
Never log process.env, and never interpolate a secret into a log line, a
URL, or an error message.
#Keeping them out of the repository
gitignore.env .env.* !.env.example *.pem *.key *.p12 credentials.json service-account*.json
Commit a .env.example with keys and empty values only — never real values —
so a contributor knows what is required.
Run a secret scanner in CI and as a pre-commit hook (gitleaks, trufflehog,
detect-secrets, or GitHub push protection). Scan the full history, not just
the diff, when onboarding an existing repository.
Never rely on .gitignore alone. It does not protect a file already tracked,
and git add -f bypasses it.
#Containers and builds
- Never use
ENV SECRET=…orARG SECRET=…in aDockerfile. Both persist in the image layers and are readable withdocker historyby anyone who can pull the image. - Use build secrets that are not committed to a layer:
dockerfile# syntax=docker/dockerfile:1 RUN --mount=type=secret,id=npm_token \ NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
- Inject runtime secrets through the orchestrator — Kubernetes
Secretmounted as a file, ECS task secrets, systemd credentials. - A Kubernetes
Secretis base64, not encrypted, at rest by default. Enable encryption at rest, restrict RBAC on thesecretsresource, and prefer an external-secrets operator backed by a real manager.
#Rotation
- Rotate on a schedule and immediately on any suspicion of exposure.
- Design every integration to support two valid credentials at once, so rotation is: issue new → deploy → verify → revoke old. Without overlap, rotation means downtime, and rotation that means downtime does not happen.
- Prefer short-lived, automatically issued credentials over long-lived static ones: IAM roles, workload identity, OIDC federation from CI. The best secret is the one that expires in an hour without anyone acting.
- Keep an inventory: what exists, who can read it, when it was last rotated. An unrotatable secret nobody owns is the one that ends up in an incident report.
#When a secret leaks
In this order:
- Revoke or rotate first. Not "remove the commit" — revoke. The old value is already cloned, cached and indexed.
- Check for use. Review provider audit logs from before the leak was noticed.
- Then clean history if you wish (
git filter-repo, BFG) and force-push. This is cosmetic; it does not un-leak anything and does not reach existing clones or forks. - Record it. What leaked, how, for how long, and what changed to prevent a repeat.
Never treat a history rewrite as remediation. Rotation is remediation.
#Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| API key committed "temporarily" | History is permanent; scanners are fast | Rotate; use a secret manager |
ENV SECRET= in a Dockerfile | Readable via docker history | --mount=type=secret |
| Secret in a client bundle or mobile app | Shipped to every user | Proxy through your backend |
console.log(process.env) | Secrets land in log aggregation | Never log the environment |
| Same key across dev, staging and prod | One compromise takes everything | Separate credentials per environment |
| No rotation because it causes downtime | Rotation never happens | Support two valid credentials |
| Deleting the commit instead of rotating | Clones and forks retain it | Revoke first |
Kubernetes Secret assumed encrypted | Base64 is encoding | Encryption at rest + RBAC |
| Secret in a URL query string | Access logs, Referer, history | Header or request body |
#Checklist
- Verify: No secret appears in source, config, or committed
.envfiles - Verify:
.gitignorecovers.env*,*.pem,*.key, service-account JSON - Verify:
.env.examplelists keys with empty values only - Verify: A secret scanner runs in CI and over full history
- Verify: Production secrets come from a secret manager or orchestrator injection
- Verify: Required secrets are validated at startup with a clear failure
- Verify:
process.envis never logged and is scrubbed from error reports - Verify: No
ENV/ARGsecrets in Dockerfiles; build secrets use--mount=type=secret - Verify: Kubernetes secrets have encryption at rest and restricted RBAC
- Verify: Each environment has distinct credentials
- Verify: Every integration supports two valid credentials for zero-downtime rotation
- Verify: Short-lived federated credentials used where the platform supports them
- Verify: A written leak procedure exists that starts with revocation
#Anchors (restated last, read last)
The rules that must hold when you stop, repeated here because the end of the context is what you act on:
-
Never commit a secret "temporarily". Never paste one into an issue, a pull request, a chat message, or a support ticket — those systems are searchable and often exportable.
-
Never log
process.env, and never interpolate a secret into a log line, a URL, or an error message. -
Never rely on
.gitignorealone. It does not protect a file already tracked, andgit add -fbypasses it. -
Never use
ENV SECRET=…orARG SECRET=…in aDockerfile. Both persist in the image layers and are readable withdocker historyby anyone who can pull the image. - Use build secrets that are not committed to a layer: -
Never treat a history rewrite as remediation. Rotation is remediation.
-
No secret appears in source, config, or committed
.envfiles -
.gitignorecovers.env*,*.pem,*.key, service-account JSON -
.env.examplelists keys with empty values only -
A secret scanner runs in CI and over full history
-
Production secrets come from a secret manager or orchestrator injection
-
Required secrets are validated at startup with a clear failure
Before reporting done, prove the module still imports — run the line for this stack and paste its output:
bashpython -c "import <package>" # Python: the package you changed
node -e "require('./<entry>')" # Node CJS, or: node --input-type=module -e "import './<entry>.js'"
go build ./... # Go