JWT Signing Silently Fails? Check Your Node.js Environment Variable Loading Order
Encountered this issue while building a SaaS authentication system for a client. Here's the root cause and solution.
TL;DR
In Node.js ES Modules, import statements execute before dotenv.config(). If module-level code reads process.env.JWT_SECRET, it gets undefined, causing JWT signing to use the string "undefined" as the secret — no errors thrown, but all token verification fails. The fix: lazy initialization.
The Problem
JWT login returns 200, but all subsequent requests return 401. Investigation reveals:
- Tokens generated at login can't be verified by
jwtVerify() - Every server restart invalidates all previously issued tokens
console.log(process.env.JWT_SECRET)outputsundefined
// jwt.ts — module-level code
import crypto from 'crypto';
// ❌ This line executes BEFORE dotenv.config(), JWT_SECRET is undefined
const SECRET = crypto.createSecretKey(
new TextEncoder().encode(process.env.JWT_SECRET)
);
The worst part: no error is thrown. new TextEncoder().encode(undefined) encodes the string "undefined" into bytes, producing a valid but wrong secret key.
Root Cause
ES Module import statements are statically hoisted:
// server.ts (entry file)
import { router } from './routes/auth'; // ← runs first
import { authenticateToken } from './middleware/auth'; // ← runs first
dotenv.config(); // ← runs AFTER all imported modules execute
Execution order:
- Node.js scans all
importstatements and builds the dependency graph - Executes all imported modules' top-level code depth-first (
jwt.ts'sconst SECRET = ...runs here) - Returns to
server.ts, runsdotenv.config() - Now
.envis loaded intoprocess.env
So jwt.ts module-level code reads process.env.JWT_SECRET as undefined.
Solution
Option 1: Lazy Initialization (Recommended)
Move secret initialization into a function — env var is read on first call, not at import time:
import crypto from 'crypto';
let _secret: crypto.KeyObject | null = null;
function getSecret(): crypto.KeyObject {
if (!_secret) {
const secretValue = process.env.JWT_SECRET;
if (!secretValue) {
throw new Error('JWT_SECRET environment variable not set');
}
_secret = crypto.createSecretKey(
new TextEncoder().encode(secretValue)
);
}
return _secret;
}
// Use getSecret() everywhere the key is needed
export async function generateToken(payload: any): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.sign(getSecret()); // ← deferred until runtime
}
No dependency on entry file import order — safe regardless of when called.
Option 2: Call dotenv at the Very Top of Entry File
// server.ts — ensure these lines come before ALL imports
import 'dotenv/config'; // or require('dotenv').config()
import express from 'express';
// ...other imports
Limitation: If another entry point (cron job, worker) forgets this line, the bug resurfaces.
Caveats
Caveats
- This pitfall affects all module-level env var reads, not just JWT — database connections, API keys, etc.; ESM module resolution has another common gotcha — missing .js extensions in dynamic imports causes module-not-found errors in production
require('dotenv').config()only guarantees order in CommonJS; ES Moduleimportalways executes before runtime code- Lazy initialization works well for: secrets, DB connection pools, external API clients, and other one-time resources; if you're using the jose library with Node 24, also watch out for KeyObject format changes