被吹上天的 JWT,为什么主流网站一个都不用

1 阅读15分钟

打开浏览器的开发者工具,翻一翻 github.com 的 Cookie。里面只有一条 _gh_session,一串 Rails 框架加密后的会话数据。再去 google.com 看,SIDHSIDSSIDSAPISID 排成一列,Google 的隐私说明写得明明白白:这些 Cookie 存的是经过数字签名和加密的账号 ID 与最近登录时间。

Amazon、Netflix 也是同样的配置。Stack Overflow 看起来特殊一点,用的是 ASP.NET 的 Forms Authentication,名字唬人,本质仍然是服务端 Session。

没有一条是 JWT。

这个事实在社区里被反复提起,又反复被遗忘。十年来,JWT 在教程、面试题和脚手架模板里出镜率极高,以至于很多后端开发者默认它就是现代认证的标准答案。但翻遍这些网站的真实登录态,会发现一个尴尬的情况:几乎所有主流 Web 网站的浏览器端认证,用的都是 Session 加 Cookie。

JWT 的主战场从来不在浏览器

先说清楚一件事:JWT 并非一无是处,它只是被放错了位置。

JWT 真正适合的场景是这些:服务之间的内部调用,自包含的身份信息省去重复查询,网关与服务网格生态对它的支持已经很成熟;需要离线校验的边缘节点,本地验签是不得已但合理的选择;开放平台里机器身份的自证,GitHub 就拿它干这件事。至于移动端 App 拿凭证调 API 该用什么格式,市场上有多种选择,大厂的答案也未必是 JWT,但无论选哪种,这个场景和浏览器会话都是两码事。

换句话说,JWT 是一份自带签名的凭证,适合在不受信任的边界之间传递身份。但它从头到尾都不是为浏览器会话设计的。Stack Overflow 上有一个被引用很多的高赞回答,说得非常直白:JWT 从来不是用来管理会话的,它是服务之间交换完整性保护消息的工具。

把这个工具硬套到"用户在浏览器里登录"这个场景上,问题就一个接一个地来了。

撤销不了,是硬伤

用户账号被盗,管理员要立刻把这个人踢下线。用 Session,服务端删掉那条会话记录,下一秒用户就掉线了。用 JWT,签出去的 token 在过期之前始终有效,服务端没有任何官方手段作废它。

社区里流传很广的一句吐槽来自 Reddit 的 node 版块:不喜欢 JWT,因为它难以控制——账号被黑之后,没有任何办法让那个 token 失效。想要失效,唯一的办法是维护一个黑名单——每个请求都要查一遍黑名单,token 才有意义。而一旦这么做,JWT 引以为傲的"无状态"就不存在了。

Hacker News 上一条高赞评论把这件事总结得更彻底:JWT 的目标是无状态,但只要需要可撤销,它就不可能无状态。

有人会退一步:缩短过期时间,比如五分钟,配合 refresh token 续期,把吊销控制在续期边界上。这个方案确实是现在一些认证服务商的推荐做法。但代价是引入了一整套双 token 机制,而且五分钟窗口内的攻击依然拦不住。安全厂商 WorkOS 在分析这个问题时提到,企业客户最常见的第一条需求就是"能不能立刻让某个用户下线",而无状态 JWT 恰好做不到这一点。

95% 的 JWT 使用,只是在模拟 Session

Reddit 的 PHP 帮助版块有个说法流传很广:绝大多数时候,人们用 JWT 只是在模仿 Session。

看看身边的项目就明白了。签发一个只装着 userId 的 token,每次请求收到后解析出来,然后去数据库查这个用户的详细信息,判断状态是否正常、角色有没有变。这一套下来,数据库查询一次没少,额外的开销是签名和验签。Session 里也存了 userId,也能查库,还能随时销毁。区别只剩下一个:JWT 的版本更重。

