登录接口刚写完,就往项目里塞 access token、refresh token、无感刷新和黑名单,仿佛少了这一套,系统就不够现代。等到发现它们也需要维护,又容易走向另一个极端:JWT 是骗局,浏览器认证只有 Session 才正确。这两种说法都把凭证格式和整套认证设计混在了一起。《被吹上天的 JWT,为什么主流网站一个都不用》问得很直接:普通 Web 应用,为什么要为一个登录态承担那么多复杂度?它对浏览器存储、主动撤销和 BFF 的提醒有道理,也承认 JWT 在服务间身份传递等场景的价值。但浏览器会话是否应该排除 JWT,还得把 Cookie、凭证存储和撤销机制分别说清。在之前的《JWT 不是无状态银弹:Session、Redis 与 WebSocket 分布式鉴权的工程取舍》里,讨论重心是服务端状态、主动踢人和长连接治理。这些问题仍然成立,但它们不足以回答另一组问题:浏览器到底应该持有什么?Cookie 和 JWT 是不是对立的?BFF 解决了什么,又留下了什么?一、JWT、Cookie 和 Session 分别解决什么一句“选 JWT 还是 Session”,经常把五个决定压成了一个。决策维度实际要回答的问题可选方式凭证表示这张票据里有没有可验证的声明?随机不透明字符串、签名 JWT、加密票据请求携带凭证怎样进入 HTTP 请求?Cookie、Authorization Header浏览器暴露面页面脚本能否直接读取或使用它?HttpOnly Cookie、内存、Web Worker、本地持久存储服务端状态有效性由哪里提供最终依据?本地验证、会话存储、introspection、混合校验授权新鲜度撤权后多久必须停止放行?每请求检查、短缓存、短期快照、事件失效JWT 用来表示声明,Cookie 负责让浏览器保存和携带数据,服务端 Session 维护会话状态。这几个选择可以组合。RFC 7519 定义的 JWT 既可以使用 JWS 保护完整性,也可以使用 JWE 加密。常见的三段式签名 JWT 可以被解码,不代表所有 JWT 都是明文;更不代表 JWT 必须放进 localStorage。浏览器里的“session cookie”还有另一层含义:它通常指没有设置持久化到期属性的 Cookie,描述客户端生命周期,并不证明服务端保存了一条 Session 记录。浏览器会话何时结束,也由用户代理定义,见 RFC 6265。OAuth 又是另一层。RFC 6749 允许 access token 是一个索引,也允许它携带可验证的授权信息;OAuth 不要求 access token 一律使用 JWT。反过来,一个普通网站也不需要因为使用了随机会话 ID,就宣称自己实现了 OAuth。因此,下面几种组合都不矛盾:TEXT随机会话 ID + HttpOnly Cookie + 服务端会话存储 签名 JWT + HttpOnly Cookie + 撤销状态检查 加密认证票据 + Cookie + 按需校验账号变化 不透明 access token + Authorization Header + introspection选型时,需要说明自己选了哪种凭证、怎样传输、存在哪里、怎样检查有效性。只写“使用 JWT”或“使用 Session”,还不足以描述一个方案。二、看几个网站的 Cookie,证明不了整个行业的架构打开开发者工具,是很好的观察起点。但观察到某个 Cookie,不等于已经掌握这个系统的认证链路。客户端能看到凭证的名称、长度、属性和传输位置,却看不到服务端是否查询状态、网关是否把它换成别的票据。网页、移动端和开放 API 也可能采用不同的机制。尤其是加密票据,客户端无法靠外观判断里面装的是随机索引,还是一组经过保护的声明。如果要论证主流网站都不采用某种方案,至少需要明确样本、观察日期、产品入口,以及“采用”的定义。只观察几个网站的浏览器 Cookie,再拿来替所有 Web 应用做选型,证据不够。没有统计方法的比例数字,也不该承担架构结论。下面两个官方公开的方案,可以直接检查这些组合是否成立。一个直接反例:JWT 也能是会话 CookieFirebase 官方的 Manage Session Cookies 明确提供 JWT 形式的服务端会话 Cookie。浏览器先完成登录,再把 ID token 交给服务端兑换成会话 Cookie,之后清理前端认证状态。这能说明浏览器会话和 JWT 格式可以配合使用,不能用来推断 Google 搜索首页的实现,也不能据此推荐所有网站都这样做。官方同时说明,基础验签可以使用缓存的公钥;若启用撤销检查,每次校验会额外发起网络请求。这套方案用了 JWT,也用了 Cookie;需要检查撤销时,照样要查询当前状态。凭证格式没有替它省掉这一步。另一个反例:Cookie 认证不自动等于服务端 SessionMicrosoft 的 ASP.NET Forms Authentication 说明 展示了把认证票据加密后写入 Cookie 的机制。Cookie 装着票据,并不自动证明服务端另存一行会话。现代 ASP.NET Core 的 Cookie Authentication 文档 更直接提醒:后端禁用账号以后,已经签发的认证 Cookie 仍可能被接受,需要通过 ValidatePrincipal 等机制响应账号变化。可见,选择 Cookie 以后,仍然要设计账号禁用和会话撤销怎样生效。三、攻击者能偷走凭证,还是只能借浏览器做事同样是凭证泄露或请求被冒用,攻击者具备的能力可能不同。下面三种情况需要分开处理。1. 把凭证偷走,离开浏览器继续用如果 bearer 凭证能被页面脚本直接读取,那么同源恶意脚本可能把它复制出去。攻击者随后可以在自己的环境里重放,而不必一直依赖受害者的页面。这也是为什么 OWASP HTML5 Security Cheat Sheet 反对把会话标识保存在 localStorage。但这项风险不专属于 JWT。把一条随机字符串放在那里,同样可以被窃取。RFC 6750 对 bearer token 的关键定义,就是持有者不必证明自己拥有额外密钥。JWT 和随机字符串都可能是 bearer 凭证,不能靠外观判断能否重放。2. 不偷凭证,直接借你的浏览器做事HttpOnly 能阻止脚本通过 document.cookie 读取 Cookie,但不能阻止恶意脚本利用当前浏览器发起请求。OWASP Session Management 说明了这个限制:HttpOnly 保护 Cookie 的机密性,不能阻止 XSS 发起带 Cookie 的请求。假设某后台页面可以正常提交一条管理操作。攻击者已经能在同源页面执行代码,就可能调用同样的接口。Cookie 不必被读出来,浏览器仍会在符合条件时携带它。所以,防止凭证被带走和防止当前页面被操纵,是两项不同的目标。BFF 不向前端代码直接暴露 OAuth token,可以减少脚本窃取它的机会。对操纵当前页面的攻击,仍需要输入处理、输出编码、脚本来源控制、服务端授权,以及高风险操作的额外确认。3. 从别的网站诱导浏览器自动携带凭证传统 CSRF 针对的是浏览器自动附带的身份信息。它和 XSS 不在同一个攻击前提下。Cookie 自动携带,需要有对应的 CSRF 防护;由合法页面代码显式添加的 Authorization Header,在合理的跨域配置下不具有同样的自动附带行为。但这不能推出 Header 模式自动消灭所有伪造请求,更不能推出 Header 必须配 localStorage。OWASP CSRF 指南 讨论了 CSRF token、来源检查、Fetch Metadata 和自定义 Header 等机制,也提醒 XSS 可以绕过 CSRF 防护。CORS 控制跨源交互,并不是阻止所有请求到达服务器的防火墙。可以把常见控制的边界放在一起:控制主要收益不能据此承诺什么HttpOnly限制脚本直接读取 Cookie页面不会发生 XSS,恶意脚本不能发请求Secure限制 Cookie 经安全连接发送凭证不会被业务日志、服务器漏洞泄露SameSite限制部分跨站 Cookie 携带完整替代 CSRF 防护,同站就是同源页面内存或 Worker缩短持久暴露,提供一定隔离已被同源代码控制的页面完全安全BFFOAuth token 不交给前端代码会话、代理接口和下游授权无需治理HttpOnly、CSRF 防护和服务端授权要分别配置,不能因为其中一项已经启用,就省掉其他检查。四、BFF 改变了 token 的保管和请求路径BFF 是 Backend for Frontend,这里讨论的是由面向前端的后端组件承担 OAuth 客户端职责,并代理资源请求的模式。截至 2026 年 10 月 9 日,RFC 10017 已经是正式的最佳实践文档,发布日期是 2026 年 8 月,不是原文所写的 2025 年。这处日期有误,但不影响继续讨论 BFF 的价值。这份规范讨论的是使用 OAuth 的浏览器应用,列出了三种主要模式,按安全性递减排列:模式OAuth token 在哪里使用主要取舍BFF后端使用 token 并代理请求,不向前端代码直接暴露减少前端 token 暴露,增加后端责任Token-Mediating Backend后端处理 token 获取,浏览器获得 access token 后直连 API保留部分后端保护,也保留 access token 暴露浏览器 OAuth 客户端浏览器负责 OAuth token 的获取和使用部署形态直接,但浏览器承担更多凭证保护责任这个比较的对象,是 token 的保管者和请求路径,不是 JWT 与随机字符串。Auth0 对 BFF 的官方说明 采用了这样的安排:后端负责获取、管理 token 和代理 API 请求,浏览器使用应用自己的会话凭证。一个带集中会话控制的 BFF,可以设计成:TEXT浏览器 | | HttpOnly 会话 Cookie v BFF:校验当前会话和本次操作权限 | | 服务端保存的 OAuth access token v 资源 API:验证凭证,并执行资源级授权后半段的 access token 完全可以是 JWT。浏览器使用应用会话,BFF 再用 JWT 访问资源 API,二者在同一个系统里各自承担职责。BFF 也不等于只有服务端 Session上图选的是服务端保存 token 的方案,但 RFC 10017 的 §6.1.2.3 同时讨论了服务端与客户端会话。后者可以把 token 封装进受保护的 Cookie,再由 BFF 读取使用;§6.1.3.2 建议包含 access token 的客户端会话 Cookie 应当加密。前端代码不能直接获得 OAuth token,不等于浏览器里不存在承载它的字节。 加密 Cookie 被复制后,也不能仅靠加密阻止会话重放。采用客户端会话时,更多控制依赖下游 access token 和 refresh token 的失效机制。集中会话存储便于直接修改状态,但团队要维护存储的可用性和扩展能力。规范对服务端会话的推荐限于小规模场景;对于高控制需求,我会优先评估集中状态,也会把维护成本算进去。这个偏好不代表规范对所有规模的通用建议。如果下游只本地验签,撤销信息又没有及时传播,客户端会话仍会受到后面讨论的旧凭证窗口约束。BFF 这个名字本身,不能替你保证即时撤销。BFF 的代价,也得写进设计OAuth token 交给后端使用以后,后端就要保护它们,处理代理路径、刷新并发、上游故障和日志脱敏。设计时至少要检查:代理目标必须受控,不能让调用方指定任意地址并借用服务器凭证。会话存储与 token 保管要有明确的故障策略,失败不能默认视为已登录。刷新并发要避免把同一份旧 refresh token 同时消费。大文件、流式响应和长连接经过代理,会改变容量规划与超时设计。浏览器入口的授权检查,不能替代资源服务对具体对象的权限判断。这些问题需要真实负载和部署条件来验证,不能凭一张架构图承诺性能更好。同源第一方应用,也不必为了登录引入整套 OAuth如果前端和 API 本来就是同源、同一应用,没有独立资源服务器的授权需求,普通服务端会话加 Cookie 可以很合适。RFC 10017 的引言明确区分了这类应用与它主要讨论的 OAuth 客户端。不能把面向 OAuth 的模式推荐,读成所有网站必须增加一个独立 BFF 服务。这也不意味着 OAuth 只适用于第三方。同一家公司运营的多个资源服务器,仍可能有独立的授权边界;所有权相同,不等于系统边界相同。对高控制需求的 Web 应用,我会先评估受控的服务端会话入口。是否需要独立 BFF,要看前端访问哪些服务、有哪些授权需求,不能只看它用了 React 还是 Vue。五、撤销信息怎样到达验证节点纯本地验证的自包含凭证,不查询动态状态、不接收失效事件,就无法知道签发以后发生了什么。这个限制不是 JWT 独有:一个只验证签名或加密完整性的自包含 Cookie,也面对同样的问题。但换成随机会话 ID,就一定可以即时撤销吗?设想这样一个系统:TEXTt=100:网关查询会话中心,确认有效,缓存到 t=160 t=101:管理员在会话中心删除会话 t=159:网关仍使用旧缓存,继续放行 t=160:缓存到期,要求重新查询会话中心已经删除记录,网关却还在使用删除之前的缓存。RFC 7662 对 introspection 缓存讨论的正是这种取舍:长缓存减少请求,却扩大旧结果继续被使用的窗口;如果响应提供了 exp,缓存不能超过这个过期时间。在这个刻意简化、已知可信过期时间的单缓存模型里:TEXT本地肯定结果的使用截止点 = min(凭证过期时间, 缓存有效截止点)这不是全系统吊销延迟公式。它没有计算状态源的复制延迟、多个验证节点、正在执行的请求,以及跨服务传播。一个可以运行的小模型下面的 Rust 代码只演示时间策略。它不解析 JWT,不验签,不访问会话中心,也不执行真实权限判断。RUST#[derive(Clone, Copy, Debug)] struct Snapshot { checked_at: u64, cache_until: u64, active: bool, } #[derive(Debug, PartialEq)] enum Decision { CachedAllow, Recheck, Revoked, Expired, } fn decide(now: u64, expires_at: u64, cached: Option<Snapshot>) -> Decision { if now >= expires_at { return Decision::Expired; } let Some(snapshot) = cached else { return Decision::Recheck; }; if now < snapshot.checked_at || snapshot.cache_until <= snapshot.checked_at || now >= snapshot.cache_until { return Decision::Recheck; } if snapshot.active { Decision::CachedAllow } else { Decision::Revoked } }在同一文件里加上调用入口:RUSTfn main() { let cached = Snapshot { checked_at: 100, cache_until: 160, active: true, }; // The authority revokes at t=101, but this local cache receives no event. println!("t=159: {:?}", decide(159, 1_000, Some(cached))); println!("t=160: {:?}", decide(160, 1_000, Some(cached))); let refreshed = Snapshot { checked_at: 160, cache_until: 220, active: false, }; println!( "t=160 after recheck: {:?}", decide(160, 1_000, Some(refreshed)) ); }两个代码块组成一个无第三方依赖的示例。以 Rust 2021 edition 编译运行:SHrustc --edition=2021 revocation_window.rs -o revocation_window ./revocation_window实际输出:TEXTt=159: CachedAllow t=160: Recheck t=160 after recheck: RevokedRecheck 的意思是本地信息不足,必须获得新的可信判断,不是查询失败后仍然放行。示例把时间作为输入,便于测试边界;真实系统还要处理时钟、网络、缓存键隔离及校验顺序。函数没有收到撤销事件,只能按传入的快照判断。新的查询结果传进来后,它才会知道这个会话已经被撤销。因此,要提出一个完整需求,不能只写“支持踢下线”。至少应说明:撤销后,新的请求允许继续放行多久?所有入口是否都经过同一控制点,有没有直连资源服务的路径?网关、本地缓存和资源服务怎样获得失效信息?在途请求是否需要取消,高风险操作是否在提交前重新确认?状态源不可用时,哪些操作拒绝,哪些低风险操作允许有限降级?即使用了 Session 或 JWT 黑名单,也得逐项确定这些行为。六、权限实时生效,不等于登录态实时生效认证和授权经常被混在一起,但它们不是同一个判断。会话仍有效,只说明这个登录上下文还被接受;不说明用户此刻仍能操作每一项资源。假设一个用户登录时有 admin 角色,后来被撤去管理员权限。如果角色被写进 JWT,纯本地验证会读到旧快照。如果角色被缓存进服务端 Session,却没有刷新或失效机制,同样会读到旧快照。把状态放在服务端更便于修改,但缓存怎样刷新,仍需单独设计。OWASP Authorization Cheat Sheet 要求在每次请求中正确执行权限判断,每一个入口、对象和操作都不能漏检。具体可以怎样实现,需要另外设计;这条要求并不等于每次必须查询同一张数据库表。例如,拥有订单查询权限,并不意味着可以查询别的租户的任意订单;拥有管理角色,也不意味着可以跨组织操作所有用户。一个系统至少要分别管理:TEXT凭证是否真实、是否过期 当前会话或账号是否仍有效 当前主体能否执行这项操作 主体能否操作这一个具体资源这些判断可以由不同层负责。JWT 中的声明可以作为输入,服务端状态也可以作为输入,但最终权限不能只靠一个未经边界限定的角色字符串。这也是为什么,“验签成功所以授权成功”与“Session 存在所以授权成功”,都是危险的简化。七、刷新和轮换的复杂度来自哪里原文提醒无感刷新有竞态、多标签页协调和失效边界,这一点值得保留。但 access token 与 refresh token 的职责分离来自 OAuth,而不是 JWT 格式。RFC 6749 已经定义了这两类凭证;access token 即使是不透明字符串,刷新机制仍然存在。RFC 9700 要求先根据风险决定是否签发 refresh token。对 public client,若签发,应使用发送者约束或轮换来检测重放,不能把轮换说成所有场景唯一的机制。假设两个标签页同时使用同一份 refresh token。第一条请求完成轮换,第二条请求提交的就可能已经是旧凭证。系统必须区分并处理并发、重试和可能的泄露;具体重用窗口及恢复策略由提供方实现决定,不能随意自创。BFF 可以把刷新职责移到后端。采用服务端 token 存储时,协调更容易集中,但仍需要原子更新、并发控制和安全的失败处理。若用客户端会话 Cookie 携带状态,还要评估并发响应回写旧 Cookie 的问题;RFC 6265 §4.1.1 也指出了并发 Set-Cookie 响应的竞态。这些工作由后端承担,浏览器代码才可以少做一部分。如果前端和 API 只维护同一应用的网页登录态,没有独立的资源授权需求,一套服务端会话的确可能更简单。这时可以省去不需要的刷新流程;JWT 格式本身并没有要求应用必须使用双 Token。八、退出登录要处理哪些会话一个接入 SSO、使用 BFF 的应用,可能同时涉及:层次保存的东西退出时要明确的目标浏览器应用 Cookie清理当前客户端携带的凭证应用服务会话状态让旧会话标识不能继续使用授权服务器refresh token 或授权关系阻止相关授权继续生成新凭证资源 API已签发的 access token按撤销机制或过期策略停止接受身份提供方SSO 登录会话是否一并退出其他已登录应用清理浏览器 Cookie,不等于复制出去的凭证已经作废。删除本应用 Session,也不意味着身份提供方的 SSO 会话已经消失。RFC 7009 定义了 token 撤销,并讨论关联凭证的失效策略和实际传播延迟。因此,吊销 refresh token 后,不能不看授权服务器和资源服务的行为,就宣称所有旧 access token 已在所有节点即时失效。OpenID Connect 的 Back-Channel Logout 则用经过签名的 Logout Token 通知应用清理相应会话。这个通知本身也是 JWT。收到通知的应用仍要清理会话状态。JWT 在这里用来验证跨系统的控制消息,服务的是有状态的会话管理。对外接收随机会话凭证、内部使用受约束的 access token,也可以是合理的分层。若有跨服务授权委托需求,RFC 8693 还定义了 token exchange 机制。但这不是小项目都应该增加的组件,更不是把浏览器凭证随意换成一个万能内部 token。每一层都要限定接收者、权限和生命周期。否则外部会话虽被删除,内部已经流通的旧凭证仍可能留下另一段窗口。九、JWT 校验仍有实现责任替 JWT 拆清责任,不等于否认它的风险。签名验证、算法限制、密钥选择、issuer、audience、凭证类型与过期判断,都需要正确实现。不能把库的默认配置当成完整的认证策略。RFC 8725 针对算法验证、签发者、接收者和跨类型混淆给出了最佳实践。不能只解码 payload,也不能把收到的 alg 当成服务器的安全策略。这意味着,一个有效的 ID token,不应该因为也是 JWT,就直接被当成 access token 接受。一个其他服务使用的 JWT,也不能因为签名正确就跨接收者通用。RFC 9068 专门定义了 OAuth JWT access token 的校验要求。至于把 Shiro 的 CVE-2016-4437 放进 JWT 更不安全的证据链,论证对象就偏了。Apache 官方公告 说明的是旧版 RememberMe 在未配置 cipher key 时的漏洞,而不是 JWT 格式漏洞。这个案例值得提醒大家不要轻视凭证实现,但不能据此给 JWT 与 Session 排安全总榜。签名 JWT 的内容可读,就应当少放不必要的数据,并选择合适的保护方式。Session ID 不携带明文用户信息,也仍然要防泄露,处理授权、存储和缓存问题。比较安全性时,两边都要检查实际的校验、存储、授权和失效机制。不能只列 JWT 的错误实现,再假设 Session 一边的这些问题已经全部解决。十、Web 项目怎样选认证方案给一个 Web 项目选择认证方案,我会先确认下面四组条件。第一,客户端是什么。第一方网页、纯静态 SPA、移动端和第三方调用方,信任边界不同,不能共用一张粗糙的默认答案。第二,访问的是什么。只是本应用资源,还是需要第三方授权、跨服务委托和多个资源服务器?第三,控制要求是什么。撤销多久生效?权限能陈旧多久?高风险操作是否需要更新的确认?这几个时间不能混成一个 token TTL。第四,团队能够维护什么。集中存储、代理层、OAuth 提供方、刷新协调、失效事件和可观测性,都要有人负责。根据前面的机制,我的工程倾向是:场景优先评估为什么普通第一方 Web 或管理后台成熟框架的服务端会话与安全 Cookie集中治理,避免无必要的前端凭证生命周期需要 OAuth 的高控制需求 Web 应用BFF减少 OAuth token 对前端代码的暴露确实无法增加后端的 SPA成熟 OAuth SDK、授权码加 PKCE,并明确存储风险承认浏览器客户端的限制,而不是宣称 Header 自动安全多资源 API 或跨服务授权按接收者和控制需求比较 JWT、opaque token、introspection 或 token exchange标准化与即时控制需要分别设计这些选择都有前提,不能当作性能或安全性的通用排名,也不能仅凭客户端类型决定 token 格式。实施后,再用失败场景检查设计:凭证过期以后,各入口是否一致拒绝?删除会话后,带着旧凭证的新请求何时开始失败?修改权限后,对具体资源的授权何时刷新?停用状态源时,系统有没有默默回到只验签放行?并发刷新、重复退出、跨标签页和跨服务调用是否符合既定策略?这些测试可以检查系统是否满足预先约定的控制要求。最后,回到普通 Web 应用原文对过度设计的警惕是有价值的。普通 Web 应用完全可以选择更简单的服务端会话;高控制需求的 OAuth Web 应用,也值得认真评估 BFF。选择这些方案,需要说明浏览器持有什么凭证、代码能否读到它、请求经过哪些服务。用了 JWT,要做相应的校验;用了 Session,要维护当前状态;增加 BFF,要承担 token 保管和代理责任。存储、传输与资源授权也不能省略。对于需要即时控制的系统,还得继续问:管理员撤销会话或修改权限后,各个入口多久开始拒绝旧请求?状态源不可用时,系统会怎样处理?这两个问题的答案,比“我们没有用 JWT”更能说明这套登录设计是否合适。
登录接口刚写完,就往项目里塞 access token、refresh token、无感刷新和黑名单,仿佛少了这一套,系统就不够现代。等到发现它们也需要维护,又容易走向另一个极端:JWT 是骗局,浏览器认证只有 Session 才正确。
这两种说法都把凭证格式和整套认证设计混在了一起。
《被吹上天的 JWT,为什么主流网站一个都不用》问得很直接:普通 Web 应用,为什么要为一个登录态承担那么多复杂度?它对浏览器存储、主动撤销和 BFF 的提醒有道理,也承认 JWT 在服务间身份传递等场景的价值。
但浏览器会话是否应该排除 JWT,还得把 Cookie、凭证存储和撤销机制分别说清。
在之前的《JWT 不是无状态银弹:Session、Redis 与 WebSocket 分布式鉴权的工程取舍》里,讨论重心是服务端状态、主动踢人和长连接治理。这些问题仍然成立,但它们不足以回答另一组问题:浏览器到底应该持有什么?Cookie 和 JWT 是不是对立的?BFF 解决了什么,又留下了什么?
一、JWT、Cookie 和 Session 分别解决什么
一句“选 JWT 还是 Session”,经常把五个决定压成了一个。
决策维度
实际要回答的问题
可选方式
凭证表示
这张票据里有没有可验证的声明?
随机不透明字符串、签名 JWT、加密票据
请求携带
凭证怎样进入 HTTP 请求?
Cookie、Authorization Header
浏览器暴露面
页面脚本能否直接读取或使用它?
HttpOnly Cookie、内存、Web Worker、本地持久存储
服务端状态
有效性由哪里提供最终依据?
本地验证、会话存储、introspection、混合校验
授权新鲜度
撤权后多久必须停止放行?
每请求检查、短缓存、短期快照、事件失效
JWT 用来表示声明,Cookie 负责让浏览器保存和携带数据,服务端 Session 维护会话状态。这几个选择可以组合。
RFC 7519 定义的 JWT 既可以使用 JWS 保护完整性,也可以使用 JWE 加密。常见的三段式签名 JWT 可以被解码,不代表所有 JWT 都是明文;更不代表 JWT 必须放进 localStorage。
浏览器里的“session cookie”还有另一层含义:它通常指没有设置持久化到期属性的 Cookie,描述客户端生命周期,并不证明服务端保存了一条 Session 记录。浏览器会话何时结束,也由用户代理定义,见 RFC 6265。
OAuth 又是另一层。RFC 6749 允许 access token 是一个索引,也允许它携带可验证的授权信息;OAuth 不要求 access token 一律使用 JWT。反过来,一个普通网站也不需要因为使用了随机会话 ID,就宣称自己实现了 OAuth。
因此,下面几种组合都不矛盾:
选型时,需要说明自己选了哪种凭证、怎样传输、存在哪里、怎样检查有效性。只写“使用 JWT”或“使用 Session”,还不足以描述一个方案。
二、看几个网站的 Cookie,证明不了整个行业的架构
打开开发者工具,是很好的观察起点。但观察到某个 Cookie,不等于已经掌握这个系统的认证链路。
客户端能看到凭证的名称、长度、属性和传输位置,却看不到服务端是否查询状态、网关是否把它换成别的票据。网页、移动端和开放 API 也可能采用不同的机制。
尤其是加密票据,客户端无法靠外观判断里面装的是随机索引,还是一组经过保护的声明。
如果要论证主流网站都不采用某种方案,至少需要明确样本、观察日期、产品入口,以及“采用”的定义。只观察几个网站的浏览器 Cookie,再拿来替所有 Web 应用做选型,证据不够。没有统计方法的比例数字,也不该承担架构结论。
下面两个官方公开的方案,可以直接检查这些组合是否成立。
一个直接反例:JWT 也能是会话 Cookie
Firebase 官方的 Manage Session Cookies 明确提供 JWT 形式的服务端会话 Cookie。浏览器先完成登录,再把 ID token 交给服务端兑换成会话 Cookie,之后清理前端认证状态。
这能说明浏览器会话和 JWT 格式可以配合使用,不能用来推断 Google 搜索首页的实现,也不能据此推荐所有网站都这样做。
官方同时说明,基础验签可以使用缓存的公钥;若启用撤销检查,每次校验会额外发起网络请求。
这套方案用了 JWT,也用了 Cookie;需要检查撤销时,照样要查询当前状态。凭证格式没有替它省掉这一步。
另一个反例:Cookie 认证不自动等于服务端 Session
Microsoft 的 ASP.NET Forms Authentication 说明 展示了把认证票据加密后写入 Cookie 的机制。Cookie 装着票据,并不自动证明服务端另存一行会话。
现代 ASP.NET Core 的 Cookie Authentication 文档 更直接提醒:后端禁用账号以后,已经签发的认证 Cookie 仍可能被接受,需要通过 ValidatePrincipal 等机制响应账号变化。
可见,选择 Cookie 以后,仍然要设计账号禁用和会话撤销怎样生效。
三、攻击者能偷走凭证,还是只能借浏览器做事
同样是凭证泄露或请求被冒用,攻击者具备的能力可能不同。下面三种情况需要分开处理。
1. 把凭证偷走,离开浏览器继续用
如果 bearer 凭证能被页面脚本直接读取,那么同源恶意脚本可能把它复制出去。攻击者随后可以在自己的环境里重放,而不必一直依赖受害者的页面。
这也是为什么 OWASP HTML5 Security Cheat Sheet 反对把会话标识保存在 localStorage。
但这项风险不专属于 JWT。把一条随机字符串放在那里,同样可以被窃取。RFC 6750 对 bearer token 的关键定义,就是持有者不必证明自己拥有额外密钥。
JWT 和随机字符串都可能是 bearer 凭证,不能靠外观判断能否重放。
2. 不偷凭证,直接借你的浏览器做事
HttpOnly 能阻止脚本通过 document.cookie 读取 Cookie,但不能阻止恶意脚本利用当前浏览器发起请求。
OWASP Session Management 说明了这个限制:HttpOnly 保护 Cookie 的机密性,不能阻止 XSS 发起带 Cookie 的请求。
假设某后台页面可以正常提交一条管理操作。攻击者已经能在同源页面执行代码,就可能调用同样的接口。Cookie 不必被读出来,浏览器仍会在符合条件时携带它。
所以,防止凭证被带走和防止当前页面被操纵,是两项不同的目标。
BFF 不向前端代码直接暴露 OAuth token,可以减少脚本窃取它的机会。对操纵当前页面的攻击,仍需要输入处理、输出编码、脚本来源控制、服务端授权,以及高风险操作的额外确认。
3. 从别的网站诱导浏览器自动携带凭证
传统 CSRF 针对的是浏览器自动附带的身份信息。它和 XSS 不在同一个攻击前提下。
Cookie 自动携带,需要有对应的 CSRF 防护;由合法页面代码显式添加的 Authorization Header,在合理的跨域配置下不具有同样的自动附带行为。但这不能推出 Header 模式自动消灭所有伪造请求,更不能推出 Header 必须配 localStorage。
OWASP CSRF 指南 讨论了 CSRF token、来源检查、Fetch Metadata 和自定义 Header 等机制,也提醒 XSS 可以绕过 CSRF 防护。CORS 控制跨源交互,并不是阻止所有请求到达服务器的防火墙。
可以把常见控制的边界放在一起:
控制
主要收益
不能据此承诺什么
HttpOnly
限制脚本直接读取 Cookie
页面不会发生 XSS,恶意脚本不能发请求
Secure
限制 Cookie 经安全连接发送
凭证不会被业务日志、服务器漏洞泄露
SameSite
限制部分跨站 Cookie 携带
完整替代 CSRF 防护,同站就是同源
页面内存或 Worker
缩短持久暴露,提供一定隔离
已被同源代码控制的页面完全安全
BFF
OAuth token 不交给前端代码
会话、代理接口和下游授权无需治理
HttpOnly、CSRF 防护和服务端授权要分别配置,不能因为其中一项已经启用,就省掉其他检查。
四、BFF 改变了 token 的保管和请求路径
BFF 是 Backend for Frontend,这里讨论的是由面向前端的后端组件承担 OAuth 客户端职责,并代理资源请求的模式。
截至 2026 年 10 月 9 日,RFC 10017 已经是正式的最佳实践文档,发布日期是 2026 年 8 月,不是原文所写的 2025 年。这处日期有误,但不影响继续讨论 BFF 的价值。
这份规范讨论的是使用 OAuth 的浏览器应用,列出了三种主要模式,按安全性递减排列:
模式
OAuth token 在哪里使用
主要取舍
BFF
后端使用 token 并代理请求,不向前端代码直接暴露
减少前端 token 暴露,增加后端责任
Token-Mediating Backend
后端处理 token 获取,浏览器获得 access token 后直连 API
保留部分后端保护,也保留 access token 暴露
浏览器 OAuth 客户端
浏览器负责 OAuth token 的获取和使用
部署形态直接,但浏览器承担更多凭证保护责任
这个比较的对象,是 token 的保管者和请求路径,不是 JWT 与随机字符串。
Auth0 对 BFF 的官方说明 采用了这样的安排:后端负责获取、管理 token 和代理 API 请求,浏览器使用应用自己的会话凭证。
一个带集中会话控制的 BFF,可以设计成:
后半段的 access token 完全可以是 JWT。浏览器使用应用会话,BFF 再用 JWT 访问资源 API,二者在同一个系统里各自承担职责。
BFF 也不等于只有服务端 Session
上图选的是服务端保存 token 的方案,但 RFC 10017 的 §6.1.2.3 同时讨论了服务端与客户端会话。后者可以把 token 封装进受保护的 Cookie,再由 BFF 读取使用;§6.1.3.2 建议包含 access token 的客户端会话 Cookie 应当加密。
前端代码不能直接获得 OAuth token,不等于浏览器里不存在承载它的字节。 加密 Cookie 被复制后,也不能仅靠加密阻止会话重放。
采用客户端会话时,更多控制依赖下游 access token 和 refresh token 的失效机制。集中会话存储便于直接修改状态,但团队要维护存储的可用性和扩展能力。规范对服务端会话的推荐限于小规模场景;对于高控制需求,我会优先评估集中状态,也会把维护成本算进去。这个偏好不代表规范对所有规模的通用建议。
如果下游只本地验签,撤销信息又没有及时传播,客户端会话仍会受到后面讨论的旧凭证窗口约束。BFF 这个名字本身,不能替你保证即时撤销。
BFF 的代价,也得写进设计
OAuth token 交给后端使用以后,后端就要保护它们,处理代理路径、刷新并发、上游故障和日志脱敏。
设计时至少要检查:
这些问题需要真实负载和部署条件来验证,不能凭一张架构图承诺性能更好。
同源第一方应用,也不必为了登录引入整套 OAuth
如果前端和 API 本来就是同源、同一应用,没有独立资源服务器的授权需求,普通服务端会话加 Cookie 可以很合适。
RFC 10017 的引言明确区分了这类应用与它主要讨论的 OAuth 客户端。不能把面向 OAuth 的模式推荐,读成所有网站必须增加一个独立 BFF 服务。
这也不意味着 OAuth 只适用于第三方。同一家公司运营的多个资源服务器,仍可能有独立的授权边界;所有权相同,不等于系统边界相同。
对高控制需求的 Web 应用,我会先评估受控的服务端会话入口。是否需要独立 BFF,要看前端访问哪些服务、有哪些授权需求,不能只看它用了 React 还是 Vue。
五、撤销信息怎样到达验证节点
纯本地验证的自包含凭证,不查询动态状态、不接收失效事件,就无法知道签发以后发生了什么。这个限制不是 JWT 独有:一个只验证签名或加密完整性的自包含 Cookie,也面对同样的问题。
但换成随机会话 ID,就一定可以即时撤销吗?
设想这样一个系统:
会话中心已经删除记录,网关却还在使用删除之前的缓存。
RFC 7662 对 introspection 缓存讨论的正是这种取舍:长缓存减少请求,却扩大旧结果继续被使用的窗口;如果响应提供了 exp,缓存不能超过这个过期时间。
在这个刻意简化、已知可信过期时间的单缓存模型里:
这不是全系统吊销延迟公式。它没有计算状态源的复制延迟、多个验证节点、正在执行的请求,以及跨服务传播。
一个可以运行的小模型
下面的 Rust 代码只演示时间策略。它不解析 JWT,不验签,不访问会话中心,也不执行真实权限判断。
在同一文件里加上调用入口:
两个代码块组成一个无第三方依赖的示例。以 Rust 2021 edition 编译运行:
实际输出:
Recheck 的意思是本地信息不足,必须获得新的可信判断,不是查询失败后仍然放行。示例把时间作为输入,便于测试边界;真实系统还要处理时钟、网络、缓存键隔离及校验顺序。
函数没有收到撤销事件,只能按传入的快照判断。新的查询结果传进来后,它才会知道这个会话已经被撤销。
因此,要提出一个完整需求,不能只写“支持踢下线”。至少应说明:
即使用了 Session 或 JWT 黑名单,也得逐项确定这些行为。
六、权限实时生效,不等于登录态实时生效
认证和授权经常被混在一起,但它们不是同一个判断。
会话仍有效,只说明这个登录上下文还被接受;不说明用户此刻仍能操作每一项资源。
假设一个用户登录时有 admin 角色,后来被撤去管理员权限。如果角色被写进 JWT,纯本地验证会读到旧快照。如果角色被缓存进服务端 Session,却没有刷新或失效机制,同样会读到旧快照。
把状态放在服务端更便于修改,但缓存怎样刷新,仍需单独设计。
OWASP Authorization Cheat Sheet 要求在每次请求中正确执行权限判断,每一个入口、对象和操作都不能漏检。具体可以怎样实现,需要另外设计;这条要求并不等于每次必须查询同一张数据库表。
例如,拥有订单查询权限,并不意味着可以查询别的租户的任意订单;拥有管理角色,也不意味着可以跨组织操作所有用户。
一个系统至少要分别管理:
这些判断可以由不同层负责。JWT 中的声明可以作为输入,服务端状态也可以作为输入,但最终权限不能只靠一个未经边界限定的角色字符串。
这也是为什么,“验签成功所以授权成功”与“Session 存在所以授权成功”,都是危险的简化。
七、刷新和轮换的复杂度来自哪里
原文提醒无感刷新有竞态、多标签页协调和失效边界,这一点值得保留。
但 access token 与 refresh token 的职责分离来自 OAuth,而不是 JWT 格式。RFC 6749 已经定义了这两类凭证;access token 即使是不透明字符串,刷新机制仍然存在。
RFC 9700 要求先根据风险决定是否签发 refresh token。对 public client,若签发,应使用发送者约束或轮换来检测重放,不能把轮换说成所有场景唯一的机制。
假设两个标签页同时使用同一份 refresh token。第一条请求完成轮换,第二条请求提交的就可能已经是旧凭证。系统必须区分并处理并发、重试和可能的泄露;具体重用窗口及恢复策略由提供方实现决定,不能随意自创。
BFF 可以把刷新职责移到后端。采用服务端 token 存储时,协调更容易集中,但仍需要原子更新、并发控制和安全的失败处理。若用客户端会话 Cookie 携带状态,还要评估并发响应回写旧 Cookie 的问题;RFC 6265 §4.1.1 也指出了并发 Set-Cookie 响应的竞态。这些工作由后端承担,浏览器代码才可以少做一部分。
如果前端和 API 只维护同一应用的网页登录态,没有独立的资源授权需求,一套服务端会话的确可能更简单。这时可以省去不需要的刷新流程;JWT 格式本身并没有要求应用必须使用双 Token。
八、退出登录要处理哪些会话
一个接入 SSO、使用 BFF 的应用,可能同时涉及:
层次
保存的东西
退出时要明确的目标
浏览器
应用 Cookie
清理当前客户端携带的凭证
应用服务
会话状态
让旧会话标识不能继续使用
授权服务器
refresh token 或授权关系
阻止相关授权继续生成新凭证
资源 API
已签发的 access token
按撤销机制或过期策略停止接受
身份提供方
SSO 登录会话
是否一并退出其他已登录应用
清理浏览器 Cookie,不等于复制出去的凭证已经作废。删除本应用 Session,也不意味着身份提供方的 SSO 会话已经消失。
RFC 7009 定义了 token 撤销,并讨论关联凭证的失效策略和实际传播延迟。因此,吊销 refresh token 后,不能不看授权服务器和资源服务的行为,就宣称所有旧 access token 已在所有节点即时失效。
OpenID Connect 的 Back-Channel Logout 则用经过签名的 Logout Token 通知应用清理相应会话。这个通知本身也是 JWT。
收到通知的应用仍要清理会话状态。JWT 在这里用来验证跨系统的控制消息,服务的是有状态的会话管理。
对外接收随机会话凭证、内部使用受约束的 access token,也可以是合理的分层。若有跨服务授权委托需求,RFC 8693 还定义了 token exchange 机制。但这不是小项目都应该增加的组件,更不是把浏览器凭证随意换成一个万能内部 token。
每一层都要限定接收者、权限和生命周期。否则外部会话虽被删除,内部已经流通的旧凭证仍可能留下另一段窗口。
九、JWT 校验仍有实现责任
替 JWT 拆清责任,不等于否认它的风险。
签名验证、算法限制、密钥选择、issuer、audience、凭证类型与过期判断,都需要正确实现。不能把库的默认配置当成完整的认证策略。
RFC 8725 针对算法验证、签发者、接收者和跨类型混淆给出了最佳实践。不能只解码 payload,也不能把收到的 alg 当成服务器的安全策略。
这意味着,一个有效的 ID token,不应该因为也是 JWT,就直接被当成 access token 接受。一个其他服务使用的 JWT,也不能因为签名正确就跨接收者通用。RFC 9068 专门定义了 OAuth JWT access token 的校验要求。
至于把 Shiro 的 CVE-2016-4437 放进 JWT 更不安全的证据链,论证对象就偏了。Apache 官方公告 说明的是旧版 RememberMe 在未配置 cipher key 时的漏洞,而不是 JWT 格式漏洞。
这个案例值得提醒大家不要轻视凭证实现,但不能据此给 JWT 与 Session 排安全总榜。
签名 JWT 的内容可读,就应当少放不必要的数据,并选择合适的保护方式。Session ID 不携带明文用户信息,也仍然要防泄露,处理授权、存储和缓存问题。
比较安全性时,两边都要检查实际的校验、存储、授权和失效机制。不能只列 JWT 的错误实现,再假设 Session 一边的这些问题已经全部解决。
十、Web 项目怎样选认证方案
给一个 Web 项目选择认证方案,我会先确认下面四组条件。
第一,客户端是什么。第一方网页、纯静态 SPA、移动端和第三方调用方,信任边界不同,不能共用一张粗糙的默认答案。
第二,访问的是什么。只是本应用资源,还是需要第三方授权、跨服务委托和多个资源服务器?
第三,控制要求是什么。撤销多久生效?权限能陈旧多久?高风险操作是否需要更新的确认?这几个时间不能混成一个 token TTL。
第四,团队能够维护什么。集中存储、代理层、OAuth 提供方、刷新协调、失效事件和可观测性,都要有人负责。
根据前面的机制,我的工程倾向是:
场景
优先评估
为什么
普通第一方 Web 或管理后台
成熟框架的服务端会话与安全 Cookie
集中治理,避免无必要的前端凭证生命周期
需要 OAuth 的高控制需求 Web 应用
BFF
减少 OAuth token 对前端代码的暴露
确实无法增加后端的 SPA
成熟 OAuth SDK、授权码加 PKCE,并明确存储风险
承认浏览器客户端的限制,而不是宣称 Header 自动安全
多资源 API 或跨服务授权
按接收者和控制需求比较 JWT、opaque token、introspection 或 token exchange
标准化与即时控制需要分别设计
这些选择都有前提,不能当作性能或安全性的通用排名,也不能仅凭客户端类型决定 token 格式。
实施后,再用失败场景检查设计:
这些测试可以检查系统是否满足预先约定的控制要求。
最后,回到普通 Web 应用
原文对过度设计的警惕是有价值的。普通 Web 应用完全可以选择更简单的服务端会话;高控制需求的 OAuth Web 应用,也值得认真评估 BFF。
选择这些方案,需要说明浏览器持有什么凭证、代码能否读到它、请求经过哪些服务。用了 JWT,要做相应的校验;用了 Session,要维护当前状态;增加 BFF,要承担 token 保管和代理责任。存储、传输与资源授权也不能省略。
对于需要即时控制的系统,还得继续问:管理员撤销会话或修改权限后,各个入口多久开始拒绝旧请求?状态源不可用时,系统会怎样处理?
这两个问题的答案,比“我们没有用 JWT”更能说明这套登录设计是否合适。