DevKitLab Logo DevKitLab
JWT / 身份认证 / 安全 / 会话

短有效期 token、refresh 轮换,以及如何撤销一个 JWT

JWT 通常的部署方式——作为一个自包含的、由服务端本地验证、无需查询任何状态的 access token——恰恰就是你难以把它收回的原因:它在过期前始终有效,服务端做什么都不改变这一点。这一篇讲的就是怎么管理它:短的 access token 有效期、refresh token 与带重放检测的轮换,以及撤销一个自包含 token 的几种真实选项——每一种都要在即时性和你当初选择 JWT 所看中的那份无状态性之间做取舍。

上一篇收尾在多数团队最终收敛到的那个安排:短有效期的 access token,配一个守得好的 refresh token。这一篇讲的,是让这套安排跑起来的那些机制——以及 JWT 的一个棘手特性:正是它让这些机制变得必需。

把那个性质直白地说出来。JWT 只是一种格式——它本身并不要求无状态——但它极其常见地被用作一个自包含的、由资源服务器本地验证的、已签名的 access token:服务器检查签名和 claim,不去调数据库、也不去问签发方,就得出结论。这正是这套模式又快、又易于跨服务扩展的原因。而这也恰恰是你难以把这样一个 token 收回的原因。当它带着 exp 时——一个 token 不一定要带,但 access token 应该带——它在那个时刻之前始终有效,你在服务器上的任何常规操作都改变不了这一点,因为验证从不停下来问一句“这个现在还作数吗?”。(唯一那个粗暴的例外,是把签名密钥本身退役:这会一次性作废用该密钥签过的每一个 token,但它是一根全局的、很粗的杠杆,还受 JWKS 与验证方缓存的传播影响,适合密钥泄露的场景,而不是一次普通的登出。)于是每个真实系统都需要的那些操作——把这个用户在所有设备上登出这个 token 被偷了,把它撤掉这个账号从现在起被封——都没有一个天然的落脚处。本文里的每一种技术,存在的意义都是为了管理这一个问题:去限定、去察觉、去撤销一个已经流到外面的 token 的有效性。

对策一:短有效期

最简单的手段,也是最重要的。如果一个 access token 在签发几分钟后就过期,那么一个被偷走的 token 也只在那几分钟里危险。你不用去撤销它——你把它等过去就好,这既便宜、又不需要任何状态。不过,短的有效期并不是撤销:撤销指的是服务端主动让一个本来仍然有效的凭据失效,而过期做不到这件事。它做到的,是限定一个泄露出去的 token 还能用多久,而且完全不需要服务端状态——这正是它成为整套生命周期赖以搭建的骨架的原因。接下来的大部分内容,其实都是在讲怎么让“短的有效期”变得可用,而不是变得难受。

因为难受是真的:一个每隔几分钟就过期的 token,单靠它自己,会把用户不停地打回登录表单。你需要一条路子,在当前 token 每次失效时,悄悄地、不弹密码框地,给浏览器换一个新的 access token。这个机制就是 refresh token,它整个设计都是对这份张力的回应。

对策二:access 与 refresh 的分工

refresh token 是一个独立的、寿命更长的凭据,它唯一的活儿就是去授权服务器换取新的 access token。之所以要两个 token 而不是一个,是因为这样它们就能有相反的暴露特征,你也就能各自对症地保护:

  • access token 会随每一个 API 请求发出,所以它暴露得很广——在各种头里、代理里、日志里。你把它的有效期压短,正因为它无处不在。
  • refresh token 只发往令牌端点,发得少、只发去一个地方,所以它暴露得很窄。这让你能重重地守住它——放在一个浏览器 JavaScript 读不到的 httpOnly cookie 里,或者按上一篇的 BFF 模式,整个交给后端持有。

这份分工就是全部要点。寿命长的那个凭据,是很少被传输、又被藏得够远的那个;而到处跑的那个凭据,则会在它能惹出多大乱子之前就过期。把两者并成一个“寿命又长、又随每个请求发出”的 token,你得到的就是两头的坏处——而这正是短的有效期存在的意义:避开它。

对策三:轮换与重放检测

refresh token 带来一个新风险——它寿命长,所以被偷一个就很值钱——而轮换,正是把这个风险变成一件你能察觉之事的答案。

轮换的意思是:每当一个 refresh token 被使用,服务器就签发一个全新的 refresh token,并作废旧的那个。refresh token 变成一次性的。单看这一点只是个不大的改进,但它启用了真正要紧的部分:重放检测。轮换之后,理应只有一方合法地持有当前那个 refresh token。所以,如果一个曾用过、已被轮换掉的 token 又被拿出来用了,那就不对劲了——链条分叉了,有两方各自握着同一次登录的后代。这就是被盗的特征。

