Where a secret lives
| Place | Verdict |
|---|---|
| in the source | never — it is in the history, in every clone, and on every laptop |
in a .env file, git-ignored | fine for local development |
| in the platform’s environment variables | the normal answer for a small service |
| in a secret manager (Vault, AWS/GCP secret manager) | the answer once there is more than one service |
| in the browser | there are no secrets in the browser — see below |
Bundled, minified and obfuscated are all the same thing: the user can read it. If a third-party API needs a secret key, the call belongs on your server. A "publishable" key — Stripe’s, a maps key — is designed for this and is restricted by origin on their side.
Configuration that refuses to start
A service that starts with SESSION_SECRET undefined signs cookies with "undefined". Checking at boot turns a silent security failure into a deployment that does not go out — which is the outcome you want.
Commit a .env.example with every key name and no values, so the list is visible and a new machine fails with a readable message.
const REQUIRED = ["DATABASE_URL", "SESSION_SECRET", "MAIL_API_KEY"];
const missing = REQUIRED.filter((name) => !process.env[name]);
if (missing.length) {
throw new Error(`Missing configuration: ${missing.join(", ")}`);
}
Secrets in logs, errors and URLs
Redact by key name, on the way into the logger, not at the call site — a denylist applied once, centrally, catches the console.log somebody adds next year. And never put a token, a password reset code or a personal identifier in a query string: a POST body or a header is invisible to every log on the path.
console.log("request", req.headers); // Authorization, Cookie
console.error(err); // the connection string in the message
fetch(`/reset?token=${token}`); // the URL is in server logs, in the Referer,
// in browser history, and in analytics
The morning after a leak
- Rotate first. The key is compromised from the moment it was pushed, whatever the repository’s visibility. Deleting the commit does not reach the clones, the forks, the CI caches or the scrapers that read every public push within seconds.
- Then revoke the old one, and check what it did while it was out — the provider’s audit log is the point of that log.
- Then clean the history if you like, with
git filter-repoor the platform’s tool, knowing it does not help with step one. - Then make it harder: a pre-commit secret scanner, and the platform’s push protection.
Rotation as a routine
A key that has never been rotated cannot be rotated in a hurry. Support two valid keys at once — the old and the new — so a rotation is: add the new one, deploy, switch, remove the old one. The same pattern as the expand-and-contract migration in the git course, and the reason a rotation becomes a fifteen-minute job instead of an outage.