双 Token 与无感刷新
双 Token 解决的是单 Token 方案里一组绕不开的矛盾:令牌签出去就收不回,而登录体验又要求它尽量长效。拆成两个令牌之后,安全性归短的管,控制权归长的管——这是目前(2026 年)Web 端登录态保活的标准做法。
一、为什么一个 Token 不够
JWT 是无状态的:服务端验签即通过,不存会话。这带来扩展性,也带来一个后果——签发后无法主动失效(详见《JWT 与无状态认证》)。于是有效期成了一对无法同时满足的要求:
- 设长(如 30 天):用户体验好,但 token 一旦泄露,攻击者能在整个有效期内横行,改密码都踢不掉它;
- 设短(如 15 分钟):泄露影响窗口小,但用户每 15 分钟登录一次,体验直接崩掉。
本质是「安全窗口」和「登录体验」在抢同一个变量。双 Token 的思路是把它们拆开:高频使用的凭证求短,低频使用的凭证求长,各拿各的代价。
二、两个令牌的分工
| Access Token | Refresh Token | |
|---|---|---|
| 有效期 | 短,15 分钟 ~ 2 小时 | 长,7 ~ 30 天 |
| 存储位置 | 内存(不持久化) | HttpOnly + Secure Cookie |
| 出现场景 | 每个业务请求的头部 | 只在「换新」这一个接口出现 |
| 泄露后果 | 几分钟内自然作废 | 需靠轮换 + 吊销兜底 |
| 能否主动吊销 | 不需要(寿命够短) | 能,服务端删掉即可 |
两条原则值得记牢:
- Access 不落地:不放 localStorage、不放 Cookie,就放内存变量。刷新页面丢了没关系,有静默刷新兜底——它寿命短,持久化只会放大泄露面。
- Refresh 少露面:它只在刷新接口出现,接口面越小,可攻击的面越小。放
HttpOnlyCookie 里,JS 读不到,XSS 偷不走。代价是 Cookie 会被浏览器自动携带,所以刷新接口必须自己防 CSRF(校验Origin、或加 CSRF token),必要时再用Path=/auth/refresh把它的作用域收窄。
请求层统一兜住 401,业务代码里就不用到处写刷新逻辑了:
async function request(url, options = {}) {
const res = await fetch(url, withAuth(options))
if (res.status !== 401) return res // fetch 对 401 不会 reject,必须自己判状态码
await refreshOnce() // 并发请求只会真正刷新一次
return fetch(url, withAuth(options)) // 拿到新令牌后重放原请求
}
三、无感刷新的完整实现
核心是响应拦截器:业务代码完全无感知,401 在拦截层被消化掉。
let refreshing = null // 单例锁:同一时刻只允许一次刷新在途
axios.interceptors.response.use(undefined, async (error) => {
if (error.response?.status !== 401) throw error
// 关键:合并并发刷新。五个 401 只发一次 /refresh,
// 后来的请求共享同一个进行中的 Promise,而不是各自再发
refreshing ??= doRefresh().finally(() => { refreshing = null })
try {
const newToken = await refreshing
error.config.headers.Authorization = `Bearer ${newToken}`
return axios(error.config) // 用新 token 重放原请求
} catch {
logout() // 刷新也失败:Refresh 过期或被吊销,踢回登录
throw error
}
})
四个容易踩的坑,每个都对应一类线上事故:
- 并发刷新——页面同时发五个请求,Access 过期会同时收到五个 401。不合并的话,要么刷新接口被打爆,要么配合了轮换机制后后四次全部失败,用户被莫名登出。合并方式有两种:共享一个进行中的 Promise(如上),或者显式的请求队列(第一个 401 触发刷新,其余入队,刷新完成后统一重放)。
- 刷新失败死循环——刷新接口本身返回 401 时,绝不能再触发刷新逻辑,直接登出。拦截器里要区分「业务请求的 401」和「刷新请求的 401」。
- 排队请求带着旧 token 出门——刷新在途时新到的请求如果不等待,会带着过期 token 再收一次 401。它们应当挂起等刷新结果。
- 多标签页各刷各的——三个标签页各自触发三次刷新,配合轮换机制会互相作废。用
BroadcastChannel(或storage事件)广播新 token,一个页面刷新,全站共用。
刷新令牌要轮换,而且要能识别出「旧的被再次使用」这种泄露信号:
// 每次刷新都下发新令牌,旧的一次性作废
const next = issueRefreshToken(userId)
await revoke(oldToken)
// 若一个已作废的令牌又被拿来刷新,说明它可能被复制过
if (await isRevoked(oldToken)) {
await revokeTokenFamily(userId) // 整条令牌链作废,强制重新登录
throw new AuthError('检测到异常,请重新登录')
}
四、Refresh Token 的安全管理
Refresh Token 是整个方案的「控制权」所在,它的安全设计决定这套方案的下限:
轮换(Rotation)——每次用 Refresh 换新时,服务端作废旧 Refresh、签发新的。任何一个 Refresh 的存活期被压缩到「两次刷新之间」。如果检测到已作废的 Refresh 再次被使用,基本可以断定 token 被盗(合法客户端手里只有新的那个)——此时作废该用户的整个 token 家族,强制重新登录。
主动吊销——Refresh 不能是自包含的 JWT 长期有效,服务端要存一份(数据库或 Redis)。这样改密码、封号、「退出所有设备」都能立即生效:删掉服务端那份即可。
存储的多端适配——浏览器放 HttpOnly Cookie;移动端没有 Cookie 机制,加密后存 iOS Keychain / Android Keystore,请求头自定义字段携带,后端兼容两种来源。
限流与风控——刷新接口要限流;结合设备指纹、IP 突变做异常刷新检测。
刷新动作本身要单例化,否则一次并发会打出多个刷新请求,后一个把前一个作废:
let refreshing = null
function refreshOnce() {
if (!refreshing) {
refreshing = doRefresh().finally(() => { refreshing = null })
}
return refreshing // 并发期间大家都等同一个 Promise
}
// 注意 finally 里清空:刷新失败也必须复位,否则后续请求会一直复用这个失败的 Promise
五、与 SSO 的关系
这两个概念经常被混着问,分工其实很清晰:
- 双 Token 解决的是「一个系统内,登录态如何长期保活且可控」;
- SSO 解决的是「多个系统间,一次登录处处通行」。
两者经常组合使用:统一认证中心登录后签发 token,各子系统各自维护双 Token 保活;中心侧用 IdP 会话(Cookie)判断「是否已登录过」,子系统凭授权码换取自己的一对 token。区分关键:SSO 管「跨应用的第一次认证」,双 Token 管「单应用内的持续会话」。
401 不等于「令牌过期」,一律刷新会把真正的权限问题掩盖掉:
if (e.status === 401 && e.code === 'TOKEN_EXPIRED') {
await refreshOnce()
return retry()
}
if (e.status === 403) showNoPermission() // 刷新也没用,别白刷一次
if (e.status === 401 && e.code === 'ACCOUNT_DISABLED') redirectToLogin()
六、几个容易想错的地方
双 Token 不代表更安全,它只是把泄露窗口压缩了。Access 泄露之后仍然能一直用到过期,真正的安全提升来自 Refresh 的轮换与吊销机制——缺了这两样,双 Token 只是把风险换了个存放位置。
Access Token 也不必放 Cookie,放内存就够了。它寿命短、出现频繁,持久化收益为零,泄露面反而变大。
无感刷新也不是「过期了再刷」。更稳的做法是主动预刷新:解析 exp,发现距过期不足一分钟就先静默换新,大部分请求根本走不到 401,401 拦截只作为兜底。
Refresh Token 用 JWT 而不存服务端同样行不通——自包含意味着无法主动吊销,「改密码踢不掉」的旧问题原样回来了。服务端存一份(Redis 即可)是控制权的前提。
双 Token 的全部价值可以压缩成一句话:用短命令牌换安全窗口,用长命令牌换用户体验,用服务端存储换控制权——三者各付各的代价,没有一样是免费的。
刷新令牌的语义来自 RFC 6749,把「双 Token」映射到规范术语后,方案讨论会顺畅很多。