JWT 不是登录方案:三种登录态架构怎么选

最近讨论 JWT 时,经常会出现两种相反的结论:一种认为 JWT 天然比 Session 更现代,另一种认为成熟网站都不该用 JWT。把这两个结论放在一起看,问题其实不在 JWT 本身,而在于大家把三个不同层次的东西放进了同一场比较。

Session 解决的是服务端如何保存登录状态;JWT 是一种携带声明的令牌格式;Opaque Token 则强调令牌内容对持有方不可见,资源服务器需要向授权服务查询状态。它们可以出现在同一个系统里,也可以组合使用,并不是三种互斥的登录技术。

真正决定方案的,不是"哪个更先进",而是四个问题:登录发生在浏览器还是 API 客户端?权限变化后要多快生效?资源服务器能不能独立验证?系统是否愿意为每次请求增加一次状态查询?把这四个问题回答清楚,选型就不会只剩下争论。

一、先把三个概念放回正确层级

先看最常见的服务端 Session。用户登录成功后,服务端创建一条会话记录,把用户 ID、登录时间、权限版本、过期时间等状态保存在服务端存储中。浏览器只保存一个随机的 Session ID,通常通过 Cookie 自动带回。服务端收到请求后,用这个 ID 找到会话,再决定是否允许访问。

JWT 的全称是 JSON Web Token。它把一组声明放进令牌中,并通过签名或加密等机制保护。常见的签名 JWT 可以让资源服务器在拿到令牌后,使用可信公钥验证签名,再检查过期时间、签发者和受众等字段。它的主要价值是把一部分验证所需的信息放在令牌里,而不是每次都查一条服务端会话记录。

Opaque Token 通常是一段没有可供客户端直接理解的随机字符串。资源服务器不能只靠解析字符串知道用户和权限,需要查询授权服务器或受控缓存,确认令牌是否有效、有哪些 Scope、是否过期或已撤销。OAuth 2.0 的 Token Introspection 规范就定义了这种查询方式。

因此,这三者不是同一维度的名词。Session 说的是状态存放在哪里;JWT 说的是令牌如何表达和验证;Opaque Token 说的是令牌对资源服务器是否透明。一个系统完全可以让浏览器使用 Session Cookie,让后端服务之间使用 JWT,再让第三方 API 使用 Opaque Access Token。

二、先看这张选型矩阵

评估维度 服务端 Session + Cookie JWT Access Token Opaque Access Token
状态主要放在哪里 服务端会话存储 令牌声明,必要时仍需服务端状态 授权服务器或令牌存储
请求时如何验证 查 Session 本地验签并检查声明 查询授权服务器或缓存
立即撤销能力 较直接,删除或禁用会话 需要额外撤销机制,或等待过期 可通过状态查询反映撤销
多资源服务器验证 共享会话存储或统一网关 可分发公钥并本地验证 依赖 Introspection 或共享缓存
每次请求的额外成本 会话存储查询 通常是本地密码学验证 通常有一次查询或缓存命中
浏览器端暴露面 Cookie 可设 HttpOnly 若令牌交给前端脚本,XSS 影响更大 令牌仍需安全保存,Opaque 不等于防盗
更适合的场景 同源 Web、后台管理、需要快速失效 多服务 API、需要本地验证 权限动态变化、撤销要求较强的 API

这张表不是性能排名。Session 查询可能被 Redis 或本地缓存优化,Opaque Token 的 Introspection 也可以缓存;JWT 本地验签虽然省掉一次状态查询,但如果每个请求还要查用户权限、租户状态或撤销名单,它就不再是真正的无状态路径。

选型时还要区分访问令牌和刷新令牌。Access Token 用于访问资源,通常应该短时有效、权限范围尽量小;Refresh Token 用于换取新的 Access Token,权限更大、保存时间更长,保护要求也更高。不要因为 Access Token 使用 JWT,就顺手把 Refresh Token 也设计成长期可读、长期有效的 JWT。

如果你的产品是普通 Web 网站,用户通过浏览器登录,前后端又属于同一站点或可控的几个域名,那么服务端 Session 往往是更简单的起点。登录成功后,服务端生成不可预测的会话 ID,把真实会话状态放在服务端;浏览器只保存 ID。用户退出、账号被禁用或权限发生重大变化时,服务端可以直接让对应会话失效。

Cookie 应明确设置安全属性,而不是依赖浏览器默认值。常见的会话 Cookie 可以采用如下形式:

http 复制代码
Set-Cookie: __Host-session=<random-id>; Path=/; Secure; HttpOnly; SameSite=Lax

