JWT 签名静默失败?检查 Node.js 环境变量的加载顺序
· 阅读需 3 分钟
在为客户构建 SaaS 认证系统时遇到此问题,记录根因与解法。
TL;DR
Node.js ES Module 中,import 语句在 dotenv.config() 之前执行。如果模块级代码读取 process.env.JWT_SECRET,拿到的是 undefined,导致 JWT 签名用 "undefined" 字符串作为密钥——不报错,但所有 token 校验都失败。解决方案:延迟初始化(lazy init)。
问题现象
JWT 登录接口返回 200,但后续请求全部 401。排查发现:
- 登录生成的 token 无法被
jwtVerify()验证 - 每次重启服务,之前签发的 token 全部失效
- 打印
process.env.JWT_SECRET,结果是undefined
// jwt.ts — 模块级代码
import crypto from 'crypto';
// ❌ 这行在 dotenv.config() 之前执行,JWT_SECRET 是 undefined
const SECRET = crypto.createSecretKey(
new TextEncoder().encode(process.env.JWT_SECRET)
);
最坑的是:不报错。new TextEncoder().encode(undefined) 会把字符串 "undefined" 编码成字节,生成一个合法但错误的密钥。
根因
ES Module 的 import 是静态提升的:
// server.ts(入口文件)
import { router } from './routes/auth'; // ← 先执行
import { authenticateToken } from './middleware/auth'; // ← 先执行
dotenv.config(); // ← 后执行,但 import 链已经跑完了
执行顺序:
- Node.js 扫描所有
import,构建依赖图 - 深度优先执行所有被导入模块的顶层代码(
jwt.ts的const SECRET = ...在这里执行) - 回到
server.ts,执行dotenv.config() - 此时
.env才加载到process.env
所以 jwt.ts 模块级代码读到的 process.env.JWT_SECRET 是 undefined。
解决方案
方案一:延迟初始化(推荐)
把密钥初始化从模块级移到函数内部,首次调用时才读取环境变量:
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 环境变量未设置');
}
_secret = crypto.createSecretKey(
new TextEncoder().encode(secretValue)
);
}
return _secret;
}
// 所有需要密钥的地方改用 getSecret()
export async function generateToken(payload: any): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.sign(getSecret()); // ← 延迟到运行时读取
}
优势:不依赖入口文件的 import 顺序,任何调用时机都安全。
方案二:入口文件顶部调用 dotenv
// server.ts — 确保这两行在所有 import 之前
import 'dotenv/config'; // 或 require('dotenv').config()
import express from 'express';
// ...其他 import
局限:如果有其他入口文件(如 cron job、worker)忘记加这行,问题复现。
注意事项
注意事项
- 这个坑不只影响 JWT,所有模块级代码读取环境变量都有同样风险(数据库连接、API Key 等);ESM 模块解析还有另一个常见坑——动态 import 缺少 .js 扩展名会导致生产环境模块找不到
require('dotenv').config()只在 CommonJS 中能保证顺序;ES Module 中import始终先于运行时代码- 延迟初始化模式适用于:密钥、数据库连接池、外部 API 客户端等一次性资源;如果你的 JWT 使用 jose 库且升级了 Node 24,也要注意 jose KeyObject 格式变化