Skip to content

JWT secret — persist, generate, or fail

Source: atrium/backend/lib/constants.jsresolveJwtSecret() Category: Pattern — authentication

JWT secret persist-or-generate — a tiny resolver that tries the environment variable first, falls back to a persisted file next, generates a new secret in dev if nothing exists, and refuses to start in production without an explicit secret. Eliminates the “where does my JWT secret come from” footgun.

At startup, the backend runs one function (resolveJwtSecret()) that returns the secret to use. The order of preference is:

  1. process.env.JWT_SECRET — if set, use it verbatim (prod practice)
  2. .jwt-secret file next to the backend — read and use (development continuity)
  3. Dev-only: generate a random 64-byte secret, write it to .jwt-secret, use it
  4. Prod (NODE_ENV=production) without (1) or (2): throw and refuse to boot

Gitignore the .jwt-secret file.

The problem: JWT secrets fail in opposite ways:

  1. Hardcoded default — every fork of your code shares a secret; trivially forged tokens.
  2. Required env var at dev time — every developer needs to set up an env var before the app runs, raising onboarding friction.
  3. Random per-restart — every restart invalidates all existing sessions.

The fix: cascade through the options. Dev gets frictionless startup; prod can’t boot without an explicit secret; restarts in dev preserve sessions across the persisted file.

backend/lib/constants.js
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const JWT_SECRET_FILE = path.join(__dirname, '..', '.jwt-secret');
function resolveJwtSecret() {
// 1. Env var wins
if (process.env.JWT_SECRET) return process.env.JWT_SECRET;
// 2. Persisted file (dev continuity)
if (fs.existsSync(JWT_SECRET_FILE)) {
return fs.readFileSync(JWT_SECRET_FILE, 'utf8').trim();
}
// 3. Generate in dev
if (process.env.NODE_ENV !== 'production') {
const secret = crypto.randomBytes(64).toString('hex');
fs.writeFileSync(JWT_SECRET_FILE, secret, { mode: 0o600 });
console.warn('[auth] generated new JWT_SECRET; persisted to .jwt-secret');
return secret;
}
// 4. Fail in prod
throw new Error('JWT_SECRET is required in production. Set it in the environment.');
}
module.exports = { JWT_SECRET: resolveJwtSecret() };

Make sure .jwt-secret is gitignored:

.gitignore
.jwt-secret
  • AtriumJWT_SECRET resolved once at startup and exported from constants.js
  • Pattern generalizes to any secret that needs (dev convenience, prod enforcement, restart continuity): session encryption keys, cookie signing, webhook HMAC secrets
  • File permissions matter. Write with { mode: 0o600 } (owner read/write only). Forgetting this means anyone on the host can read the secret. On shared hosts this is a real risk.
  • Don’t log the secret, not even in warn. The generate branch should only say “generated a new secret”, never include the value.
  • Restarts rotate the secret if the file is missing. If your deploy strategy wipes the app directory and doesn’t persist .jwt-secret, every deploy invalidates sessions. Either symlink the file to a persistent location (e.g. CAIRN_DATA_DIR) or set the env var.
  • NODE_ENV is the prod signal. If your prod setup forgets NODE_ENV=production, the “generate in dev” branch kicks in and your prod server quietly creates .jwt-secret without anyone noticing. Belt-and-suspenders: also check !isTTY or the presence of production-specific env vars.
  • Secret length matters. 64 bytes hex = 128 chars = 512 bits. Plenty for HS256. For RS256/ES256 you’d use a keypair, not a secret; different pattern, different file.
  • Rotate by generating a new secret and invalidating old tokens. Two ways: keep a list of valid secrets and check against each (supports overlap), or just rotate and force re-login. Atrium’s model is the latter — simpler, but all-or-nothing.