JWT secret — persist, generate, or fail
Source: atrium/backend/lib/constants.js —
resolveJwtSecret()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.
What it is
Section titled “What it is”At startup, the backend runs one function (resolveJwtSecret()) that returns the secret to use. The order of preference is:
process.env.JWT_SECRET— if set, use it verbatim (prod practice).jwt-secretfile next to the backend — read and use (development continuity)- Dev-only: generate a random 64-byte secret, write it to
.jwt-secret, use it - Prod (
NODE_ENV=production) without (1) or (2): throw and refuse to boot
Gitignore the .jwt-secret file.
Why it exists
Section titled “Why it exists”The problem: JWT secrets fail in opposite ways:
- Hardcoded default — every fork of your code shares a secret; trivially forged tokens.
- Required env var at dev time — every developer needs to set up an env var before the app runs, raising onboarding friction.
- 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.
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:
.jwt-secretHow it’s used
Section titled “How it’s used”- Atrium —
JWT_SECRETresolved once at startup and exported fromconstants.js - Pattern generalizes to any secret that needs (dev convenience, prod enforcement, restart continuity): session encryption keys, cookie signing, webhook HMAC secrets
Gotchas
Section titled “Gotchas”- 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_ENVis the prod signal. If your prod setup forgetsNODE_ENV=production, the “generate in dev” branch kicks in and your prod server quietly creates.jwt-secretwithout anyone noticing. Belt-and-suspenders: also check!isTTYor 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.
See also
Section titled “See also”- patterns/jwt-refresh-rotation — the session-rotation pattern that sits on top of a JWT secret
- projects/atrium