← All news
security6 min read

The JWT secret fallback that never gets removed

By Seaworthy · 14 August 2026

A padlock resting on a laptop keyboard

Photo by Zaqy Al Fattah on Unsplash

Ask an AI assistant to wire up JWT auth for an Express app and there's a good chance you'll get this line somewhere near the top:

const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me'

It reads like a reasonable default. If the environment variable is set, it's used. If it's not, the app still boots instead of crashing on startup, which feels like good developer experience. That's precisely why it survives into production: nothing ever forces the question of whether JWT_SECRET is actually set on the server that matters.

The fallback is not a placeholder, it's a permanent option

The string 'dev-secret-change-me' isn't hidden. It's sitting in the git history of every repo that was ever generated from this pattern, and in a fair number of tutorials and starter templates besides. If a deployment ever runs without JWT_SECRET in its environment, either because the variable was misnamed, the secrets manager returned an error that got swallowed, or someone just forgot, the app doesn't fail loudly. It falls back and keeps serving requests, signing every token with a string an attacker can find by searching GitHub.

Once someone has the signing secret, they don't need to guess a password or break a hash. They can forge a valid token for any user ID, including an admin account, and the server will accept it because the signature checks out. There's no brute force involved and no rate limit to hit, because the token is simply correct.

Why this is worse than a missing config value

Compare it to what happens if the code instead does this:

const JWT_SECRET = process.env.JWT_SECRET
if (!JWT_SECRET) {
  throw new Error('JWT_SECRET is required')
}

Now a missing environment variable is a boot failure. It's loud, it happens in staging before it happens in production, and it gets fixed before anyone can log in with a forged token, because nobody can log in at all until it's fixed. The failure mode changes from silent and exploitable to obvious and safe.

This is the general shape of the fix for any secret with a fallback: a missing secret should stop the app, not quietly downgrade it. An app that's broken because a secret is missing is annoying. An app that's running fine because a secret is missing is a security incident waiting for someone to notice the repo is public.

Why the assistant reaches for the fallback in the first place

The || pattern is a completely reasonable default for things like a port number or a log level, where the fallback value is genuinely fine to run with. An assistant trained on that pattern will apply the same shape to a secret, because syntactically it's the same kind of statement: get a value from the environment, fall back if it's missing. Nothing in the code itself distinguishes "safe to default" from "must be present or nothing works," and that distinction lives entirely in what the value is used for.

The fix takes one extra line and doesn't touch anything else in the auth flow. It's not a redesign, it's a guard clause. The hard part is remembering to check for this specific pattern, because it looks identical to a dozen other environment variable reads that really are fine to default.

It's rarely just the one variable

Once you go looking for JWT_SECRET, the same shape tends to show up on SESSION_SECRET, COOKIE_SECRET, and any encryption key used for things like resetting a password or signing a webhook payload. Auth code tends to get written in one sitting, copied from one project to the next, and reviewed less than the rest of the codebase because "it already works." That makes it exactly the kind of place a pattern like this spreads quietly: one generated auth module with three or four fallback secrets in it, reused as the starting point for every new service afterward.

It's also worth checking whether the fallback secret is short and guessable rather than absent entirely. A fallback of 'secret' or 'changeme' has the same effect as no secret at all, since both are trivially available to anyone who's ever read the source or a public template that used the same convention.

Checking for this in an existing codebase

A quick grep across the codebase for process.env.JWT_SECRET ||, process.env.SESSION_SECRET ||, and similar patterns against any signing or encryption key will usually surface every instance in a repo of any size. It's worth doing this once even in a codebase that's been running for years, because the pattern tends to get copy-pasted into new services faster than it gets audited out of old ones.

Seaworthy flags hardcoded fallback values on secrets used for signing, encryption, or session handling, since a missing production secret should be a startup crash, not a silent downgrade.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

Seaworthy scans your repo for the issues covered in articles like this one.

Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.

$ npx seaworthycode