Secure 限制 Cookie 只通过 HTTPS 发送;HttpOnly 阻止普通页面脚本通过 document.cookie 读取它;SameSite 可以减少部分跨站请求携带 Cookie 的情况。SameSite 是纵深防御,不应被当成 CSRF 防护的唯一措施。还要检查登录、退出、修改密码和权限变更时的会话轮换与失效逻辑。

如果前端是 SPA,但后端可以提供一个同源 BFF(Backend for Frontend),可以让浏览器只持有 HttpOnly Cookie,由 BFF 在服务端保存和刷新 OAuth Token。浏览器不需要直接接触长期 Refresh Token,前端也不必把凭证放进 localStorage。OWASP 的 Session Management 指南明确提醒,不要把 Session ID、JWT、Access Token 或 Refresh Token 当作普通业务数据存进 localStorage 或 sessionStorage,因为同源 JavaScript 一旦受到 XSS 影响,就可能读取这些值。

这并不表示 Cookie 没有风险。浏览器会自动附带 Cookie,因此服务端仍要正确处理 CSRF、跨站请求、Cookie 域与路径范围、过期时间和会话固定问题。对于同源 Web,Session Cookie 的优势是服务端能控制状态,代价是需要维护会话存储和相关基础设施。

四、JWT 真正有价值的地方是跨服务本地验证

JWT 的优势出现在资源服务器较多、服务之间希望独立验证访问令牌的场景。授权服务器签发短时 JWT Access Token,资源服务器通过可信的密钥材料验证签名,再检查必要声明,就可以在本地完成一部分授权前置判断,不必为每个请求都回源查询会话。

但"能验签"不等于"可以直接放行"。验证时至少要固定允许的算法集合,不能照单全收令牌头里的 alg;要验证 iss 是否属于可信签发者,aud 是否包含当前资源服务器,exp 是否尚未过期,并根据业务检查 scope、sub、tenant 等字段。不同用途的 JWT 还应使用相互独立的验证规则,避免把一个场景签发的令牌拿到另一个场景使用。

JWT 的签名也不等于加密。普通签名 JWT 的 Payload 通常可以被解码读取,不应把密码、密钥、身份证号等敏感内容塞进去。签名主要用于完整性和来源验证,不负责隐藏内容。RFC 8725 专门列出了算法混淆、弱密钥、Issuer 和 Audience 校验缺失、不同用途令牌混用等实现风险。

真正容易被低估的是撤销问题。假设用户刚刚被管理员禁用,某个已经签发、尚未过期的 JWT 仍可能通过本地验签。若业务要求权限变化立即生效,就必须增加额外机制,例如缩短令牌有效期、维护撤销列表、检查用户权限版本,或在关键操作前回到授权服务确认状态。每增加一次状态查询,都会重新引入一致性、缓存和可用性成本。

所以 JWT 不是"完全无状态"的同义词。它只是让资源服务器可以在某些条件下本地验证令牌。只要业务还需要即时禁用、租户隔离、动态角色或设备撤销,就要明确这些状态由谁保存、多久同步、缓存失效如何处理。

五、Opaque Token 用查询成本换取状态控制

Opaque Access Token 的思路相反:令牌本身不承担完整的权限表达,资源服务器拿到它以后,向授权服务器的 Introspection Endpoint 查询当前状态。授权服务可以返回 active、scope、client_id、sub、aud、exp 等信息,资源服务器根据结果决定是否放行。

它的优势是撤销和权限变化更容易集中控制。管理员撤销令牌后,后续查询可以返回 active=false;授权服务也可以根据当前账号状态、令牌范围和目标资源作出判断。对于权限变化频繁、需要集中审计或资源服务器不能自行维护撤销状态的系统,这种方式有实际价值。

代价也很明确:资源服务器多依赖一个网络服务。授权服务器变慢或不可用时,资源访问会受到影响;每次都查询会增加延迟和负载;缓存 Introspection 结果虽然能减轻压力,却会形成一段撤销信息尚未同步的窗口。RFC 7662 明确要求设计者考虑缓存时长与安全性之间的取舍,不能只把缓存打开就当成性能优化完成。

Opaque Token 也不是天然比 JWT 安全。只要令牌被窃取,攻击者仍可能在令牌有效期间使用它;它只是让服务端更容易控制令牌状态。传输、存储、Scope 限制、客户端认证、访问日志脱敏和令牌轮换仍然需要完整设计。

六、按四个问题走完选型路线

先问客户端是谁。如果是普通浏览器网站,并且有自己的后端,优先评估 Session Cookie 或 BFF。浏览器只持有短小的会话凭证,服务端保留真正的登录状态。若是移动端、桌面端或第三方 API 客户端,再按 OAuth 客户端类型和资源服务器结构选择 Access Token。

