- Published on
刷新令牌轮换的设计空间:复用检测、宽限期与客户端持久化顺序的机制账
- Authors

- Name
- Jack Qin
把移动端从浏览器会话 Cookie 迁到第一方 Bearer Token,听起来只是"加个 JWT"。但 Bearer Token 真正的难度从来不在签发,而在刷新——具体说,在"用一次轮换一次"(rotate-on-use)的刷新令牌上。这个看似简单的机制,是一整片设计空间:令牌该静态哈希存还是慢 KDF 存、复用检测靠什么不变量成立、丢响应重试和真正的令牌窃取怎么区分、客户端写入顺序为什么是安全契约的一部分。
本文不讲"怎么发一个 JWT",而是想把刷新令牌轮换这件事拆成一笔清晰的机制账:它依赖哪个核心不变量、这个不变量在哪些边界处会被悄悄破坏、以及每一种破坏如何把"伪掉登录"或"安全漏洞"引回来。贯穿全篇的工作样例,是某环境监测平台一次跨层认证迁移(栈:.NET 10、JWT HS256、PostgreSQL、Expo / React Native)。它是 Cookie 分块重发那篇的续篇——上一篇打了临时稳定器把 Cookie 重发降到每 30 分钟一次,这一篇是让移动端彻底离开会话 Cookie 的根治方案。
迁移的约束:加法式,而非替换式
任何认证迁移的第一个设计决策,是"新旧怎么共存"。这里的约束是 additive(加法式):
- Cookie 和 Bearer 两套方案共存;
- Web 端字节级不变——还是 Cookie + CSRF,
apps/web不动一行; - 老的 Cookie 移动端构建继续可用,滚动发布期间不强制任何用户重新登录。
服务端把 Bearer 注册为第二套 scheme,让授权策略接受两者之一:
// bearer 作为第二套 scheme
options.DefaultPolicy = new AuthorizationPolicyBuilder(
CookieAuthenticationDefaults.AuthenticationScheme,
JwtBearerDefaults.AuthenticationScheme).RequireAuthenticatedUser();
数据库迁移是纯加法:只 CreateTable("refresh_tokens") 加索引,不 ALTER 任何现有表,Down() 只 DropTable,完全可回滚。加法式不是保守,而是一条降低迁移风险的设计原则:新机制并行铺设、旧机制原样保留,回滚成本趋近于零,灰度期间没有"必须同时切换"的悬崖。
Token 模型:两类令牌,两套截然不同的取舍
Bearer 认证有两类令牌,它们的设计取舍几乎完全相反,混为一谈是后续一切混乱的开始。
Access JWT:HS256 + kid 头、30 分钟 TTL、只放最小的 sub / email claim。它是无状态、自验证的——服务端不查库就能验签。代价是它无法被即时撤销,所以 TTL 必须短。
Refresh Token:不透明的 ≥256-bit CSPRNG 随机串、静态以 SHA-256 hex 存储、用一次轮换一次、14 天滑动空闲、无绝对上限。它是有状态、可撤销的——每次使用都要查库、轮换、可被作废。
这里第一个值得记的取舍是存储:refresh token 静态哈希存,而非慢 KDF(如 bcrypt)存。原因是它本身就是高熵的 CSPRNG 串——慢 KDF 是为了抵御对低熵口令的暴力枚举,而对一个 256-bit 随机串做暴力枚举在物理上不可行,慢 KDF 在这里只是平白增加每次刷新的延迟,没有任何安全收益。密钥的熵决定了该用哪种存储,这是一条比"密码一律 bcrypt"更精确的准则。
第二个取舍是 access 的 30 分钟 TTL——它刻意和 Cookie 的 ValidationInterval=30min 撤销 SLA 对齐,让整个系统只有一个撤销故事,不用维护两套时效模型。
而授权逻辑完全不变:Bearer 的 sub → NameIdentifier、email → Email,身份照样流进既有的 CurrentUserMiddleware → PermissionResolver 做每请求 DB 解析。永远不从 JWT claim 读 role/permission——这和上一篇 Cookie 的不变量是同一条。它的设计意义是:令牌只承载身份,授权永远来自每请求的实时 DB 解析,于是 token 路径需要零授权改造,账号禁用/权限变更也天然即时生效,不受任何令牌 TTL 的延迟拖累。
签名密钥走 secret/env,提交进 appsettings.json 的值必须为空——生产缺失则启动失败,绝不裸跑无签名。
坑一:JWT 签名提供者缓存的 kid 碰撞
第一个坑是生产相关、不只是测试假象的。Microsoft.IdentityModel 的默认 CryptoProviderFactory 会按 SecurityKey.KeyId(即 kid)缓存签名提供者。如果两个 key 共享同一个 kid 但字节不同(一个进程里多个 host、或同 kid 下轮换了密钥),缓存会返回一个为另一个 key 的字节构建的提供者,于是抛出莫名其妙的 IDX10503: signature is invalid。
正确写法是把签名 key 和验证 key都退出按 key 的提供者缓存:
var key = new SymmetricSecurityKey(bytes)
{
KeyId = kid,
CryptoProviderFactory = new CryptoProviderFactory { CacheSignatureProviders = false }
};
关键 gotcha:CacheSignatureProviders = false 必须在签名侧(TokenService)和验证侧(Program.cs)两边都设,只设一边照样碰撞。HMAC-SHA256 提供者构建很便宜,关掉缓存的代价可忽略。这个坑揭示了一个更一般的教训:当一个缓存的 key 是"逻辑标识"(kid)而非"内容标识"(字节哈希)时,标识冲突就会让缓存返回错误的对象——这是所有按逻辑键缓存的系统共有的陷阱。
坑二:刷新令牌轮换的"宽限期"是安全敏感区
这是整个迁移最值得细讲的一段——它是代码评审时真实抓出来的安全缺陷,不是理论假设。要看懂它,得先建立 rotate-on-use 的核心不变量。
核心不变量:每 family 恰好一个活跃头
rotate-on-use 的安全性建立在一条不变量上:同一条令牌链(family)在任意时刻恰好有一个活跃的头。 每次刷新消费掉当前头、链出唯一的后继。基于此,复用检测才成立:如果一个已消费的 token 再次被呈递,说明要么是合法客户端在重放、要么是窃取者拿着偷来的旧 token——无论哪种,正确反应都是撤销整个 family,因为"一个已消费 token 又出现"意味着不变量已被破坏。这正是 rotate-on-use 比"长寿不轮换的 refresh token"更安全的根本原因:它能侦测到令牌被克隆。
为什么需要宽限期
但这条不变量和现实网络打架。最初的实现保守地"拒绝任何已消费的 refresh token",结果在车厢内不稳定的网络下把"伪掉登录"又引了回来:刷新的响应丢包了,客户端拿着一个服务端已消费过的 token 重试 → 朴素地看就像复用攻击 → 登出。司机在上班途中被踢。
于是需要一个宽限窗口(RefreshReuseGraceSeconds,默认 30 秒),在窗口内容忍"丢响应重试"。难点在于:宽限期必须只容忍合法的丢响应重试,而不能给真正的令牌窃取开口子。这正是设计空间里最窄的一条缝。
错误的宽限实现:fork 兄弟、留旧头活着
一个诱人但错误的修法是:从已消费的前驱 A 派生一个新兄弟 C,同时让原后继 B 继续活着——这样慢响应(非丢响应)也能用:
// 丢响应重试:客户端回放前驱 A(它从没拿到后继 B)。
// 库里只存了 SHA-256(B),B 的原始值无法返回。诱人的"修法":
// 从已消费的 A 派生一个新兄弟 C,并让 B 继续活着。
var c = Mint(familyId: a.FamilyId, previous: a.Id); // A 仍是 consumed
// B 留着,没动。→ family 现在有两个活跃的头(B 和 C)
为什么这是安全缺陷(评审中发现,非理论):
- 悬空令牌:B 一直有效到 14 天空闲到期,却没有任何客户端持有它。
- 该 family 的复用检测永久失效:B 和 C 是并行的活跃分支,"每 family 恰好一个活跃 token"的不变量永远被破坏——一个被窃取的 B 永远不会触发检测,因为系统里本就有两个合法活跃头,"已消费 token 重现"这个信号失去了意义。
- 无界铸造:A 在宽限分支里从未被消费,所以在宽限窗口内反复回放 A 就能铸造无限个有效兄弟,无一触发检测。
一句话:fork 兄弟的瞬间,核心不变量被永久打破,复用检测——也就是 rotate-on-use 的全部安全价值——随之归零。
正确实现:带余量的轮换(RFC 9700 / Auth0-Okta 模式)
正确做法是绝不 fork,而是把 family 的头向前轮换一格:原子地消费掉丢失的头 B,从 B 链出单一的新后继 C。
// 通过条件更新原子地消费掉丢失的头 B,从 B 链出单一的新后继 C。
// 保持"每 family 恰好一个活跃 token"。合法的丢响应重试仍拿到可用的 pair;
// 被窃取的 B 现在命中 consumed-replay → family 撤销;
// C 出现后再回放 A 会过不了 depth-1 守卫 → ReuseDetected。
// 并发安全:竞态中落败的那个重新进入 RotateAsync,落到同一个幂等路径。
if (!await TryConsumeAsync(headB, replacedBy: c.Id, ct)) return await RotateAsync(raw, ct);
var c = Mint(familyId: b.FamilyId, previous: b.Id);
关键 gotcha:用静态哈希存储的轮换令牌,重试时你永远无法把之前签发过的某个原始值再交回去——库里只有它的哈希。所以唯一安全的做法是把 family 的头向前轮换一格(消费掉丢失的头、链出后继),绝不用并行兄弟去 fork family。这条约束直接来自"静态哈希存储"这个早先的取舍:因为存的是哈希、原始值不可逆,所以"重发旧 token"在物理上不可能,剩下的唯一出路就是向前轮换。两个看似无关的设计决策,在这里咬合成了同一条因果链。
宽限谓词与复用检测:把不变量翻译成判定
宽限只在一组严格谓词全部成立时触发:呈递的 token 是 consumed-only(未撤销、未过期)且 ReplacedByTokenId 已设 且 ConsumedAt 已设 且 now ≤ ConsumedAt + RefreshReuseGraceSeconds 且 后继行仍存在、活跃、successor.ConsumedAt is null(严格 depth-1:直接后继必须仍是活跃的头)。命中即走"带余量轮换"。
反过来,复用检测触发整族撤销:一个已消费 token 再次被呈递,且它不是宽限期内紧邻的前一个(即 depth > 1、或过了宽限窗口、或 family 已被撤销)——撤销所有共享 FamilyId 的行,该 family 后续每次刷新都失败 → 一次干净的重新登录。这正是把"每 family 恰好一个活跃头"这条不变量,翻译成可执行的判定:depth-1 之内是合法重试,depth-1 之外是攻击信号。
并发守卫:用原子条件更新堵死读-改-写窗口
TryConsumeAsync 是条件式 ExecuteUpdateAsync:UPDATE … SET consumed_at, replaced_by_token_id WHERE id=@id AND consumed_at IS NULL AND revoked_at IS NULL,原子、无读-改-写窗口。两个竞态轮换里恰好一个拿到 affected==1;落败者重新进入 RotateAsync 走到幂等的宽限路径(不 fork、不双重铸造)。仅关系型走这条路;InMemory 回退是单线程测试代码,Postgres 永远走原子路径。这条揭示了:轮换的并发安全不能靠应用层加锁,必须把"消费"做成一次带条件的原子写——条件本身(consumed_at IS NULL)就是乐观锁。
坑三:客户端的"持久化顺序"是一条隐形契约
服务端做对了,客户端如果把顺序搞反,会从客户端这一侧把整族撤销 → 登出的 bug 又引回来。
错误:在持久化轮换后的 token 之前就 resolve
// 单飞刷新,但轮换后的 refresh 写入是 fire-and-forget(或排在 resolve 之后)。
const r = await fetch('/auth/token/refresh', { body: oldRefresh })
const pair = await r.json()
setAccessInMemory(pair.access)
void SecureStore.setItemAsync('refresh', pair.refresh) // 没 await
return { ok: true } // resolve 太早
// → 下一次 /token/refresh 回放已消费的 token。宽限能容忍第一次;
// 但在任何重试/冷启动抖动下会复发 → 整族撤销 → 强制登出。
问题的本质:一个等待中的请求——或下一次刷新——会在 SecureStore 仍持有旧(已消费)refresh token 时就继续推进,于是下一次刷新回放了已消费 token。
正确:轮换写入 happens-before 共享 promise resolve
// performRefresh 在它的 promise resolve 之前,await 新 refresh 的持久化写入。
// 因为单飞把它包进一个拦截器 await 的共享 promise,这保证了在轮换后的
// refresh 落进 SecureStore 之前,没有等待请求会恢复、也没有下一次刷新能开始。
const pair = await (await fetch('/auth/token/refresh', { body: oldRefresh })).json()
setAccessInMemory(pair.access)
await SecureStore.setItemAsync('refresh', pair.refresh) // happens-before
return { ok: true }
关键 gotcha:对 rotate-on-use 的 refresh token,客户端对新 refresh 的持久化写入,必须在任何能触发下一次刷新的代码路径之前完成。"持久化了 token"是必要但不充分的——真正的契约是顺序(write-before-resolve,由那一个共享单飞 promise 强制)。这是服务端不变量在客户端的镜像:服务端保证"每 family 一个活跃头",客户端必须保证"持有的永远是那个活跃头"——而保证后者的唯一方式,是让"写入新头"happens-before"任何可能用到头的操作"。
客户端方案的其余要点
服务端的轮换/复用检测、客户端的持久化顺序之外,客户端还有几条支撑性的不变量:
- 令牌存储拆分。 Refresh → Expo
SecureStore(AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY、无 iCloud 同步)。Access → 仅模块内存,绝不落盘、绝不记日志。clearTokens()同时删 SecureStore 条目(iOS Keychain 卸载后仍存活,必须显式擦)和置空内存 access。 - 单飞 401 → 刷新 → 重试一次。 守卫是一个共享的 in-flight
Promise,不是布尔标志。N 个并发 401 → 恰好一次POST /auth/token/refresh,所有等待者从同一个 promise resolve;原请求至多重试一次,仍 401 则一次干净登出。401→刷新是一个if,绝不是循环。 - 失败 → 恰好一次干净登出。 任何失败的刷新 → 拥有该 flight 的逻辑运行
clearTokens()+onSessionEnded()恰好一次,即便 N 个并发 401。 - 启动 = 开机刷新。
AuthContext.restore()加载存储的 refresh → 刷新 → 成功则一次me()填充不变的用户/权限上下文,失败则干净的未认证态。替换掉旧的 Cookieme()竞态。 - 移动端移除(仅移动端):
react-native-nitro-cookies及其 peer、cookieJar.ts、csrf.ts——都只用于认证路径。apps/web的 Cookie+CSRF 不动。
坑四:把瞬时失败当成拒绝
收尾这个 bug-class 的最后一块,是认清"刷新失败了"其实是两个不同的事件。朴素的硬化会把任何非 ok 的刷新都当成会话失效:
// 朴素的重试/超时硬化:任何非 ok 的刷新 → 登出。
const r = await fetchWithTimeout('/auth/token/refresh', { body: refresh })
if (!r.ok) {
await clearTokens()
onSessionEnded()
return
}
// 一个请求超时 / abort / 5xx / 429 / 离线(糟糕的车厢内网络)现在被读成
// "会话失效" → 司机在上班途中被登出。又把伪登出 bug 从重试层引回。
正确做法是严格、穷尽的"瞬时 vs 拒绝"分类:
// 只有"确定的服务端拒绝"才结束会话。连接形态的失败在有界预算内重试。
function classify(o): 'ok' | 'transient' | 'rejected' {
// transient(重试,绝不登出):传输错误、abort/timeout、HTTP >=500、HTTP 429
// rejected(恰好登出一次):401/400、其他非 2xx、2xx 但 token body 缺失/无效、无存储 refresh
// ok:2xx 且带有效的轮换 pair
}
// 拆除仅当 reason === "rejected";transient-耗尽 → token 不动。
关键 gotcha:登出必须仅由确定的服务端拒绝触发;每一个连接形态的结果都是 transient → 重试后保留,绝不登出。具体硬化参数:每次 fetch 对一个 8 秒 AbortController 超时赛跑、最多 3 次、全抖动指数退避、硬性约 20 秒总退避预算;重试循环串在那一个单飞 promise 内,N 个并发 401 仍只产生一条序列的 fetch;遥测是纯本地内存 sink(无网络、无 PII、无 token 值),其中 transport:"bearer" 是采纳标记——移动端 Cookie 路径被废弃之前,必须在生产里先测到它。
这条分类背后是一个可迁移的原则:在"撤销可观测的会话"这种破坏性操作前,必须把"明确拒绝"和"连接没说清楚"分开——把后者当成前者,等于让网络抖动行使本该只属于服务端的撤销权。
可迁移的那一层
抛开 .NET 和 React Native 的具体 API,这次迁移真正可迁移的认知有四条:
第一,安全机制的价值寄生在一条不变量上,破坏不变量等于让机制失效。 rotate-on-use 的全部安全价值都来自"每 family 恰好一个活跃头"——fork 兄弟那一刻,复用检测就归零了。设计安全机制时,先找出它依赖的那条不变量,再确保每条代码路径(尤其是"为了体验加的宽限/重试")都不破坏它。
第二,看似无关的设计决策会在边界处咬合。 "refresh 静态哈希存"(一个存储决策)和"轮换只能向前、不能 fork"(一个安全决策)其实是同一条因果链:因为存哈希、原始值不可逆,所以重发旧 token 不可能,于是只能向前轮换。孤立地评估每个决策会错过这种耦合。
第三,分布式状态的不变量需要在每一侧都被镜像。 服务端保证"一个活跃头",客户端就必须保证"持有的永远是那个活跃头"——靠 write-before-resolve 的顺序契约。一侧做对、另一侧顺序错,bug 照样从另一侧引回。
第四,破坏性操作前要区分"拒绝"和"未知"。 登出、删除、撤销这类不可逆动作,只应由确定的信号触发;把"连接没说清楚"当成"明确拒绝",是把网络的抖动错当成了权威的判决。