NestJS JWT 密钥配置:让认证服务在启动时拒绝弱密钥
本文目录
JWT 登录实现里,一个很容易被忽略的问题是:密钥缺失时,服务是否会悄悄退回到代码里的默认值。默认密钥让开发环境更方便,却会把部署错误变成可预测的认证凭证。更稳妥的行为是 fail closed:关键配置无效时服务直接启动失败,而不是签发可被伪造的 token。
本文基于本站 NestJS 后端的一次本地改进。源码抽出 requireJwtSecret,供 JWT 签名模块、Passport 验证策略和角色守卫共同使用;配置缺失、类型不符或少于 32 个 UTF-8 字节时启动失败。定向测试覆盖缺失、空值、短值、32 字节边界和多字节字符。代码改动尚未部署,生产运行状态与本地源码分开说明。
把密钥校验集中在一个函数
配置验证不应散落在登录控制器、模块工厂和策略构造器中,否则签名端可能接受一种配置、验签端却读取另一种配置。可以将读取与校验封装成一个窄函数,然后让签发和验证共用:
const MIN_JWT_SECRET_BYTES = 32;
export function requireJwtSecret(configService: ConfigService): string {
const secret = configService.get<string>('JWT_SECRET');
if (
typeof secret !== 'string' ||
Buffer.byteLength(secret, 'utf8') < MIN_JWT_SECRET_BYTES
) {
throw new Error('JWT_SECRET must be configured with at least 32 bytes');
}
return secret;
}
Nest 的 JWT 模块在创建时调用这个函数,把结果用于签名;Passport JWT 策略在构造时也调用同一个函数,把结果用于验签。这样缺密钥不是等到第一个用户登录或受保护请求到来才暴露,而是在应用启动阶段明确失败。不要在 catch 后回退到固定字符串、空字符串或“仅供开发”的默认密钥。
错误信息应指出配置项名称和约束,但不能打印密钥值。进程日志、异常追踪、诊断端点和部署流水线输出都应避免回显环境变量。
32 字节不等于强密钥
长度校验只能挡住明显缺失或过短的配置,不能证明熵足够。当前实现的测试用重复字符构造“恰好 32 个 UTF-8 字节”的边界样例;这能验证比较条件,却不能把重复字符变成安全密钥。人为短语、重复字符、用户名拼接和可猜测默认值,即使长度够,也不适合作为 HMAC 密钥。
优先从密码管理器或密钥管理系统生成随机值。也可以在可信的本地终端使用 Node 加密模块生成 32 个随机字节:
const { randomBytes } = require('node:crypto');
process.stdout.write(randomBytes(32).toString('base64') + '\n');
Node 的 randomBytes 使用密码学安全的伪随机生成器。上面的命令会把生成值输出到终端,因此应只在可信设备上运行,并立即保存进受访问控制的服务端密钥配置;不要粘贴到聊天、工单、README、前端环境变量、构建日志或 Git。NestJS 当前认证文档也建议使用至少 32 字节的随机密钥。字符数、字节数和熵是不同概念:如果格式或编码改变,应按所用 JWT 算法及库的要求校验字节长度,并保留随机生成来源,而不是只看字符串够不够长。
环境隔离与部署检查
每个环境都应使用独立密钥。开发、测试、预发布和生产不要共用同一把钥匙;仓库里的环境模板只放空占位符和变量名,真实值通过服务器权限受控的环境文件、部署密钥存储或专门的 Secret Manager 注入。检查 Git 跟踪状态时只记录路径和处置计划,不把可能的环境文件内容复制进文章、提交或日志。
多实例服务的所有实例必须使用同一环境对应的密钥,否则同一 token 可能在一个实例通过、在另一个实例失败。部署前先确认配置存在且满足约束;变更后做启动健康检查、登录签发、受保护接口验签和日志脱敏检查。健康检查只应回答服务是否就绪,不能把 JWT_SECRET 返回给探测器。
轮换会影响现存登录
本站 access token 当前有效期为 7 天。直接替换 JWT_SECRET 会让此前签发的 token 验签失败,用户需要重新登录;密钥轮换适合处理泄露或计划中的全员会话失效,但需要同步更新全部服务实例。除此之外,本站已有单令牌注销:后端保存 token 的 SHA-256 摘要至其过期时间,后续请求会拒绝已撤销令牌。密钥轮换使一批旧 token 失效,单令牌撤销只使选定会话失效,两者用途不同。
不要为了“无感轮换”长期保留旧密钥。若业务确实需要平滑切换,必须显式设计短暂的双密钥验签期、密钥标识和移除时间;新 token 只用新密钥签发,旧密钥只在约定窗口内验证,窗口结束后删除旧值。密钥轮换、撤销与 token 过期是不同机制,轮换也不会替代账号禁用、权限变更或单会话撤销。
认证不等于实时授权
JWT 签名只能证明 token 由持有密钥的一方签发,不能独自证明账号当前仍启用或角色没有变化。本站的 JwtStrategy 会按 subject 重新读取账号状态,受保护的 RolesGuard 会按当前数据库角色授权,而不是相信 token 中的旧角色声明;角色变更和封禁因此可在后续受保护请求中生效。审查新控制器时仍要确认守卫覆盖范围与方法/类级元数据,并保留陈旧角色、封禁和删除账号的回归测试。
其它系统可按风险选择每次请求重载账号状态与角色、短时 access token 配合可撤销 refresh session,或引入 tokenVersion/sessionVersion。不要把客户端解码 JWT 得到的角色作为授权依据;关键资源仍应在服务端检查当前身份、权限和资源归属。本站的 7 天只是当前配置,不是所有系统都适用的推荐值。
回归测试应覆盖什么
现有 helper 测试证明了校验函数对缺失、空、过短及长度边界的行为,但它没有证明密钥有足够熵,也没有覆盖模块是否真的在启动时失败。可以继续补充以下测试:
- JWT 模块签发与 Passport 策略验签使用相同配置;密钥缺失时应用初始化失败。
- 生产配置不允许使用示例值、固定值或测试密钥;异常和日志不包含秘密内容。
- 用户角色变更、账号禁用后,旧 token 按明确策略被拒绝或在约定时限内失效。
- 密钥轮换后旧 token 的结果符合预期;多实例发布过程不会随机接受或拒绝同一 token。
- 每个环境读取独立密钥,仓库模板和前端产物中不存在实际凭证。
参考资料与边界
- NestJS Authentication:JWT 配置和密钥要求。
- Node.js crypto.randomBytes:密码学安全随机字节生成。
- OWASP Secrets Management Cheat Sheet:密钥存储、访问控制、轮换与撤销。
- OWASP JSON Web Token Cheat Sheet:JWT 风险和撤销相关设计。
- OWASP Session Management Cheat Sheet:会话生命周期和权限变化后的会话处理。
本文不披露实际环境变量值、私有公司代码或生产服务凭据。这里的密钥长度校验改动尚未部署;阅读其它运行能力时,仍需按目标 Node/Nest 版本、JWT 算法、密钥注入方式和实际业务风险复核。
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。