---
URL: "/zh-CN/blog/2026-10/JWT-Only.html"
LLMS_URL: "/zh-CN/blog/2026-10/JWT-Only.md"
lastUpdated: true
commentabled: true
recommended: true
title: "被吹上天的 JWT，为什么主流网站一个都不用"
description: "被吹上天的 JWT，为什么主流网站一个都不用"
date:

pageClass: "blog-page-class"
cover: "/covers/platform.svg"
---

打开浏览器的开发者工具，翻一翻 github.com 的 Cookie。里面只有一条 `_gh_session`，一串 Rails 框架加密后的会话数据。再去 google.com 看，`SID`、`HSID`、`SSID`、`SAPISID` 排成一列，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 会话 Cookie | RFC 10017 推荐，token 不进浏览器 |
| 纯前端无后端折中 | localStorage + 轮换 + 严格 CSP | 安全下限方案，重放检测不可省 |
| 移动端 App | OAuth2 不透明 Access Token | 授权码加 PKCE，token 存 Keychain 或 Keystore |
| 单体 API 多端共用 | 不透明 token + Redis | Header 通道对 App 友好，本质是手工 Session |
| 微服务内部调用 | JWT 或 mTLS | 短时效自包含凭证，限信任边界内使用 |
| 开放第三方 API | OAuth2 Access Token | 授权服务器统一管理，支持吊销与范围控制 |
| SSO 单点登录 | 中心化认证服务 | 各端共享同一个后端，网页多一层 Cookie 包装 |


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

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

## 回到工程判断 ##

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

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

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

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