把这个盗窃过程走一遍,机制就清楚了。攻击者偷到一个 refresh token 并抢先使用:他拿到一对新的 access/refresh,那个被偷的 token 现在已经用掉了。等到合法客户端稍后拿着手里那份副本——如今已作废的原件——去刷新时,服务器看到一个用掉的 token 死而复生,就认出了这次入侵。一个构建得当的授权服务器会作废整个 token 家族(token family):从那次原始登录派生出的每一个 refresh token,一次性全部作废,逼迫一次干净的重新认证。攻击者刚签出来的那些 token,和受害者的一起报销。不过要诚实地说清时机:检测只在合法客户端下一次尝试刷新、并递上那个已用掉的原件时才触发。在那之前,攻击者可以一直轮换他自己那条活着的分支——所以对一个长期不活跃的用户来说,暴露窗口会一直拖到真正的客户端再回来为止。轮换能可靠地把盗窃暴露出来,并在合法客户端回来后把它掐断;它并不保证在几分钟内就控制住。这套“带自动重放检测的 token 家族”模型,是 OAuth 2.0 安全指南对公开客户端(single-page app 和移动端)的建议,也正是轮换值得那份额外复杂度的原因:它挡不住第一次恶意使用,但它限定损害、并可靠地把入侵浮出水面。

一个诚实的注意点:轮换在不可靠的网络上会误报。如果一个刷新响应在传输途中丢了,客户端可能会拿着那个它根本不知道已被轮换掉的旧 token 去重试,而这看起来和重放一模一样。实现会缓解这一点——但不是白来的。一个“持续容忍上一枚 token”的宽限窗口,同时也给被偷的旧 token 留了一段可用的窗口,所以它必须极短、范围极严;而一个幂等的刷新——对重试的请求返回同一枚后继 token,而不是再轮换一次——能更干净地绕开这份歧义。无论哪种,它都是一份相对重放检测的真实安全取舍,该是特意设计出来的,而不是在生产上撞见的。

轮换也不是唯一受认可的答案。同一份 OAuth 安全指南给了公开客户端一个并列的选项:sender-constrained(发送方绑定)的 refresh token,通过 DPoP 或双向 TLS 绑定到客户端持有的一把密钥上,这样一个被偷的 token 没有配对的私钥就是废的。规范把它写成一个二选一——绑定发送方轮换。不过它同样值得本系列一贯的那份诚实:在浏览器里,一个能偷到 token 的 XSS 通常也能够到密钥材料,所以这一招硬化的是针对网络截获与离线重放,而不是针对已经在你的同源环境中运行的脚本。

难的部分:撤销

短的有效期加轮换,覆盖了大多数情况,但有时你需要当下就让访问失效——用户点了“登出所有设备”,管理员停用了一个账号,某个 token 已知被攻陷。这里,JWT 的无状态就不再是优点了,因为没有内建的“删除”。可选的办法,全都是在“你能多即时地撤销”和“你愿意交回多少无状态”之间做取舍。有四种值得知道。

靠短 TTL,在刷新这一层撤销。 这是务实的默认做法。客户端仍然持有那个 refresh token,但授权服务器记着它的状态——存下的哈希、一条 token 家族记录——正是这份状态,让它日后能拒掉这个 token;把那条记录一撤,这次会话就再也签不出新的 access token 了。当前那个 access token 仍旧有效,直到它自己过期——但如果你把它压得够短,那也就几分钟。你没法当场弄死这个 access token,但你把它剩下的寿命封顶到几分钟,并从源头止住了失血。对多数应用,一小段残余有效期,是换取“验证保持无状态”的一个可接受的代价。

维护一份 denylist(拒绝名单)。 撤销一个 token 时,给它记下一个标识——如果签发方设了 jti 就用它(那是个可选 claim,并无保证),否则用 token 的哈希或它的会话 ID——一直记到它本会过期为止,并让验证器在每个请求上都去查这份名单。这能让你近乎即时地撤销特定 token——代价是热路径上多一次查询,把你当初选择 JWT 所看中的那点无状态性又交回去了一些。不过这个代价是有界的:这份名单只装那些已被撤销、却还没过期的 token,所以它保持得很小,条目也会自己到期消失。

维护一个按用户的“valid-after(此后有效)”截断点。 不去追踪单个 token,而是给每个用户存一个标记,意思是“拒绝这个点之前签发的一切”。在登出全部、改密码、或封号时,你把它往前拨;验证时拒绝任何更早的 token。把它挂在一个你自己签发方给出的、可信的 iat 上,或者——更稳妥地——挂在一个你自己递增的、按用户的会话版本(session-version)claim 上,因为 iat 是可选的,而秒级粒度的时间戳在同一秒签发和时钟漂移面前会变得别扭。它只是一次便宜的查询,又干净利落地覆盖了“一次性作废某个用户全部会话”这个常见需求。它很粗——按用户全有或全无,没有单会话粒度——但小而有效,而且和 denylist 搭配得很好,可以补上那些需要更细控制的场合。

