安全 · 鉴权

为什么我把 JWT 从生产环境删了:无状态鉴权的真实代价

有个用户半夜来工单:账号被盗了,改了密码,但对方还在登录状态下转移数据。我们改了密码、清了 session——然后想起来,登录态是 JWT,签发出去的那张令牌在过期前谁也拦不住。那一刻我盯着代码意识到,我们用错工具了。JWT 没毛病,是我们把它放在了不该放的位置。

先说立场,省得你看半天:对绝大多数 Web 应用,拿 JWT 当登录态(替代服务端 session)是个性价比很差的选择。它解决的是"无状态、跨服务"的问题,而大多数单体应用根本没这个问题,却要替它的缺点买单。下面是这些缺点具体长什么样。

先分清两个概念,很多争论是在这儿错位的

JWT(JSON Web Token)只是一种自包含、带签名的令牌格式:服务端把用户信息编码进 payload,签个名发给客户端;之后客户端每次带上它,服务端验签就信。它本身没错,错的是用法。

而"登录态管理"有两条路:

注意"签出去就管不着了"这句——它是后面所有麻烦的根源。

代价一:撤销,几乎做不到

这是最痛的一条,也是开头那个工单的根。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 是非对称的:私钥签名、公钥验签,公钥本来就是公开的。攻击者把头部的 algRS256 改成对称的 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 是把好锤子。开头那次返工教会我的,是别把每个登录态都看成钉子。

在线 JWT 编辑器

解析、编辑、重新签名 JWT,查看三段结构,全程本地不上传

打开 JWT 编辑器