OAuth2 / JWT 体系里的 refresh token,如果只靠"签名 + 过期时间"校验,是无状态的:服务端不记录任何已发放、已使用的记录,因此没法检测重放、也没法单独撤销某一次登录。要补齐这块安全能力,业界通用做法叫 refresh token rotation(轮换)+ reuse detection(重放检测),思路和 Spring Security 的持久化 remember-me(见 Spring Security Remember Me 实现机制)几乎一致,只是术语不同。
无状态 refresh token 的问题
最简单的实现:refresh token 就是一个长期有效的签名字符串,服务端只验证签名和过期时间,不查库。
- 优点:无状态,扩展性好。
- 缺陷:refresh token 一旦泄露,在过期之前可以被无限次使用,服务端发现不了,也撤销不了单独一次登录(除非撤销全部密钥或拉黑名单)。
这跟 Spring Security 默认的 TokenBasedRememberMeServices(哈希令牌)是同一类问题:都是"无状态签名",天然缺少重放检测和单点撤销能力。
Rotation + Reuse Detection 的做法
给每一条 refresh token 的"血统"维护一个 family_id(有的实现里叫 session_id、lineage_id,作用等价于 remember-me 里的 series):
| family_id | current_token | issued_at | used |
|---|
- 用户登录时,颁发
family_id+ 第一个refresh_token,同时记录到数据库。 - 客户端用
refresh_token换新的access_token时,服务端同时颁发一个新的refresh_token,旧值作废,数据库里current_token更新为新值。 - 如果收到的
refresh_token匹配family_id但不等于数据库里记录的current_token——说明这个 token 已经被换过一次了,现在又被用了一次,只有两种可能:客户端重复提交,或者 token 被别人窃取后重放。 - 一旦判定为重放,直接撤销整条
family_id,强制该登录下线重新认证。
这跟持久化 remember-me 的判定逻辑完全对应:
| 概念 | Remember-me(持久化令牌) | Refresh token rotation |
|---|---|---|
| 登录血统标识 | series |
family_id |
| 一次性凭证值 | token |
refresh_token |
| 轮换时机 | 每次带 cookie 认证成功后 | 每次用 refresh token 换 access token 后 |
| 重放判定 | series 匹配、token 不匹配 | family_id 匹配、refresh_token 不匹配 |
| 处理方式 | 删除该 series,强制重新登录 | 撤销该 family_id,强制重新认证 |
安全性取决于有没有服务端状态
refresh token rotation 不是 JWT 天生自带的能力,而是额外加的一层服务端状态。所以对比时不能简单说"JWT 比 session 安全"或反过来,要看具体实现:
- 纯无状态 refresh token(只验签名和过期)→ 没有重放检测、没法单点撤销,安全性弱于持久化 remember-me。
- 加了 rotation + family 追踪的 refresh token(本质上又变成"有状态")→ 和持久化 remember-me 的安全模型等价。
一句话总结:能不能防重放、能不能单独踢掉一次登录,取决于服务端是否维护了轮换记录,跟用的是 session 还是 JWT 无关。
维护记录
| 时间 | 修改内容 | 原因 |
|---|---|---|
| 2026-07-02 | 新建文章,说明 refresh token rotation 与 reuse detection 机制,并与 remember-me.md 对照 | 博客里此前没有相关文档,用户希望系统了解并留档 |