在真正要紧处,放弃无状态。 对风险最高的那类访问,有些系统干脆放弃自包含 token,改用不透明的引用型 token,让资源服务器每次都拿它去授权服务器做 introspection(内省)——可撤销、有状态,但也有它自己的一个注意点:资源服务器常会缓存 introspection 的结果,所以撤销到底有多即时,取决于那个缓存的 TTL、以及一次撤销传播得有多快。那句诚实的话在这里最有用:如果“即时、细粒度的撤销”对某个 token 是硬性要求,那么一个自包含的 JWT 也许根本就是错的工具,而一个服务端会话、或一个被内省的 token 才是对的。

贯穿这四种的,是同一笔交易:撤销的即时性,对无状态。短 TTL 在保持无状态的同时,买来“够用”的撤销;denylist 和 introspection 则通过重新引入状态,买来即时性。这条线上没有一个放之四海皆准的点——你按每个 token 来挑,依据是这个 token 一旦被滥用能干出什么。

“登出”到底意味着什么

这一点值得直说,因为它是个常见、又后果不小的误解:对 JWT 而言,在客户端把 token 删掉,并不是撤销。如果这个 token 在用户登出之前就泄露了,那么清掉你自己那份副本,对攻击者那份毫无影响——他那份会一直好用到过期。真正的登出是一个服务端动作:清掉客户端的凭据,并且撤销 refresh token,好让会话续不了期。“登出全部”再往前一步——把每一个 refresh token 家族都撤掉,让任何会话都无法续期。如果你还需要那些已经在流通中的 access token 立刻停止工作,而不是等到它们下一次过期,那你就得把该用户的 valid-after 截断点往前拨(或者把它们列入 denylist),好让资源服务器主动拒掉它们。光撤销 refresh token,只挡得住续期;那些在外的 access token,仍会一直验过、直到过期,除非请求路径上有什么东西去查那个截断点。

会话时长:绝对上限与空闲上限

refresh token 通常带着两条互相独立的上限,把它们分开来看会有帮助。一个绝对上限,给整个会话封顶——比如从最初登录起三十天,过了这条线,无论用户多活跃,都得重新登录。一个空闲(不活跃)超时,则在会话闲置了某段更短的时间后把它结束。这个超时是一条服务端策略,不是自动产生的效果——你记下 refresh token 上一次被使用的时间,一旦间隔超过窗口就拒掉它(OAuth 安全指南把它表述为:在客户端不活跃一段时间后让 refresh token 过期)。轮换,是让这条策略执行起来很顺手的东西:每一次轮换都是一个天然的时机,把一个新的“上次使用时间”和空闲截止盖到后继 token 上,于是一个不断刷新的会话就活着,一个休眠的会话就作废。两条上限合在一起,既框住“一个会话最长能持续多久”,也框住“它能休眠多久、之后我们就不再信它”。

一个联系回第一篇的实用小提醒:别把有效期调到去和时钟较劲。验证器可以被配上一点对 exp/nbf 的时钟偏差容差——规范是允许、而非要求,通常也就控制在至多几分钟——因为各服务器的时钟会漂。一个短到会去和这份偏差赛跑的 access token,会制造出莫名其妙的失败,所以“短”指的是几分钟,不是几秒。

在一个真实 token 里看见生命周期

多数生命周期上的 bug,在你读到那些真实数字之前都是隐形的,而那些数字,不过是些 claim。当一个 token 带着 iatexp 时——分别是记录它何时被签发、何时过期的那两个注册 claim——它们合在一起精确地告诉你它该活多久,但它们是以裸的 Unix 时间戳存着的,肉眼读不出来。把 token 粘进 JWT 解析器,这些 claim 就渲染成真实的日期:你一眼就能看出手里拿的是一个短有效期的 access token,还是一个长寿命的;也能确认你的签发方,是不是真在按你配置的时长签发。一个常见的惊讶,是发现一个你以为十五分钟就过期的 token,实际上带着好几个小时——一个生命周期的 bug,在 exp 从一串十位整数变成一个日期的那一刻一目了然,在此之前则完全隐形。

这个系列,每篇一句话

这就为一段四部曲收了尾,而这几部分,只有合在一起才管用。读一个 token——把它解码、读懂它的 claim,记住解码不等于信任。正确地验它——让你自己的配置、而不是 token 的 header,来决定接受什么。妥当地存它——把长期凭据放到 JavaScript 够不到的地方,并把“防住 XSS”当成真正的防线。以及管好它的生命周期——短的 access token 有效期、会轮换以致窃取可被察觉的 refresh token,以及一条在短有效期不够用时的撤销通道。少了其中任何一环,其余几环都救不了你:一个验得完美无缺、却把 token 存进 localStorage、给了 24 小时有效期、又没法撤销的系统,不过是造了一把好锁,再把钥匙贴在了门上。

JWT 本身是一个小而近乎优雅的主意——一组签了名的 claim,任何人都能解码,却只有持有预期密钥的验证器才真能信任。它身上一切难办的,都在运维层面:你的验证器选择去信什么、浏览器把 token 存在哪里、以及当你不得不收回它时怎么收。把这些做对,token 就安安静静地把活儿干了;做错了,那份密码学从一开始就不是关键所在。