JWT与Session深度对比:无状态认证的真相与安全落地
这两年被 JWT 吹得有点离谱,随便打开一篇技术文章都在讲“JWT 完爆 Session”“无状态认证才是未来”。可真去扒一扒 GitHub、知乎、淘宝这些站点的 Cookie,你会发现它们没有一个在靠 JWT 维持登录态。这中间到底出了什么问题?是主流网站不懂技术,还是 JWT 本身就被讲歪了?我啃了大半年源码、翻了不少大厂方案,说说我的真实看法。
这篇文章不打算站队,也不走那种“JWT 天下无敌”的路线,我只想把 JWT 和 Session 的账一笔一笔算清楚:它到底哪里香,哪里拉胯,主流网站为什么集体不用它,以及如果你项目里非要上 JWT,怎么设计才相对安全。适合正在做登录模块的开发者、被“无状态认证”种草的技术负责人,以及想搞明白 token 续签、防篡改、黑名单这些坑的同学。读完你至少能回答三个问题:JWT 的真实定位是什么、它适不适合我的业务、如果非用它,核心防线要摆在哪。
1. JWT 被吹上天,到底吹的是什么
1.1 一段 token 凭什么能“自证身份”
先说清楚 JWT 是个什么玩意儿。它全称叫 JSON Web Token,翻译成人话就是:把用户信息放到一段 JSON 里,用加密算法签个名,然后发给客户端。客户端以后每次请求都把这个 token 带回来,服务端只要验一下签名,就能确定这段内容是合法的、没有被改过的。
一个标准的 JWT 长这样:
用点(.)分隔成三段:
- Header:声明算法(alg)和类型(typ)
- Payload:存用户 ID、过期时间这些“声明”(claim)
- Signature:把前两段拼起来,用密钥签名
服务端收到 token 后,只要用同一个密钥对前面两段重新算一次签名,和第三段比对一下,相等就说明内容没被改过。这个机制的好处很明显:服务端不用存任何状态,拿到 token 验一下签名就知道是谁,这就是所谓的“无状态认证”。
1.2 开发者喜欢它的几个理由,确实成立
JWT 能火,不是没道理的。它有几个特性是 Session 方案给不了的:
- 天然跨域:Cookie 有 SameSite、Domain 这些限制,跨域带上 Cookie 要配一堆 CORS 和凭证策略。JWT 通常在 Authorization 头里,压根不关心你是哪个域名发来的。
- 分布式友好:后端有 10 个服务节点,Session 方案得让每个节点都能查到 Session 数据(要么粘性会话,要么 Redis 共享)。JWT 呢?每个节点只要能验签名就行,不需要查任何共享存储。
- 移动端适配:App 里没有 Cookie 的概念,如果用 Session 还得手动管理会话 ID。JWT 就是一个字符串,存本地、塞请求头,干净利落。
- 自包含:Payload 里可以直接带上用户 ID、昵称、角色,后端验完签名直接拿去用,不用再查一次库。
这些理由单独拎出来每一个都成立,合在一起就给人一种“JWT 是认证终极方案”的错觉。这也是为什么这么多技术文章愿意吹它——面试能聊、简历能写、Demo 能跑,三件套齐了。
1.3 问题在于:Demo 里能跑的东西,和线上要扛的场景是两码事
我见过太多人照着教程写了个 JWT 登录 Demo,然后直接往生产环境里搬。教程里只告诉他“验签通过就放行”,但没告诉他后面还有一堆事:用户被顶下线怎么办、token 被人偷了怎么办、token 过期了用户还在操作怎么办、密钥泄露怎么补救……这些在 Demo 里都不存在,但线上的用户和攻击者会一个一个帮你试出来。
JWT 的定位被严重误读了。它不是登录态的替代品,而是一种轻量级鉴权协议。Session 完完全全能胜任登录这件事,而 JWT 一旦被用在“需要服务端主动回收的登录态”上,就会暴露出大量结构性缺陷。下面这些坑,是真实业务场景里绕不开的。
2. 主流网站的登录态,根本不是 JWT 那一套
2.1 大厂实际在用的“引用式令牌”到底长什么样
我扒过 GitHub 的 Cookie,也看过知乎、淘宝的请求头,它们维持登录态的其实是一串长的随机字符串——看上去像 sessionid=8d5a9c1f3b7e... 这样的东西。这个字符串本身没有任何可读信息,就是一个“钥匙牌”,服务端拿着它去 Redis 里查出对应的用户数据。
这种形态学名叫 Opaque Token(引用式令牌)。和 JWT 这种 Self-Contained Token(自包含令牌)最大的区别在于:引用式令牌是“凭据 + 服务端存储”,JWT 是“凭据本身就包含了所有信息”。
主流网站的选择几乎一致:登录态用引用式令牌,只在特定场景(比如 OAuth 授权码、OpenID Connect 的 ID Token)里才用 JWT。这背后的原因非常现实:
| 维度 | Session(引用式令牌) | JWT(自包含令牌) |
|---|---|---|
| 服务端存储 | 需要(Redis/内存) | 不需要 |
| 主动失效 | 删 Redis 键即可 | 无法做到(除非黑名单) |
| 信息更新 | 改 Redis 值即可 | 只能重新签发 |
| 体积 | 一个 32 位短字符串 | 至少 1KB+ |
| 跨域 | 需要额外配置 | 天然支持 |
| 适合场景 | 登录鉴权 | 服务间授权、一次性凭证 |
2.2 “无状态”在认证场景里,其实是劣势不是优势
很多人把 JWT 的“无状态”当成最大卖点,但换个角度想:你的账单系统需要知道“这个用户已被封禁”,JWT 不知道;运营后台要把“异常的账号”秒踢下线,JWT 做不到;用户改完密码后希望“其他设备全部退出登录”,JWT 还是做不到。
Session 方案为什么能秒做这些事?因为服务端有状态。登录态的本质就是“服务端对客户端的临时信任记录”,这种信任天然就应该被服务端掌控。你把信任凭证做成一个“自解释”的小纸条(JWT),意味着你已经把管理的权力让渡给了客户端。任何一次你需要主动收回信任的动作——封号、踢人、改权限、拉黑设备——都会变成一场和 JWT 的对抗战。
Google 的登录系统、GitHub 的会话机制,核心思路全都是:短期票据 + 服务端会话管理。它们不是不懂 JWT,而是太懂业务对“状态控制”的需求了。你要的从来不是无状态,而是分布式环境下无需共享 Session 的验证能力——这两个诉求被 JWT 强行捆绑,是认知上最大的误区。
3. 四个硬伤:登出失效、续签困境、体积膨胀、算法混淆
3.1 登出与踢人:JWT 的“已过期”不等于“已失效”
这是 JWT 项目里最常见的线上事故来源。用户点了“退出登录”,前端把 token 删了,但 token 在有效期内依然是一张有效的入场券。如果它被人截获、被留在浏览器历史里、被第三方脚本偷偷copy了一份,服务端完全没有办法让它“作废”。
要实现“退出后 token 失效”,一般只能两条路:
- 把 token 的 jti(JWT ID)存到一份黑名单里,每次请求都查一遍黑名单
- 给用户维护一个“token 版本号”,每次签发递增,验签时比对版本
但这两条路的本质,都是重新引入了服务端存储。一旦你为了能踢人而加了黑名单,“无状态”这个最大优势就已经名存实亡了。更尴尬的是,你付出了无状态的好处,却还没换来 Session 方案的管理能力——黑名单还得自己管过期时间、自己处理并发。
3.2 续签困境:过期时间怎么设都是错
JWT 的 exp 声明是用来控制过期时间的,但在真实场景里你会发现它怎么设都有问题:
- 设短(比如 15 分钟):用户刷着刷着就被踢下线,体验极差,前端还得写一堆 401 拦截逻辑,静默刷新队列,后端要配合一个 refresh token 接口。
- 设长(比如 7 天):登录态长期有效,token 一旦泄露,攻击者有整整一星期的时间窗口可以为所欲为。
- 设成“用户活跃就自动续”:这就要在服务端维护状态了,又绕回了有状态方案。
就算你搞一套 access token + refresh token 的双 token 体系,麻烦也一点不少。refresh token 必须存库、必须支持轮换、必须处理并发刷新时的 token 家族(token family)安全——一个简单的登录功能,被你写出了一个 OAuth 服务端的复杂度。这还是在没考虑客户端时钟偏差(clock skew)、token 在多个 tab 间同步这些边角问题的情况下。
我见过一个团队做 App 登录,因为 JWT 过期时间设了 30 天,结果某天一批测试 token 泄露到日志系统里,只能临时改密钥把所有用户全部踢下线重新登录——那一刻他们才明白 Session 方案里一句 redis.del(session_id) 有多么珍贵。
3.3 体积膨胀:每一个请求都在为“自包含”买单
JWT 的 Payload 里只要多塞几个 claim,token 体积很快就冲破 1KB。1KB 是什么概念?一个 Session ID 只有 32 个字符左右。每次请求都要把整个 JWT 塞进 Header,在移动端弱网环境下,这 1KB 会实打实消耗用户流量、增加请求耗时。
有人会说:1KB 算什么大?但你算的是单次请求,实际业务里一个页面可能要发 10 个接口,每个接口都带 1KB token,那就是 10KB 额外开销。内网服务间调用还好,公网 API、移动端 API、IoT 设备上,这个浪费是实实在在的。
更麻烦的是,Payload 太大还会导致 URL 放不下。有些 GET 请求需要带 token 做分享链接、下载链接,JWT 那一段超长字符串塞进 URL 不仅难看,还可能被网关截断。Session 方案用短 ID,这种问题根本不存在。
3.4 算法混淆与密钥管理:你以为验签了,其实被绕过了
JWT 库踩坑概率最高的是算法相关漏洞,最出名的是这两类:
- alg: none:老版本库如果没严格校验算法白名单,攻击者把 Header 里的 alg 改成
none,再删掉签名段,某些库就会直接通过验证。 - RS256 降级 HS256:服务端用 RSA 非对称签名(私钥签名,公钥验签),攻击者把 alg 改成 HS256(对称签名,同一个密钥签名和验签),再用服务端的公钥当作 HMAC 密钥去签名。如果服务端没有校验算法白名单,就会拿公钥去验 HS256 签名——结果就是攻击者可以自由伪造 token。
这些漏洞的根因不只在库,更在开发者对“签名算法是密码学基础设施”这一点没有足够敬畏。使用非对称加密(RS256/ES256)意味着你得管理私钥的生成、存储、轮换、泄露应急——这个话题聊起来又是一个大坑。而如果使用对称加密(HS256),所有服务节点共享同一个 secret,任何一个节点被攻破,整个系统的 token 都可以被伪造。
相比之下,Session 方案没有签名密钥这个概念,服务端只需要保证 Session ID 的随机性足够强就行。攻击面小了一个量级。
4. 也别一棍子打死:JWT 的真正主场在哪儿
4.1 单点登录(SSO)与第三方授权
JWT 最典型的成功场景是 OAuth2.0 / OpenID Connect。用户在 A 站点登录,拿到一个 ID Token(JWT),拿着它去 B 站点换取访问凭证。这时候 JWT 的自包含特性成了巨大优势:A 和 B 之间不需要共享 Session 存储,B 只要验签就能确认用户身份。
注意一个关键差别:这里的 JWT 通常是短期有效的票据,不是持续的登录态。它解决的是“一次性证明用户身份”的问题,而不是“维持用户在线状态”的问题。大多数 SSO 流程里,JWT 只是整个链路中的一环,换来换去之后,各站点最终还是会建立自己的 Session。
4.2 微服务/API 网关鉴权:验签一次,全局放行
在内部微服务架构里,JWT 的价值是真实的。网关收到用户请求后,验证 JWT,把用户信息解析出来塞进请求上下文,下游服务不再需要查用户中心。这种模式下,下游服务完全无状态,既不用连 Redis,也不用调本地 session store,扩展起来极其舒服。
前提是你控制得住 token 的生命周期和密钥。很多中台系统会把 access token 的有效期压到 5 分钟以内,再配合定时刷新,让 token 泄露的窗口尽可能小。这个能接受“短生命周期 + 主动刷新”的场景,才是 JWT 的高光舞台。
4.3 一次性临时授权:邮件验证、链接分享、扫码确认
JWT 还有一个被低估的场景:一次性凭证。比如“重置密码链接”里的 token,本质就是“在 10 分钟内允许这个人改密码”的临时授权。用 JWT 做这种授权,签名保证无法伪造,过期时间写死在 exp 里,用完即弃。即使 token 被泄露,有效期也短到不值得攻击者费劲。
同样的思路可以用在:文件下载的临时签名 URL、扫码登录的确认码、Webhook 回调鉴权。这时 JWT 的“自包含”不必查库,短期有效,就能发挥它最大的价值。
5. 如果必须用 JWT,这套落地方案能帮你绕开 80% 的坑
5.1 生命周期别贪:短 access + 长 refresh 才是正解
如果你确实要用 JWT 做登录态(比如 SPA 纯前端项目确实图省事),那请一定不要把 access token 的有效期设得太长。我的参考配置:
| 参数 | 建议值 | 理由 |
|---|---|---|
| access token 有效期 | 15 分钟 | 泄露窗口足够小 |
| refresh token 有效期 | 7~30 天 | 用户无需频繁登录 |
| refresh token 存储 | 服务端数据库/Redis | 支持主动吊销 |
| 刷新机制 | refresh token 一次性轮换 | 旧 refresh token 无效 |
access token 过期后,前端用一个 refresh token 去换取新的 access token,拿到手马上更新本地存储。这套流程写出来不难,难点在于处理并发情况:用户同时打开了三个 Tab,三个 Tab 都发现 access token 过期,会同时发起三个 refresh 请求——你需要一个“刷新队列”来合并这些并发请求,只让第一个请求真正去刷新,剩下的共享第一次的结果。
5.2 黑名单兜底 + 服务器时钟校准
就算你选了短生命周期方案,也建议留一条黑名单后路:特定条件下的 token 需要立即失效,比如用户改密码、封号、检测到异地登录时。
不用所有 token 都查黑名单,只有这些“安全敏感”操作才需要。把需要失效的 jti 写进 Redis,设置一个等于 token 剩余生命周期的过期键。验签通过后,再花一次 Redis 查询确认 jti 不在黑名单。这个方案虽然不再是纯无状态,但只在你真正需要主动踢人的场景下启用,能平衡大多数业务需求。
另外两个小点别忽略:
- 服务端验签时用
exp判断过期,别信客户端的的iat,服务端和客户端的系统时间可能有偏差 - 设一个很小的时钟容差(比如
clockTolerance: 30秒),避免因为服务器和用户手机时间不一致导致误判过期
5.3 签名算法与密钥处理:这个没得商量
用 JWT 就绕不开签名算法,我的建议没有任何模糊空间:
- 用 RS256 或 ES256,不要用 HS256 做生产环境服务间认证。对称密钥意味着所有服务共享一个 secret,一旦某个服务被攻破,整个系统的 token 可信度归零。非对称密钥下,只有私钥能签发,公钥到处分发都没关系。
- 校验算法白名单。无论用什么库,都要显式声明
algorithms: ['RS256'],而不是交给库自动判断。这是防住 alg:none 和算法混淆攻击的唯一可靠方式。 - 私钥单独保管,定期轮换。正确做法是把私钥放到专门的密钥管理系统里,服务启动时读取,不要在代码库里明文提交。轮换周期建议最长半年,出过泄露事件立刻全量换。
- 密钥泄露应急方案:一旦怀疑私钥泄露,要有“全员 token 失效”的能力。如果你一直用短 access token,最多等 15 分钟就能自然恢复安全;如果只有一条长 token,那就只能靠黑名单或者强制改密钥来收场了。
5.4 Payload 里别放敏感数据
JWT 的 Payload 虽然要签名才能改,但它是 base64url 编码的,任何人拿去解码都能看到明文内容。所以:密码、手机号、身份证号、银行卡号、真实姓名,一概不能放。
Payload 里只放:
sub:用户 IDrole:角色/权限标识(可选)exp、iat、jti:过期时间、签发时间、token 唯一 ID
需要更多用户信息时,后端拿 sub 去查库或者查缓存。有人觉得这不就又多了一次查询了吗?没错,这就是自包含 token 和数据库访问之间的权衡——但安全起见,这个查询成本不能省。
下面是一个基于 Express 的最小示例,演示了 RSA256 签名 + jti 黑名单的基本链路:
redis 黑名单写入时机是:
- 用户主动登出
- 管理员封禁/踢人
- 用户改密码
- 检测到 token 异常使用
这个 ttl 等于 token 的剩余有效期。也就是说,blacklist 里这些键在 token 过期后会自动消失,不会无限累积,Redis 内存不会爆。
这整套方案下来,你其实已经不是在享受 JWT 的“无状态”了,而是在享受 JWT 的“跨域 + 可解析 + 扩展性”,同时用部分状态去补它最大的两个缺陷:主动失效和长周期安全。
6. 我的最终建议:什么项目该上 JWT,什么项目坚决别上
说实话,我自己做过的最难受的一个项目,就是刚入行时在一个纯后端管理系统里强行上了 JWT。当时照着网上的教程写,登录、鉴权、续签都跑通了,结果产品提了一个需求:“运营后台要能一键把所有测试账号踢下线。”我当场就傻眼了——JWT 根本做不到,最后只能硬着头皮给每个用户加 token 版本号,加黑名单,改了一堆代码,才把需求圆回来。如果一开始就老老实实用 Session + Redis,这个需求就是一行 redis.del() 的事。
现在我选型基本遵循这几条经验:
- 登录态 / 会话保持:首选 Session 方案(服务端存储 + Opaque Token)。要跨域就把 Opaque Token 放 Header,照样能用,完全没有必要上 JWT。
- 需要承载 SSO、跨域授权、第三方开放平台:用 JWT 作为短期授权票据,配合标准的 OAuth 流程。
- 微服务内部鉴权、API 网关:用短生命周期 JWT,让下游服务无状态验签。前提是密钥和黑名单这套基建你已经能吃下来。
- 内容分享、下载链接、密码重置邮件这种一次性场景:JWT 是完美的,又短又安全,用完即焚。
- IoT / 弱网设备:优先用短 Session 或 Opaque Token,不要在弱网条件下跟 1KB 的 token 体积较劲。
最后再分享一个小技巧:不管用哪种方案,登录模块的核心安全边界都不在技术本身,而在你的“主动控制能力”。Session 方案的主动控制能力天生强,JWT 需要你额外加固。评估一个技术能不能上生产,不是看它能不能跑通 Demo,而是看它遇到“用户要被踢、Token 要作废、权限要即时变更”这些真实业务动作时,会不会让你手忙脚乱。JWT 的“无状态”是所有优点的来源,也是所有缺点的根——想清楚这一点,你就知道该在什么时候用它的潇洒,什么时候主动放弃它的潇洒。