JWT 好签发,也容易被签发错。针对它的攻击都有案可查,而几乎全都来自少数几个能避免的低级错误:信任攻击者可控的字段、把一把密钥跨算法复用、以及忘了无状态 token 没法吊销。把下面这份清单当成上线前的门禁:哪一条打不了勾,你的 token 就还没到生产可用,而每个攻击场景都会演示失败究竟怎么被利用。
钉死算法,别信 token 自己说的
最重要的铁律:验签端必须决定允许哪种算法,token 自己的 alg 头绝不能说了算。
// 错误:库从 token 里读 alg
jwt.verify(token, publicKey);
// 正确:显式强制算法
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
一旦让 token 挑算法,攻击者就掌握了密码学。下面两个经典漏洞直接源于这个失误,它们也确实出现在真实事故报告里。
攻击:alg:none 伪造
有些库看到 "alg":"none" 会直接跳过验签,把 token 当合法。攻击者拿一个截获的 token,改写头部,丢掉签名:
// 伪造的头部
{"alg":"none","typ":"JWT"}
// 伪造的 payload
{"sub":"admin","role":"admin","exp":9999999999}
// 发给服务端的 token:
// eyJhbGc... . eyJzdWI... . (第三段签名留空)
因为第三段是空的、而服务端又认了 alg:none,它就以 admin 身份收下了这个 token。防御:在库层面拒绝 none(现代库基本都做),并钉死白名单,让 none 永远不是允许值。一个能被吩咐「不需要签名」的验签器,设计上就是坏的——所以要 fail closed:算法不在白名单里就先拒,再碰密钥。
攻击:RS256 被混淆成 HS256
你的服务端用 RSA 公钥以 RS256 验签。攻击者知道那把公钥——它公开是天职。于是攻击者造一个声明 "alg":"HS256" 的 token,并用那把公钥当 HMAC 密钥去签名。因为 HS256 的签名和验签用同一把密钥,而攻击者手握公钥,他就能造出一个你的服务端会收下的签名:
// 攻击者用你的公钥当「密钥」造一个 HMAC token
const forged = jwt.sign({ sub: 'admin' }, publicKey, { algorithm: 'HS256' });
// 你的服务端若没钉死 RS256,会尝试用 HS256 验,伪造就此通过
防御:把算法钉死成 RS256,这样 HS256 的 token 在任何碰密钥之前就被拒;并且把每把密钥绑定到它支持的算法。RSA 公钥绝不该能被当成 HMAC 密钥用。那种允许你给验签器传同一把密钥同时干两件事的库,是根因——给验签器一个声明了类型的密钥对象,它就会拒绝拿 RSA 密钥去做 HMAC 运算。
攻击:kid 路径穿越与注入
kid(key id)头告诉验签器用哪把密钥。如果服务端拿 kid 拼文件路径或拼 SQL 而没有校验,攻击者就能引导它:
// 有漏洞:kid 被直接拼进路径
const key = fs.readFileSync('/keys/' + header.kid + '.pem');
// 攻击者发头部:{ "kid": "../../../../etc/passwd", "alg":"HS256" }
// 服务端于是把 /etc/passwd 当「密钥」,并据此验一个 HMAC token
另一种变体是把 SQL 注入进 SELECT key FROM keys WHERE id='<kid>' 这样的查询。防御:维护一份合法 key id 的白名单,或者把 kid 通过固定字典映射到密钥,绝不要用字符串拼接塞进路径或查询。非要把密钥存数据库,就用参数化查询,并拒绝任何含 /、\ 或引号字符的 kid。一旦一个头字段碰到了文件系统或 SQL 字符串,你已经输了。
密钥管理的基本功
除了攻击,枯燥的部分也重要。RSA 密钥至少 2048 位,最好 4096;按排期轮换。通过 JWKS 端点发布轮换,并让旧密钥在过渡期短暂有效,这样在途 token 仍能验过。绝不要把 HS256 密钥塞进客户端代码:装进移动 App 或浏览器包里的密钥就不再是秘密,谁都能伪造 token。密钥要用 CSPRNG 生成,别从口令或常量来。审计密钥的存放位置——一把被误提交进 .env 文件、或在聊天里贴出来的密钥,是最常见的真实 JWT 事故,跟算法混淆毫无关系。把签名密钥当生产数据库密码对待:进保险库、勤轮换、绝不进源码。另外,区分签名密钥和验证密钥的部署面:验证端只需要公钥,不该持有私钥;一旦验证服务也被配上私钥,攻击面就翻倍。把密钥的用途写进文档和配置,定期做密钥清单审计,比事后救火便宜得多。
让 payload 保持无聊
payload 是 Base64url,谁拿到 token 都能读。别往里放邮箱、地址或任何敏感信息,也别放一大串权限名、把组织架构泄露给任何解码 token 的人;只放标识符和标志位。过期时间要短:访问 token 活几分钟,不是几小时。被盗的短命 token 爆炸半径小;有效一天的 token 就是一整天的敞开大门。要更长会话,用 refresh-token 流程,而不是长寿命访问 token。
把 refresh token 放离 JavaScript 远点
refresh token 是长寿命的,所以它的存储决定了是小事泄露还是整账号被接管。把它放进 httpOnly、Secure、SameSite 的 Cookie,而不是 localStorage:
| 存储位置 | XSS 暴露 | CSRF 暴露 |
|---|---|---|
| localStorage | 高——任何脚本都能读 | 无直接暴露 |
| httpOnly Cookie | 无——JavaScript 读不到 | 有可能——用 SameSite 加 CSRF token 堵上 |
一个 XSS 漏洞就能把 localStorage 里的 token 直接递给攻击者;httpOnly Cookie 对脚本不可见。代价是开了一道 CSRF 门,用 SameSite=Strict 加双重提交 CSRF token 关上。这笔交易几乎总是划算的,因为靠 XSS 偷 token 远比攻破 SameSite Cookie 的 CSRF 常见。
到底怎么吊销一个 JWT
无状态 JWT 在 exp 之前没法失效,这破坏了「现在就登出」和「这个 token 被盗了」。三种模式能修,各有代价。
吊销 jti 的黑名单
给每个 token 一个唯一 jti 声明;登出或出事时,把这个 jti 加进一个高速存储(如 Redis)。每次请求都查一遍。代价:你又有了状态、需要一个存储,而且列表会一直长到 token 过期,所以得给条目设 TTL 到 exp,免得它无限涨。
短访问 token 加 refresh 轮换
把访问 token 设为 1 到 5 分钟寿命,refresh token 每次使用都轮换。要吊销,就让 refresh token 失效,下一个访问 token 随之死掉。代价:环节更多、还得保护 refresh 端点,但你省掉了全局黑名单,并拿到近乎即时的吊销。正因为如此它是最常见的生产方案。
版本号或指纹声明
在 payload 里埋一个 token_version 或设备指纹,并按用户存当前版本。改密码或登出时把版本一改,所有旧 token 同时失效。代价:每次请求多一次查询,而且得让这个版本存储在多服务间保持一致。当你想要一个「干掉所有会话」的按钮时它最顺手。
永远校验标准声明
校验 iss 确认 token 来自你信任的权威;校验 aud 确认给服务 A 发的 token 不能被重放到服务 B——而且要做精确匹配,不是子串匹配。带着几秒时钟偏差去验 exp、nbf、iat,这样时钟略有差异的服务不会把合法 token 弹掉;密钥泄露后,还要拒绝在已知安全时间点之前签发的 token。跳过这些校验,正是 token 重放漏洞在微服务间溜进来的方式。顺带一提,很多团队只验 exp 而忘了 aud,结果一个本该给内部服务 B 的 token 被前端原样发给了服务 A 也被收下——声明校验必须成组做,缺一不可。
一句话总结
钉死算法、密钥留在服务端、payload 保持无聊、访问 token 要短、refresh token 塞进 httpOnly Cookie,并且永远校验 iss/aud/exp。要吊销时,按你反应要多快来选黑名单、refresh 轮换还是版本号。把这些做了,就能避开那 90% 只是老错误翻版的 JWT 事故。