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) outputs undefined
import crypto from 'crypto';
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:
import { router } from './routes/auth';
import { authenticateToken } from './middleware/auth';
dotenv.config();
Execution order:
- Node.js scans all
import statements and builds the dependency graph
- Executes all imported modules' top-level code depth-first (
jwt.ts's const SECRET = ... runs here)
- Returns to
server.ts, runs dotenv.config()
- Now
.env is loaded into process.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;
}
export async function generateToken(payload: any): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.sign(getSecret());
}
No dependency on entry file import order — safe regardless of when called.
Option 2: Call dotenv at the Very Top of Entry File
import 'dotenv/config';
import express from 'express';
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 Module import always 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