Lesson 1 of 1 in Secrets Have No Fallbacks
A Default Is No Secret At All
The fallback exists so development is convenient, and it is the value running in production.
3 min read
Not yet reviewed
Somebody adds a signing secret. It needs a value locally, so it gets a fallback. The fallback is in the repository, the deploy forgets to set the real one, and the application starts perfectly.
Starts, and is broken
const SECRET = process.env.CSRF_SECRET ?? 'dev-secret-change-me'Refuses to start
const SECRET = process.env.CSRF_SECRET
if (!SECRET) throw new Error('CSRF_SECRET is not set')The first has no failure mode you can observe. Tokens are signed, they verify, everything works — with a key that is in a public repository or in every developer's shell history.
Why this one is worse than it looks
Most misconfiguration announces itself. A missing database URL crashes on the first query; a wrong port refuses connections. A missing secret with a fallback produces an application that behaves exactly correctly in every test you have, and is forgeable by anyone who has read the default.
Note
This is the shape worth learning, not the specific bug: a safety control whose absence looks identical to its presence. When you find one, the fix is always to make the absence loud.
Throwing on start, not on use
Check at startup rather than at the point of use. A secret validated lazily fails on the first request that needs it, which might be a week after the deploy and is certainly not while anybody is watching the logs.
Read every required secret when the process starts
Throw with the variable's name in the message
Never log the value, only the name
- Line 1A deploy that is going to fail should fail before it takes traffic, not on the first user who happens to hit the path.
- Line 2"Configuration error" costs somebody an hour. "
CSRF_SECRETis not set" costs them a minute. - Line 3The error will end up in a log aggregator, a ticket and possibly a screenshot. The name is enough to fix it.
How this platform enforces it
CSRF_SECRET and RATE_LIMIT_SECRET throw when unset. There is no fallback and no default, deliberately — the codebase treats a hardcoded fallback secret as identical to having no secret, because it is, and because it would ship.
That makes local development annoying, though. Every new contributor hits a wall.
They hit a wall with the variable's name in it, once, and then it is in their .env forever. Weigh that against the alternative, which is that nobody hits any wall and one day the wall is in production with everybody's tokens signed by a string from a README.
Which of these is the safest way to handle a missing signing secret?
Tip
Audit for this with a grep for ?? and || next to process.env. Every hit is either a genuinely optional setting or a secret with a default, and the two are easy to tell apart once you are looking.