还有一层更隐蔽的问题。JWT 的签名只能保证 payload 没被篡改,不能保证 payload 里的信息仍然成立。一个 token 在签发那一刻写着 role: admin,两个小时后管理员权限被回收了,这个 token 里的 admin 依然畅通无阻,直到过期。Session 没有这个问题,因为权限判断永远以服务端的实时数据为准。

安全性并没有更好,反而更差

JWT 的 payload 只是 Base64 编码,不是加密。把 token 拆开贴到任何解码工具里,内容一目了然。放到浏览器场景里,这意味着所有用户信息对同源的 JavaScript 完全可见。

于是存储位置变成了一个两头堵的选择题。放在 localStorage,XSS 攻击直接把 token 偷走,安全社区的共识是这条路风险最高。放进 Cookie,XSS 的问题确实缓解了,但此时它和一条 Session Cookie 面临的攻击面完全一样——CSRF 要防,SameSite 要设。绕了一圈,JWT 没有带来任何额外保护,只把 payload 内容多暴露了一份。

JWT 自身的实现史也不太光彩。安全研究者曾经批量指出主流 JWT 库的算法混淆漏洞:攻击者把 header 里的算法改成 alg: none,去掉签名,服务器照样放行。知名认证服务商 Auth0 的 API 就中过这类招,攻击者可以伪造任意用户的 token。这类漏洞的根源在于 JWT 把太多决定权交给了实现者——验签逻辑稍有不严谨,整个认证体系就穿了。

Java 生态里更著名的例子是 Shiro 的 RememberMe 反序列化漏洞,编号 CVE-2016-4437,因为默认使用了 AES 加密且密钥硬编码在代码里,攻击者可以构造序列化数据实现远程代码执行。这个漏洞给整个行业提了个醒:凭证体系里任何"本地保存的密钥"都可能成为突破口,凭证机制越复杂,出错的面就越大。

顺带纠正一个流传很广的说法:Session 更容易被 CSRF 攻击。实际上 CSRF 防护和认证方式无关,靠的是 SameSite 属性和 CSRF Token。而 JWT 因为放在 Header 里天然免疫 CSRF,代价是必须存进 localStorage,把 XSS 风险请了回来。两种方案各有各的坑,Session 的坑更小,防护手段更成熟。

SPA 是 JWT 的正当主场吗

有一个场景需要认真对待:前后端分离的 SPA。Vue、React 的项目里,JWT 加 localStorage 的写法几乎是教程标配,看起来 JWT 在浏览器里找到了自己的正当主场。

把存储方案摊开看,SPA 面对的是一个三难困局。放 localStorage,任何一段被注入的脚本都能把 token 读走,XSS 一旦发生就是登录态裸奔。放内存,刷新页面就丢,只能靠静默刷新不断续签,复杂度不比双 Token 少。放 HttpOnly Cookie,脚本摸不到了,但 CORS 配置、Cookie 的 SameSite 策略都得跟着调——而这正是很多团队当初逃离 Cookie 的原因。

安全社区在这件事上论证得很透:CSRF 和 XSS 不是并列的风险,XSS 永远是更致命的那个,攻击者一旦能执行脚本,任何 CSRF 防御都形同虚设。所以存储的第一原则是让脚本读不到 token,HttpOnly 是底线,其他两条路都是在给底线打折。

规范层面已经给出了答案。IETF 在 2025 年把《OAuth 2.0 for Browser-Based Apps》正式定稿为 RFC 10017,推荐的架构叫 BFF,即 Backend for Frontend:SPA 只和一个轻量后端对话,access token 和 refresh token 全部留在服务端,浏览器拿到手的只有一条 HttpOnly 会话 Cookie。授权码加 PKCE 是固定动作,refresh token 的重放检测是 RFC 9700 里的明确要求。

看清楚这个走向了吗?绕了一圈,SPA 的规范答案还是 Session Cookie。那些坚持纯前端、不引入后端的团队,只能退一步用 localStorage 加 refresh token 轮换,认证服务商社区里确实有人推荐,也有安全研究者专门写文章批判这种折中的脆弱——它是安全下限,不是推荐架构。

