JWT 验签代码看起来就是 jwt.verify(token, key) 一行,但每年都有人因此踩坑:alg confusion、kid 注入、JWKS 被打挂、时钟漂移导致登录全挂……这篇逐个拆解,每个坑给出真实攻击场景 + 正确写法,让你能一次性把验签代码加固到位。
验签的最小正确写法
先给个标杆,下面所有的坑都围绕这段代码展开:
// Node.js (jsonwebtoken v9+)
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // 1. 算法白名单——防 alg confusion
issuer: 'https://auth.example.com', // 2. 签发方校验
audience: 'api.example.com', // 3. 受众校验
clockTolerance: 30, // 4. 时钟容差 30s
ignoreNotBefore: false, // 5. 严格校验 nbf
});
// 验签后还要做:
if (decoded.token_use !== 'access') throw new Error('wrong token type');
if (await isRevoked(decoded.jti)) throw new Error('revoked');
接下来逐项讲为什么每条都重要。
坑一:alg confusion
攻击流程:
- 你的服务用 RS256 验签,公钥公开(OAuth 标准把公钥放在
/.well-known/jwks.json) - 攻击者拿到一个合法 RS256 token
- 攻击者把 header 改成
{"alg":"HS256"}、payload 改成{"role":"admin"} - 攻击者用你公开的 RSA 公钥 PEM 字符串作为 HMAC secret,重新签名
- 你的代码
jwt.verify(token, publicKey)没传 algorithms,库按 header 里的 alg 选算法 - 选 HS256,secret 正好是公钥——验证通过
根因:JWT 库默认根据 header 选算法,而你的 key 既能当 RSA 公钥又能被解读为 HMAC secret。
防御:算法白名单写死。
// ❌ 错
jwt.verify(token, publicKey);
// ✅ 对
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
主流库默认行为:
| 库 | 不传 algorithms | 行为 |
|---|---|---|
jsonwebtoken v9+ | 报错 | 强制传 |
jsonwebtoken v8 及以下 | 按 header | 危险 |
PyJWT | 报错 | 强制传 |
python-jose | 按 header | 危险 |
jose(Java) | 通常按 header | 需手动校验 |
golang-jwt | 在 keyFunc 里手动判 method | 必须自己校验 |
升级老库 + 显式传 algorithms = 这条坑彻底关掉。
坑二:alg = none
攻击:
header : {"alg":"none","typ":"JWT"}
payload : {"role":"admin"}
signature: (空)
token: eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJyb2xlIjoiYWRtaW4ifQ.
↑ 末尾的点
老版本库见到 alg=none 直接跳过签名校验。RFC 7519 规范本身允许这个值,是为了”未签名 JWT”的场景保留的——但实际几乎没人需要。
防御:
- 算法白名单——
algorithms: ['RS256']自动拒绝 none - 升级库——主流库现在默认拒 none
- 额外断言——验签后再判
header.alg !== 'none' - WAF 层匹配
eyJhbGciOiJub25lIg==(“alg”:“none” 的 base64 头)
坑三:kid 注入
kid(Key ID) 是 header 里指向”用哪把密钥验证”的字段,多密钥场景必备:
{"alg":"RS256","typ":"JWT","kid":"key-2026-q2"}
服务端拿到 kid 去查密钥库找对应公钥。如果实现不当:
SQL 注入:
// ❌ 危险
const key = await db.query(
`SELECT pubkey FROM keys WHERE kid = '${kid}'`
);
// 攻击者 kid: ' UNION SELECT 'attacker_pubkey' --
路径穿越:
// ❌ 危险
const key = fs.readFileSync(`./keys/${kid}.pem`);
// 攻击者 kid: ../../etc/passwd
指向不存在 / 空文件:
// ❌ 危险
const key = fs.readFileSync(`./keys/${kid}.pem`);
// 找不到 → catch 后 fallback 到默认 HMAC secret 空字符串
// 空 secret 对空签名 HMAC = 通过
防御:
const ALLOWED_KIDS = new Set(['key-2026-q1', 'key-2026-q2']);
if (!ALLOWED_KIDS.has(kid)) {
throw new Error('unknown kid');
}
// 严格白名单,找不到直接拒,不 fallback
const key = await db.query('SELECT pubkey FROM keys WHERE kid = ?', [kid]);
或者干脆不允许动态 kid——把 kid 硬编码到验签逻辑里,密钥轮换通过部署解决。
坑四:JWKS 拉取陷阱
JWKS(JSON Web Key Set) 是 OAuth/OIDC 用的”公钥分发端点”。验签端从 /.well-known/jwks.json 拉一份 JSON:
{
"keys": [
{"kid": "key-2026-q2", "kty": "RSA", "n": "...", "e": "AQAB"}
]
}
四个常见坑:
4.1 JWKS URL 来源不验证
一些库允许动态发现:根据 token 里的 iss,去 <iss>/.well-known/openid-configuration 拉 jwks_uri,再从 jwks_uri 拉公钥。
攻击:攻击者签一个 token,iss 设成自己控制的 IdP——你的服务一路跟到攻击者的 JWKS、用攻击者的公钥验签,验证通过。
防御:iss 和 jwks_uri 必须白名单,不接受动态发现。
4.2 不缓存 JWKS
每次验签都拉一次 IdP——单次验签 100ms+,QPS 一上去 IdP 直接挂,你的服务也挂。
防御:缓存 5-15 分钟。
4.3 缓存太长
密钥轮换后,老服务还用旧公钥几小时——过期窗口期内,新签发的 token 验不通过。
防御:缓存 ≤ 15 分钟;密钥轮换前 IdP 应提前几小时把新 kid 加入 JWKS。
4.4 未知 kid 触发反复拉取
部分库的逻辑:缓存里没找到 kid → 立刻去 IdP 重新拉一次 → 仍找不到再拉 → ……
攻击:构造大量未知 kid 的 token,DoS 你的服务和 IdP。
防御:未知 kid 时限速刷新缓存——每分钟最多 1 次。仍找不到就拒签。
4.5 强制 HTTPS
JWKS 拉取必须 HTTPS + 验证证书。HTTP 拉 JWKS 等于把公钥扔在 MITM 桌上。
坑五:时钟漂移
问题:服务器之间时钟不严格同步——容器、跨数据中心、用户终端,差 1-2 秒甚至几十秒都常见。
影响:
- 签发服务器时钟正常,验证服务器时钟慢 5 秒
- 用户登录拿到 access token,nbf = iat =
1714200000 - 验证服务器看当前时间是
1714199998—— nbf 在未来,token “未生效” - 用户登录立刻失败
防御:设置 clockTolerance / leeway —— 30-60 秒。
jwt.verify(token, key, {
algorithms: ['RS256'],
clockTolerance: 30, // 30 秒容差
});
不要超过 5 分钟——leeway 越大,过期 token 仍可用的窗口越长。
根本解法:所有服务统一 NTP 源,监控时钟漂移 > 1 秒报警。
坑六:验签后还要校验的字段
验签通过 ≠ 这个 token 是给你这个服务用的。至少校验:
| 字段 | 校验内容 |
|---|---|
iss | 等于预期签发方 |
aud | 等于本服务标识 |
exp | 未过期(库自动处理) |
nbf | 已生效(库自动处理) |
token_use | 是 “access” 而非 “refresh” |
sub | 非空 |
jti | 不在黑名单 |
为什么 aud 重要:IdP 给整个组织签发 token,service-a / service-b 共用同一公钥。如果你的 service-b 不验 aud,service-a 的 token 也能用在 service-b 上——权限越界。
主流库都支持 audience 参数,自动校验。
坑七:payload 反序列化的边界
JWT 的 payload 理论上没有大小限制——攻击者可以塞个 1MB 的 payload。验签后服务端 JSON.parse 时:
- 内存激增
- 超深嵌套触发栈溢出
- 字段类型混淆——
role: ["admin"]数组绕过role === "admin"字符串比较
防御:
- 验签前先判 token 总长——> 8KB 直接拒
- payload 字段类型严格校验——用 zod / joi 等 schema 校验
- 自定义 claims 不接受意外类型
一份验签加固清单
按顺序检查你的验签代码:
- 算法白名单:
algorithms: ['RS256']写死,绝不省 - 拒绝 alg=none:升级库 + 白名单兜底
- kid 严格校验:白名单或参数化查询,禁止 fallback
- JWKS 缓存 + 限速:5-15 分钟缓存,未知 kid 限速刷新
- 时钟容差 30-60s:clockTolerance 参数
- iss / aud 校验:白名单签发方和受众
- token_use 隔离:access / refresh 不混用
- payload 大小限制:> 8KB 直接拒
- 黑名单/吊销:jti 在已撤销列表中拒签
把这九条全过一遍,JWT 验签的攻击面基本就关掉了。