为什么我把 JWT 从生产环境删了:无状态鉴权的真实代价
有个用户半夜来工单:账号被盗了,改了密码,但对方还在登录状态下转移数据。我们改了密码、清了 session——然后想起来,登录态是 JWT,签发出去的那张令牌在过期前谁也拦不住。那一刻我盯着代码意识到,我们用错工具了。JWT 没毛病,是我们把它放在了不该放的位置。
先说立场,省得你看半天:对绝大多数 Web 应用,拿 JWT 当登录态(替代服务端 session)是个性价比很差的选择。它解决的是"无状态、跨服务"的问题,而大多数单体应用根本没这个问题,却要替它的缺点买单。下面是这些缺点具体长什么样。
先分清两个概念,很多争论是在这儿错位的
JWT(JSON Web Token)只是一种自包含、带签名的令牌格式:服务端把用户信息编码进 payload,签个名发给客户端;之后客户端每次带上它,服务端验签就信。它本身没错,错的是用法。
而"登录态管理"有两条路:
- 服务端 session:服务端存登录状态(内存 / Redis / 库),发给客户端一个无意义的 session id。状态在服务端,随时能改能删。
- JWT(无状态):状态编码在令牌里,服务端不存。签出去就管不着了,只能等它自己过期。
注意"签出去就管不着了"这句——它是后面所有麻烦的根源。
代价一:撤销,几乎做不到
这是最痛的一条,也是开头那个工单的根。JWT 一旦签发,在 exp 到期之前天然有效,服务端没有任何状态可以让它提前失效。
账号被盗要强制下线?改了密码要让旧令牌作废?管理员要封一个用户?——这些在 session 模式下就是删一行 Redis 的事,在 JWT 模式下你做不到。
于是大家的"解决方案"是:维护一张黑名单,每次请求都去查这个令牌在不在黑名单里。
停一下品品这个方案:你为了"无状态"选了 JWT,结果为了能撤销,又给每个请求加了一次有状态的黑名单查询。那你跟直接查 session 表区别在哪?无状态这个唯一卖点,被你自己亲手取消了,还多扛了 JWT 的一身缺点。
代价二:改了密码,旧令牌照样能用
这是代价一的具体化,但太常见了值得单拎出来。用户改密码这个动作,下意识地大家都认为"会让所有旧登录失效"——这是几十年 session 模式养成的预期。JWT 默认不会。改完密码,之前在另一台设备上签发的令牌,到期前继续畅通无阻。
要修,又得回到服务端存状态:比如记一个 password_changed_at 时间戳,验令牌时比对签发时间。每次都查。无状态再一次名存实亡。
代价三:令牌膨胀,每个请求都在搬运它
session id 就是个几十字节的随机串。JWT 不一样——它把用户 id、角色、权限、签发/过期时间全塞进去,再 Base64 编码,动辄几百字节到 1KB+。
而这坨东西每一个请求都要塞在 Authorization 头里发一遍。接口调用量大的时候,这是实打实的带宽和解析开销。顺带提一句,Base64 不是压缩也不是加密,它只会让体积比原始 JSON 还大约 33%。payload 里的内容是明文可读的,谁都能解开看——
# 把一个 JWT 的中段(payload)单独 base64 解码,明文就出来了
echo 'eyJ1c2VyIjoiYWxpY2UiLCJyb2xlIjoiYWRtaW4ifQ' | base64 -d
# {"user":"alice","role":"admin"}
想拆开看一个 JWT 三段分别是什么、或者改改 payload 测接口,用 在线 JWT 编辑器 比敲命令直观,签名段也能本地重算。
正因为 payload 是明文,千万别往 JWT 里放敏感信息——身份证号、手机号、内部用户标识,放进去等于明文发给客户端。签名只保证"没被篡改",不保证"看不见"。
代价四:密钥轮换是个工程
签名密钥泄露了要换,或者按规范定期轮换——session 模式下你换了就换了,存量 session 不受影响。JWT 模式下,换密钥意味着所有用旧密钥签的令牌瞬间全部验签失败,全站用户被踢下线。
要平滑过渡,得同时持有新旧两套密钥、按 kid(key id) 头区分、维护一个轮换窗口。能做,但又是一摊本来不需要的复杂度。
顺便:两个把人坑惨的经典漏洞
JWT 的坑不止性能和运维,还有安全。这两个都是被反复利用过的真实漏洞,不是理论。
alg: none —— 直接绕过验签
JWT 头里有个 alg 字段声明签名算法。早年很多库(2015 年 Tim McLean 那篇著名的披露集中点名过一批)有个致命默认:如果攻击者把 alg 改成 "none",库就跳过签名校验直接放行。
攻击者把头部改成:{"alg":"none","typ":"JWT"}
payload 改成: {"user":"alice","role":"admin"}
签名段: 留空
→ 有缺陷的库:验签通过,alice 成了 admin
修法:服务端固定写死接受的算法,绝不读令牌里的 alg 来决定怎么验。
RS256 → HS256 密钥混淆
这个更阴。RS256 是非对称的:私钥签名、公钥验签,公钥本来就是公开的。攻击者把头部的 alg 从 RS256 改成对称的 HS256,然后用那把公开的公钥当作 HMAC 的密钥去签名。
有缺陷的库看到 HS256,就拿它手里的公钥当对称密钥来验——而这把公钥攻击者也有。于是伪造的令牌验签通过。一个"公开的公钥"摇身变成了"共享密钥"。修法同上:算法白名单写死,不信令牌自报的算法。
那 JWT 什么时候是对的
把它放回它擅长的位置,JWT 是好东西:
| 场景 | 为什么 JWT 合适 |
|---|---|
| 服务间调用的短时令牌 | 几分钟就过期,撤销需求几乎为零,无状态省掉共享 session 存储 |
| OAuth 的 access token | 本就设计成短命(如 15 分钟)+ 配长效 refresh token,撤销靠 refresh 环节 |
| 跨域 / 跨子系统 SSO | 一处签发多处验签,不必所有系统共享一个 session 库 |
| 纯无状态 API、无登出概念 | 比如对接第三方的机器对机器接口 |
共同点:令牌短命,且不需要"立刻强制失效"。撤销需求一旦出现,JWT 的优势就开始倒贴。
我现在的默认选择
普通的有用户登录的 Web 应用,我直接上服务端 session:HttpOnly + Secure 的 cookie 装一个 session id,状态存 Redis。强制登出、改密下线、封号,全是删 key 的一行操作。简单、安全、符合所有人的直觉。
真要无状态架构,就走 OAuth 那套成熟模式:短命 access token(JWT)+ 长效 refresh token,撤销作用在 refresh 环节,access token 短到来不及被滥用。
JWT 是把好锤子。开头那次返工教会我的,是别把每个登录态都看成钉子。