所以 SPA 用 JWT 的真相是:它多半是前后端分离早期、Cookie 被 CORS 配置劝退之后的将就,而不是浏览器场景的新答案。BFF 一上,登录态回到 HttpOnly 会话 Cookie 里,JWT 在浏览器里连容身之处都没有。承认这个用法普遍存在,和承认它是无奈之选,是两件事。

双 Token 机制的复杂度被严重低估

JWT 的过期机制催生了 access token 加 refresh token 的双 Token 架构,然后复杂度就开始指数级增长。

refresh token 要存储、要轮换,防止重放攻击要做复用检测,过期边界要处理,前端请求队列要在 token 刷新期间挂起重发,多个标签页之间要同步登录态。中文社区里关于"无感刷新"的踩坑文章汗牛充栋:并发请求撞上 401 之后排队刷新、刷新接口的竞态条件、轮换后旧 token 失效引发的连环失败,每一个都是生产事故的常见来源。

Session 这边呢?设置一个 maxInactiveInterval,到期自动失效。活跃用户的会话在滑动窗口内一直续命,不活跃的用户安静地被清理。一行配置解决的事情,双 Token 架构要用一整套基础设施来模拟。

"分布式所以要用 JWT"是传播最广的误解

支持 JWT 的人有一句万能话术:分布式系统下 Session 没法共享,所以要用 JWT。

这个论点在十年前可能成立,如今早就不是了。Spring Session 加 Redis 的组合把分布式会话变成了一个引入依赖就能解决的问题,会话数据集中存储,所有节点共享,水平扩展毫无压力。这套方案在生产环境里被反复验证过,阿里云、腾讯云的开发者社区里大量实践文章可以做证。

反过来想,JWT 真的让系统变成无状态了吗?上一节说过,为了吊销能力,黑名单还是要建,黑名单还是要放在 Redis 里。绕了一大圈,该有的集中存储一样不少,还多出了签名验签和 payload 膨胀的成本。体积上,Session ID 只有几十个字节,JWT 动辄几百上千字节,每一个 API 请求都要背着这串东西跑。

无状态确实是一个好特性,但它解决的是"认证中心单点"的问题,而不是"认证"本身的问题。为了一个部署架构上的便利,把安全性和可控性打七折,这笔账怎么算都不划算。

token 加 Redis,是第三条路还是换皮 Session

中文社区还有一条流传很广的路线:不用 Session 容器,登录成功后生成一个随机字符串发给客户端,状态存在 Redis 里,网关每个请求查一次。sa-token 把这条路线做成了开箱即用的框架,默认模式就是不透明 token 加 Redis,功能列表里有踢人下线、账号封禁、分布式会话、微服务网关鉴权。注意它官方的自我定位,写的正是分布式 Session。

这条路线的语义,拿 Session 的定义对照一下:一个随机标识,客户端持有,服务端按标识找到状态。这就是 Session,一个字都不用改。Reddit 的 golang 版块有人专门提问"带 Redis 的 JWT 和 Session 有什么区别",回答的方向出奇一致:往认证体系里加进 Redis 校验的那一刻,它就已经不是什么新东西了,它是一个自己手写的 Session。

真实的差异只有三点。生成方不同,Session ID 由框架或容器生成,这条路线由自己的代码生成。存储位置不同,Session 在容器内存或框架管理的共享存储里,这条路线显式放在 Redis,天然跨节点共享。通道不同,Session 走 Cookie 自动携带,这条路线走 Header,对 App 和小程序更友好。除此之外,超时、续期、并发登录控制这些会话语义,两条路线一模一样。