第二个问题是撤销时效。用户退出后只要求当前浏览器会话失效,服务端 Session 很直接;管理员禁用账号后要求所有 API 尽快停止访问,Opaque Token 加 Introspection 更容易集中控制;如果能接受短时间的令牌有效窗口,JWT 配合较短过期时间也可以成立。不要把"调用了撤销接口"误认为所有资源服务器已经立刻拒绝旧 JWT。

第三个问题是资源服务器能否独立验证。如果资源服务器分布在多个服务或边缘节点,希望不依赖每次回源,就考虑 JWT 本地验签。但前提是密钥分发、轮换、Issuer、Audience、Scope 和过期时间都有明确规则。如果这些条件没有准备好,JWT 只会把复杂度从请求路径搬到密钥管理和故障排查里。

第四个问题是前端是否必须直接持有 Token。能通过 BFF 代理就不必把长期凭证交给浏览器脚本;必须由公共客户端持有令牌时,应采用符合 OAuth 安全最佳实践的授权流程。RFC 9700 对公共客户端的 Refresh Token 保护提出了明确要求:使用发送方约束令牌,或采用 Refresh Token Rotation 来检测重放。不要自己发明一个永不过期的刷新凭证。

把路线压缩成下面这张图:

text 复制代码
先看客户端
├─ 浏览器 Web
│  ├─ 有自己的后端 / BFF → Session Cookie,Token 留在服务端
│  └─ 必须前端直连 API → 按 OAuth 公共客户端规则设计
└─ 移动端 / 桌面端 / 第三方 API
   ├─ 需要集中撤销、动态权限 → Opaque Token + Introspection
   └─ 需要资源服务器本地验证 → 短时 JWT + 严格声明校验

这不是绝对规则,而是把最关键的架构成本提前摆出来。选 JWT 需要承担密钥和撤销设计;选 Opaque Token 需要承担授权服务查询与缓存一致性;选 Session 需要承担服务端会话存储和跨服务共享。没有哪一种方案能把这些成本全部消掉。

七、三个常见项目可以这样落地

小型后台管理系统:先使用服务端 Session 和安全 Cookie。会话记录放在服务端存储,登录后轮换 Session ID,退出和密码变更时使会话失效。不要为了"看起来分布式"就把每个浏览器会话改造成 JWT。

SPA 加自有后端:优先考虑 BFF。浏览器通过 HttpOnly Cookie 访问 BFF,BFF 保存 OAuth Token 并代为访问下游 API。这样可以减少长期令牌暴露在浏览器 JavaScript 环境中的机会。若确实需要跨站调用,先把 Cookie、CSRF、CORS 和刷新流程设计完整,不要把 localStorage 当作默认答案。

多服务 API 平台:如果资源服务器数量多、可以稳定分发公钥,并且接受短时令牌的撤销窗口,JWT Access Token 是合理选择;如果权限需要集中即时收回,Opaque Token 加 Introspection 更容易实现。也可以按风险分层:普通读接口使用短时 JWT,高风险操作额外检查账号状态或权限版本。混合方案必须明确哪些接口走本地验证、哪些接口必须实时检查,避免不同服务对同一令牌作出不同判断。

最后再强调一次:认证方案的目标不是让每个请求都少查一次数据库,而是让系统在正常请求、用户退出、权限变更、密钥轮换和服务故障时,都能给出一致且可解释的结果。真正的成本不是 JWT 多几个字段,而是当你需要撤销、审计和跨服务一致性时,系统是否已经为这些事情留好了位置。

如果你正在做一个真实项目,你更难解决的是"用户退出后立即失效",还是"多个资源服务器不想每次回源"?把客户端类型、服务数量和撤销要求说清楚,通常比先争论 JWT 好不好更容易得到可执行的答案。

参考资料

相关推荐
starzy199017 分钟前
深入理解Scala Map:从实现原理到工程实践
开发语言·后端·scala
卷无止境1 小时前
凌晨三点的绿灯
后端
excel2 小时前
基于 GPT 的自动发布文章接口方案:从一句话或一张图片生成并发布内容
前端·javascript·后端
风若飞4 小时前
Spring Ai 入门遇到问题1
java·后端·spring
AI入职记4 小时前
Spring AI 结构化输出:结构 100% 通过,严格准确率只有 57%
后端·ai编程
aZhe的全栈知识分享4 小时前
AI 还原 Figma 效果总达不到预期?先别急着让它写代码
前端·后端·面试
码事漫谈4 小时前
WorkBuddy 一直更新卡死?两个毛病,一套处理办法
后端
函数小陈4 小时前
阿里云部署DSH与RAM完成运维
前端·后端
aurora_zjj4 小时前
一次持续 3 年的 OpenResty 死循环排查:最终定位到 LuaJIT ARM64 的一条汇编指令
后端