Access 过期就换一枚新的,refresh 却一直用同一枚——很多项目的「双 Token」停在这一步。另一种做法是每次兑换 access 时顺手作废旧 refresh,再发一枚新的。表面上还是 access + refresh,出事以后能撑多久,两边差很多。下面按 Auth0 的轮换说明,对照 RFC 9700 和 OWASP 的写法说威胁模型,不贴某家控制台逐步截图。
长寿命 refresh 漏了会怎样
短寿命 access 就算被人截到,窗口通常只有几分钟到一小时量级。refresh 往往按天、按周甚至更长活着,专门用来在用户无感的情况下续 access。一枚长期 refresh 落进 XSS、备份日志、共享电脑的本地存储,攻击者就可以反复换新的 access,直到你撤销或它过期。
Auth0《Refresh Token Rotation》的定义是:每次用 refresh 换 access 时,授权服务器同时下发一枚新的 refresh,并作废请求里那枚旧的。这样不再长期握着「同一枚高价值凭证」。文档写明该做法符合 OAuth 2.0 BCP,并适用于授权码、带 PKCE 的授权码、设备码、资源所有者密码等流。
Auth0 还写了自动重用检测:旧 refresh 一旦在已作废后再次出现,会把同一家族后续签发的 refresh 一并废掉,逼双方重新登录。合法客户端和攻击者谁先用那枚泄露的 refresh,结果都是家族失效——这是用「偶发把好人踢下线」换「偷到一枚不能无限续命」。日志里可以看到类似失败兑换的事件,方便接到监控。
只轮换 access、refresh 固定不变时,泄露窗口约等于那枚 refresh 的剩余寿命。上了 rotation 之后,单枚 refresh 的有用窗口被压到「下一次成功兑换之前」。再叠加重用检测,两边抢同一枚旧票会整族作废。
RFC 9700 / OWASP 怎么要求公共客户端
《RFC 9700》(OAuth 2.0 Security BCP)§2.2.2 写:面向公共客户端的 refresh,必须做发送方约束(sender-constrained),或使用 refresh token rotation。§4.14 把 rotation 说成:每次刷新响应都发新 refresh,旧的作废并保留族谱关系。合法与恶意两侧若都拿着已作废的去换,服务器能察觉重放并撤销当前活跃 refresh。同一节还建议在密码变更、在授权服务器登出等安全事件时自动撤销 refresh,并让长期不用的 refresh 自行过期。
《OWASP OAuth2 Cheat Sheet》同样把「sender-constraining(DPoP / mTLS)或 refresh token rotation」列为保护 refresh 的做法,并提醒 rotation 可与 PoP 叠加做纵深防御。发送方约束是「没有对应密钥就用不了这枚令牌」。轮换是「即便能用,窗口也短,重放会暴露」。两条不是互斥。
公共客户端(SPA、很多原生 App)存不了可靠的客户端密钥,单靠「refresh 很长、access 很短」不够。要么令牌绑定到客户端密钥材料,要么轮换把单枚 refresh 的有效窗口压短。
别把 localStorage 里的长期 refresh 当成「双 Token 就够了」
Auth0 原文对 SPA 说得很狠:浏览器里没有一种持久存储能保证「只有本应用能读到」高价值凭证,因此长期 refresh 并不适合 SPA。轮换是在浏览器隐私策略搞砸静默 iframe 续期之后的补救手段之一,不是「塞进 localStorage 就安全」。默认实现往往把令牌放在内存里,持久化要另看 Token Best Practices,不能自行脑补成「有 refresh 轮换 = localStorage 可放心」。
XSS 一旦能跑你的脚本,内存里的令牌和 localStorage 里的令牌都可能被读走。轮换降低的是「偷到之后长期免登录」的窗口,不消灭读取面。能上通行密钥、少依赖长期 refresh 时,前端少放一枚高价值字符串,始终更干净。服务端仍要有会话与撤销策略。通行密钥走挑战–响应、私钥不出设备,和 refresh 轮换是上下游关系,不是互相替代;浏览器侧入口一般是 PublicKeyCredential。
有没有团队上了轮换之后,被重用检测误伤、整族 refresh 被撤、用户突然要重新登录的?你们 refresh 现在放内存、放 HttpOnly Cookie,还是仍在 localStorage?