所以这条路线值得选,但要认清选的是什么:它不是 Session 的替代品,是 Session 的手工版。买到的是 Header 通道的自由和状态管理的显式控制,付出的是自己实现全套会话语义、自己扛所有边界情况的代价。Redis 官方博客把 token 存储和会话存储并列成两个专题讨论,业界从来没人把这条路线当成超越 Session 的新范式。

真正的问题不是选谁,是状态放哪里

把 Session、token 加 Redis、JWT 三条路线摆在一起,会发现争论的焦点一直不太对。真正的问题是:登录状态放在哪里,认证结论允许陈旧多久。

放的位置有四种。应用进程内存,最传统,重启即失,天然单机。共享缓存,Spring Session、sa-token、OAuth2 授权服务器都属此类,业务节点无状态,缓存中心承担全部会话。客户端签名,JWT,状态冻结在 token 里,服务端不存。第四种最近几年才成型:边界兑换,网关对外的凭证不透明,进了信任边界再兑换成 JWT,外部可控、内部高效。

新鲜度是另一根轴。每请求查一次 Redis,认证结论零延迟,吊销即时生效;校验结果缓存几秒,用极短的吊销延迟换掉绝大多数查询;JWT 把新鲜度拉到极限,签发那一刻的快照在过期前始终成立。三条路线其实是同一根拨盘上的三个刻度,从来就不存在"无状态对有状态"的对立。

用这把尺子量一下现实,JWT 的无状态宣传就更站不住了。中文微服务社区的标准配置就是 JWT 加 Redis 黑名单,网关层判黑名单是标配动作,阿里云的微服务引擎甚至专门做了 jwt-logout 插件,官方描述里出现了一个精准的词:对 JWT 的弱状态管理。一个要靠"弱状态管理"插件才能支撑登出功能的方案,说它无状态,多少有点自欺欺人。

什么时候该用什么,一张表说清楚

场景推荐理由
传统多页 Web 应用Session + Cookie可撤销、简单、HttpOnly 防 XSS、SameSite 防 CSRF
SPA 单页应用BFF + HttpOnly 会话 CookieRFC 10017 推荐,token 不进浏览器
纯前端无后端折中localStorage + 轮换 + 严格 CSP安全下限方案,重放检测不可省
移动端 AppOAuth2 不透明 Access Token授权码加 PKCE,token 存 Keychain 或 Keystore
单体 API 多端共用不透明 token + RedisHeader 通道对 App 友好,本质是手工 Session
微服务内部调用JWT 或 mTLS短时效自包含凭证,限信任边界内使用
开放第三方 APIOAuth2 Access Token授权服务器统一管理,支持吊销与范围控制
SSO 单点登录中心化认证服务各端共享同一个后端,网页多一层 Cookie 包装

OWASP 的会话管理规范也给出了明确建议:登录成功后重新生成会话标识、设置软硬两种超时、Cookie 使用 __Host- 前缀并配齐 HttpOnlySecureSameSite 属性。这一整套要求全部围绕服务端 Session 展开,没有给 JWT 留位置。

浏览器端的认证选型,答案早就写在这些网站的 Cookie 里了。

回到工程判断

作者手头有个 Java 项目,早先也跟着潮流上了 JWT,双 Token、无感刷新、黑名单一样不落。后来维护的人受不了这套复杂度,评估之后回归了 Shiro 的 Session 管理,所有的踢人、续期、并发登录控制都回到了熟悉的服务端逻辑。当初项目的备注里留了一句话:既然 Shiro 有一套会话服务,又何须 token。

这句话和主流网站的选择,是同一个判断。

所谓无状态,在工程上从来是个刻度问题;所谓新范式,很多只是旧概念换了外壳。技术选型不需要追赶热度。JWT 在它该在的地方——服务间凭证、离线校验、机器身份——依然是称手的工具。但在浏览器会话这个场景里,几十年演进下来的 Session 加 Cookie,配合今天成熟的分布式方案,仍然是那个最省心、最可控、被验证最充分的答案。

主流网站用脚投票的结果,值得每一个做技术选型的人停下来看一眼。