Handling Keys and Secrets
Where signing keys, client secrets and API keys should live — and the places they keep ending up instead.
Every scheme in this course rests on a secret staying secret. That makes secret handling part of authentication, not a separate ops concern.
The hierarchy, best to worst
- No secret at all — workload identity, mTLS from a mesh. Nothing to leak.
- A managed secret store — Vault, AWS Secrets Manager, GCP Secret Manager. Versioned, audited, rotatable.
- Injected environment variables from a platform secret store, never committed.
- An encrypted file in the repository (SOPS, sealed secrets) with the key held elsewhere.
- A plaintext
.envin.gitignore— acceptable locally, not in production. - Committed to git. Never. It is in every clone and every fork, permanently.
Never ship a secret to a browser
Anything in your frontend bundle is public. NEXT_PUBLIC_, VITE_ and REACT_APP_ prefixes exist to make that visible — a secret behind one of those prefixes is a published secret. If a browser needs to call a service with a key, put a server between them.
Rotate on a schedule
Rotation that only happens during incidents is rotation that has never been tested. Support two live values everywhere — two signing keys, two API keys, two webhook secrets — so rotation is: publish the new one, switch, retire the old one.
Assume it will leak, and plan the response
- Detect — enable push protection and secret scanning on your repositories.
- Revoke first, investigate second. A leaked credential is live until it is revoked.
- Rotating git history is not enough. Forks, clones, caches and CI logs keep the old value. Revoke it.
Do not log them
Redact Authorization, Cookie, Set-Cookie and any field named like a secret, in your logger — not at each call site. And check your error reporter: full request bodies are a common accidental exfiltration path.
Example
# Generate real secrets — not "changeme", not the project name
openssl rand -base64 48 # session / JWT signing secret
openssl rand -hex 32 # webhook signing secret
# Keep them out of git
cat >> .gitignore <<'EOF'
.env
.env.*
!.env.example
*.pem
*-key.pem
EOF
# Commit the SHAPE, never the values
cat > .env.example <<'EOF'
APP_URL=https://abc.com
ACCESS_SECRET= # openssl rand -base64 48
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
EOF
# Check what is already committed before it becomes someone else's problem
git log --all -p | grep -nE '(secret|password|api_key|BEGIN [A-Z ]*PRIVATE KEY)' | headWhen to use it
- A leaked signing key is revoked within minutes by rotating to the second published key, invalidating every outstanding token.
- A pre-commit hook blocks a private key from being committed, avoiding a permanent presence in the repository history.
- A team discovers a Stripe secret key behind a NEXT_PUBLIC_ prefix in their bundle and moves the call to a server route.
More examples
Fail fast on missing or weak configuration
Treating an unconfigured OAuth provider as 'feature off' rather than 'crash' is the pattern that lets one codebase run in environments with different capabilities.
// config.js — validate at boot. A missing secret must never become a runtime
// surprise on a route nobody exercised in staging.
function required(name, { minLength = 0 } = {}) {
const value = process.env[name];
if (!value) throw new Error(`Missing required environment variable: ${name}`);
if (value.length < minLength) {
throw new Error(`${name} must be at least ${minLength} characters`);
}
return value;
}
const WEAK = new Set(['secret', 'changeme', 'password', 'test', 'dev', 'abc123']);
function requireStrong(name, minLength) {
const value = required(name, { minLength });
if (WEAK.has(value.toLowerCase())) {
throw new Error(`${name} is a placeholder value — generate a real secret`);
}
return value;
}
export const config = {
appUrl: required('APP_URL'),
accessSecret: requireStrong('ACCESS_SECRET', 32),
sessionSecret: requireStrong('SESSION_SECRET', 32),
google: process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
? { id: process.env.GOOGLE_CLIENT_ID, secret: process.env.GOOGLE_CLIENT_SECRET }
: null, // feature simply does not render when unconfigured
};
// Refuse to start in production with anything missing.
if (process.env.NODE_ENV === 'production') {
for (const name of ['APP_URL', 'ACCESS_SECRET', 'SESSION_SECRET', 'DATABASE_URL']) {
required(name);
}
}Redacting secrets in logs and error reports
Logging req.path rather than req.originalUrl drops the query string entirely, which is the cheapest way to keep any token someone put in a URL out of your logs.
const SENSITIVE_HEADERS = new Set([
'authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-xsrf-token',
'proxy-authorization',
]);
const SENSITIVE_FIELDS = /(password|secret|token|key|credential|authorization)/i;
function redact(value, depth = 0) {
if (depth > 4 || value == null) return value;
if (Array.isArray(value)) return value.map((v) => redact(v, depth + 1));
if (typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value).map(([k, v]) =>
[k, SENSITIVE_FIELDS.test(k) ? '[redacted]' : redact(v, depth + 1)]));
}
// Request logging
app.use((req, res, next) => {
logger.info({
method: req.method,
path: req.path, // path, NOT originalUrl — no query string
headers: Object.fromEntries(Object.entries(req.headers).map(([k, v]) =>
[k, SENSITIVE_HEADERS.has(k.toLowerCase()) ? '[redacted]' : v])),
body: redact(req.body),
});
next();
});
// Error reporting — the usual accidental exfiltration path
Sentry.init({
beforeSend(event) {
if (event.request?.headers) {
for (const h of Object.keys(event.request.headers)) {
if (SENSITIVE_HEADERS.has(h.toLowerCase())) event.request.headers[h] = '[redacted]';
}
}
if (event.request?.data) event.request.data = redact(event.request.data);
delete event.request?.query_string; // tokens end up here more often than you think
return event;
},
});
Discussion