JWT 验签的四个坑:alg confusion、kid 注入、JWKS 拉取、时钟漂移

· 约 6 分钟 JWT 验签

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

攻击流程

  1. 你的服务用 RS256 验签,公钥公开(OAuth 标准把公钥放在 /.well-known/jwks.json
  2. 攻击者拿到一个合法 RS256 token
  3. 攻击者把 header 改成 {"alg":"HS256"}、payload 改成 {"role":"admin"}
  4. 攻击者用你公开的 RSA 公钥 PEM 字符串作为 HMAC secret,重新签名
  5. 你的代码 jwt.verify(token, publicKey) 没传 algorithms,库按 header 里的 alg 选算法
  6. 选 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-configurationjwks_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 不接受意外类型

一份验签加固清单

按顺序检查你的验签代码:

  1. 算法白名单algorithms: ['RS256'] 写死,绝不省
  2. 拒绝 alg=none:升级库 + 白名单兜底
  3. kid 严格校验:白名单或参数化查询,禁止 fallback
  4. JWKS 缓存 + 限速:5-15 分钟缓存,未知 kid 限速刷新
  5. 时钟容差 30-60s:clockTolerance 参数
  6. iss / aud 校验:白名单签发方和受众
  7. token_use 隔离:access / refresh 不混用
  8. payload 大小限制:> 8KB 直接拒
  9. 黑名单/吊销:jti 在已撤销列表中拒签

把这九条全过一遍,JWT 验签的攻击面基本就关掉了。

❓ 常见问题

alg confusion 攻击到底怎么打?我代码里写了 RS256 也会中招吗?

会中招——只要你"按 header 选算法"攻击流程:(1) 你的服务用 RS256 验签,公钥公开;(2) 攻击者拿到一个原本 RS256 的合法 token;(3) 攻击者把 header 改成 {"alg":"HS256"}、payload 改成想要的内容(比如 role:admin);(4) 攻击者用你公开的 RSA 公钥(PEM 字符串原文)作为 HMAC secret,重新计算签名;(5) 你的服务按 header 里的 alg 选算法——选了 HS256,secret 是默认配置里那把 RSA 公钥(很多框架默认把"验签 key"当作 HS256 的 secret),验证通过。关键漏洞:你的代码 jwt.verify(token, publicKey) 没传 algorithms 参数,库默认根据 header 里的 alg 选算法正确写法jwt.verify(token, publicKey, { algorithms: ["RS256"] })——把白名单写死。库默认行为对照:(1) node-jsonwebtoken v9+ 已强制要求传 algorithms,老版本默认按 header;(2) python-jose / PyJWT 仍要手动传 algorithms;(3) golang-jwt 用 ParseWithClaims 时要在 keyFunc 里检查 method.Algorithm。

alg:none 攻击现在还有效吗?老问题了吧?

老问题但仍有效——尤其老版本库或自实现的代码攻击:(1) 攻击者把 header 改成 {"alg":"none","typ":"JWT"};(2) 签名段留空(xxxxx.yyyyy.,注意末尾的点);(3) 提交给服务端;(4) 老版本库见到 alg=none 直接跳过签名校验返回 payload。仍有效的原因:(1) 一些低维护项目用着五年前的 JWT 库;(2) 自己写的"简陋 JWT 解析"未实现签名校验;(3) 一些聚合鉴权层只读 payload 不验签;(4) RFC 7519 规范本身允许 alg=none,库为了"标准合规"保留了实现。防御:(1) 永远写死 algorithms 白名单——{ algorithms: ["RS256"] };(2) 升级 JWT 库到最新版本——主流库现在默认拒绝 alg=none,需手动开启;(3) 额外校验——验签后再次断言 header.alg !== "none";(4) 服务里禁用 alg=none 的所有路径,包括缓存、中间件、网关层;(5) WAF 层加规则匹配 eyJhbGciOiJub25lIg== 之类的特征。

kid 字段是什么?为什么会被注入攻击?

kid(Key ID)是 header 里指向"用哪把密钥验证"的字段,注入风险来自服务端拿 kid 直接拼 SQL 或路径正常用途:(1) 一个服务有多把签发密钥(轮换、多租户);(2) 签发时 header 写 kid: "key-2026-q2";(3) 验签端拿 kid 查密钥库找对应公钥。经典注入:(1) SQL 注入——SELECT pubkey FROM keys WHERE kid = '" + kid + "',攻击者把 kid 写成 " UNION SELECT 'my-public-key' --;(2) 路径穿越——fs.readFileSync("./keys/" + kid + ".pem"),攻击者把 kid 写成 ../../etc/passwd;(3) 命令注入——某些库把 kid 拼进 shell 命令;(4) kid 指向 /dev/null ——读出空内容,HMAC 用空 secret 验证空签名通过(罕见但出现过)。防御:(1) 白名单——kid 必须在已知集合里,否则拒绝;(2) 参数化查询 —— SQL 用 prepared statement;(3) 路径验证 —— path.basename 后再拼,禁止 ../;(4) kid 字符限制——只允许 [A-Za-z0-9_-]{1,64};(5) 找不到 kid 对应密钥时直接拒签,不要 fallback 到默认密钥。

用 OAuth/OIDC 拉对方的 JWKS 验签,会有什么坑?

JWKS 端点是攻击者最爱的注入点JWKS(JSON Web Key Set) 是一个 HTTP 端点,返回该 IdP 当前公钥列表。典型坑:(1) JWKS URL 不验证——一些 OAuth 库允许配置 issuer 后从 issuer 的 /.well-known/openid-configuration 拉 jwks_uri,但不验证 jwks_uri 是不是同源;攻击者操纵 issuer 让它返回攻击者控制的 jwks_uri;(2) 不缓存 JWKS——每次验签都拉一次 IdP,单次验签变成 100ms+,QPS 一上去 IdP 直接挂;(3) 缓存太长——密钥轮换后老服务还用旧公钥几小时;(4) 找不到 kid 时再次拉 JWKS——攻击者构造未知 kid 触发反复拉取 IdP,DoS;(5) HTTP 而不是 HTTPS 拉 JWKS——中间人能换公钥。正确做法:(1) 白名单 issuer 和 JWKS URL——硬编码或配置文件,不接受动态查;(2) 缓存 JWKS——5-15 分钟 TTL,密钥轮换前 IdP 应提前几小时把新 kid 加入 JWKS;(3) kid 找不到时——一次性"刷新缓存",但要限速(如每分钟最多 1 次),避免被 DoS;(4) 强制 HTTPS + 校验证书;(5) 预加载常用 kid——服务启动时拉一次 JWKS,避免冷启动延迟。

时钟漂移、leeway 是什么?要设多大?

服务器之间时钟差几秒就会让 token "未生效"或"已过期",leeway 是允许的容差问题来源:(1) 签发服务器时钟和验证服务器时钟不严格同步;(2) NTP 同步精度通常在毫秒级,但跨数据中心可能差 1-2 秒;(3) 容器/虚拟机时钟漂移更大,未严格同步可能差几十秒;(4) 用户终端时钟可能比服务器晚或早。影响:(1) 用户登录时拿到 access token,nbf=iat=now,验签时如果验证服务器时钟比签发慢 5 秒——nbf 在未来,token "未生效",登录失败;(2) exp 临界点同理。leeway 设置:(1) 主流库都支持 clockTolerance / leeway 参数;(2) 推荐 30-60 秒——足够覆盖正常时钟漂移;(3) 不要超过 5 分钟——leeway 越大,过期 token 仍可用的窗口越长,安全性下降;(4) 检查 nbf 和 exp 都要应用 leeway。根本解法:(1) 所有服务用同一 NTP 源(chrony / ntpd);(2) 监控时钟漂移——超过 1 秒报警;(3) iat 不带 leeway 校验——iat 在未来 30 秒内可接受,超过就拒绝(防签发服务器时钟设错)。

验签通过后还需要做什么?拿到 payload 直接用?

至少做四项额外校验,缺一不可(1) iss 校验——iss 字段必须等于预期签发方。攻击者可能拿别处签的 token 来打你——同一公钥被多服务复用时尤其危险。(2) aud 校验——aud 必须等于本服务标识。例如 IdP 同时给 service-aservice-b 签 token,B 服务必须只接受 aud: "service-b" 的 token,否则 A 的 token 能用在 B 上。(3) typ 校验——header 的 typ 应是 JWT,不要接受 at+jwturn:ietf:params:oauth:token-type:jwt-introspection 之外的非预期值。(4) 用途隔离——access token 不能拿来当 refresh token 用,反之亦然。在 payload 加 token_use: "access" / "refresh" 字段,端点上验证。额外建议:(5) jti 黑名单——如果有强制注销需求,记录已撤销 jti;(6) sub 必须存在——别接受没有用户标识的 token;(7) 自定义 claims 严格校验类型——避免 role: ["admin", "user"] 数组绕过 role === "admin" 字符串比较;(8) 反序列化 payload 时限制深度和大小——攻击者可能塞超大 payload 做 DoS。

打开 JWT 验签 HS/RS/ES/PS 全算法·SPKI/JWK/证书提取公钥·exp/nbf 时效检查·本地验签