跳到主要内容

双 Token 策略详解

在前后端分离项目里,登录态管理经常会遇到一个矛盾:

  • Token 过期时间太长,泄漏后风险大
  • Token 过期时间太短,用户频繁掉线,体验差

双 Token 策略就是用两个职责不同的令牌来解决这个矛盾:

  • Access Token(访问令牌):生命周期短,用来访问业务接口
  • Refresh Token(刷新令牌):生命周期长,用来换取新的 Access Token

一句话概括:让短期令牌负责“安全”,让长期令牌负责“续命”


为什么需要双 Token?

先看两种极端方案:

方案一:只用一个长期 Token

优点是简单,登录一次能用很久。

问题也很明显:

  • 一旦 Token 泄漏,攻击者可长期冒用用户身份
  • 前端常把 Token 放在 localStorage,更容易被 XSS 读取
  • 服务端难以及时收回已经签发的令牌

方案二:只用一个短期 Token

优点是安全性更高,令牌很快失效。

问题是:

  • 用户会频繁重新登录
  • 每次刷新页面、切后台过久,都可能触发登录失效
  • 体验很差,不适合真实业务

双 Token 的核心思路

双 Token 不是“多发一个 Token”那么简单,而是把职责拆开

  • Access Token 专门负责访问接口,尽量短命
  • Refresh Token 专门负责续签,不参与普通业务请求

这样就能同时得到:

  • 更好的安全性:Access Token 被偷后,可用时间有限
  • 更好的体验:Access Token 过期后,可以静默刷新

两个 Token 分别负责什么?

类型作用生命周期是否频繁发送推荐存储位置
Access Token调用业务接口短,常见 5 分钟到 2 小时内存优先,其次受控存储
Refresh Token换取新的 Access Token长,常见 7 天到 30 天否,只发给刷新接口HttpOnly + Secure + SameSite Cookie

1. Access Token

它是“门票”,前端访问接口时通常放在请求头中:

Authorization: Bearer <access_token>

特点:

  • 有效期短
  • 权限信息通常直接编码在 Token 里
  • 服务端校验通过后可直接放行业务请求

2. Refresh Token

它是“换票凭证”,不应该用于访问普通业务接口,只应该发给专门的刷新接口,例如:

POST /api/auth/refresh

特点:

  • 生命周期更长
  • 权限通常更少,职责更单一
  • 一般需要更强的保护和服务端控制
关键原则

Refresh Token 不是“更高级的 Access Token”,它只是“重新申请 Access Token 的凭证”,不要混用。


双 Token 的工作流程

登录阶段

用户登录成功后,服务端通常会:

  1. 生成短期 Access Token
  2. 生成长期 Refresh Token
  3. Access Token 返回给前端
  4. Refresh Token 写入安全 Cookie,或返回给前端后立即受控保存

访问接口阶段

前端访问普通接口时,只携带 Access Token

如果 Access Token 还没过期,请求正常通过;如果已过期,服务端返回 401 Unauthorized

刷新阶段

当前端发现 Access Token 过期时,会调用刷新接口,带上 Refresh Token,换取新的 Access Token

退出登录阶段

服务端需要让 Refresh Token 失效,同时前端清除本地登录态。这样用户才算真正退出。


一个常见的落地方案

这是实际项目里比较稳妥的一种组合:

  • Access Token:放内存里,例如 React 状态、Zustand、Redux Store
  • Refresh Token:放 HttpOnly Cookie
  • 刷新接口:只接收 Cookie 中的 Refresh Token
  • 刷新成功后:返回新的 Access Token

这样设计的好处是:

  • JS 代码拿不到 HttpOnly Cookie,能降低 XSS 窃取 Refresh Token 的风险
  • 普通接口不需要每次都带 Refresh Token
  • Access Token 即使泄漏,也只能用较短时间

前端请求拦截示例

let accessToken = '';
let refreshingPromise: Promise<string> | null = null;

export function setAccessToken(token: string) {
accessToken = token;
}

async function refreshAccessToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});

if (!response.ok) {
throw new Error('刷新失败');
}

const data = await response.json();
accessToken = data.accessToken;
return accessToken;
}

export async function authFetch(input: RequestInfo, init: RequestInit = {}) {
const doRequest = (token: string) =>
fetch(input, {
...init,
credentials: 'include',
headers: {
...init.headers,
Authorization: `Bearer ${token}`,
},
});

let response = await doRequest(accessToken);

if (response.status !== 401) {
return response;
}

if (!refreshingPromise) {
refreshingPromise = refreshAccessToken().finally(() => {
refreshingPromise = null;
});
}

const newToken = await refreshingPromise;
response = await doRequest(newToken);
return response;
}

这个例子解决了一个高频问题:多个请求同时 401 时,只刷新一次,避免重复刷新。

服务端刷新接口示例

app.post('/api/auth/refresh', async (req, res) => {
const refreshToken = req.cookies.refreshToken;

if (!refreshToken) {
return res.status(401).json({ message: '缺少 refresh token' });
}

const payload = verifyRefreshToken(refreshToken);
if (!payload) {
return res.status(401).json({ message: 'refresh token 无效' });
}

const tokenRecord = await tokenStore.findByJti(payload.jti);
if (!tokenRecord || tokenRecord.revoked) {
return res.status(401).json({ message: 'refresh token 已失效' });
}

const newAccessToken = signAccessToken({
userId: payload.userId,
roles: payload.roles,
});

return res.json({
accessToken: newAccessToken,
});
});

刷新接口为什么是重点保护对象?

普通业务接口的核心是“读数据、写数据”,刷新接口的核心是“续发身份”。一旦刷新接口设计松散,会出现两个严重问题:

  • 攻击者可以反复刷新,长期维持会话
  • 用户明明已经退出,但旧令牌还能继续换新令牌

所以刷新接口通常要多做几层控制:

  • 校验 Refresh Token 是否有效、是否过期
  • 校验该 Token 是否已被吊销
  • 绑定用户、设备、会话 ID、jti
  • 记录最后刷新时间和来源信息
  • 对异常刷新频率做限流

Refresh Token Rotation 是什么?

这是双 Token 体系里非常重要的一步,中文常叫刷新令牌轮换

意思是:每刷新一次,就把旧的 Refresh Token 作废,再签发一个新的 Refresh Token。

为什么要轮换?

如果 Refresh Token 长期不变,那么它一旦泄漏,攻击者就可以持续刷新 Access Token。

而做轮换后:

  • 正常用户刷新一次,旧 Refresh Token 就失效
  • 如果攻击者还拿着旧 Token 再来刷新,就能被识别为异常

轮换流程

典型实现方式

给每个 Refresh Token 一个唯一标识 jti,服务端存储它的状态:

字段说明
jtiToken 唯一 ID
userId归属用户
expiresAt过期时间
revoked是否已吊销
replacedBy被哪个新 Token 替换
deviceId可选,所属设备

当刷新成功时:

  1. 把旧记录标记为 revoked
  2. 生成新的 jti
  3. 存入新记录
  4. 返回新的 Refresh Token

什么是 Refresh Token 重放攻击?

所谓重放攻击,就是攻击者拿着已经偷到的 Token,重复发送原本有效的请求。

在双 Token 场景里,最危险的是:

  1. 攻击者窃取了某个 Refresh Token
  2. 用户正常刷新了一次,系统已经发了新 Token
  3. 攻击者又拿旧 Refresh Token 去刷新

如果系统没有做轮换或复用检测,攻击者仍然能继续维持登录态。

如何检测复用?

当服务端发现:

  • 这个 Refresh Token 已经被标记为失效
  • 但它又被再次拿来刷新

就可以推断:这个 Token 很可能已经泄漏

常见处理方式:

  • 立即吊销该用户当前会话下的所有 Refresh Token
  • 强制重新登录
  • 记录安全日志,必要时触发风控
记忆技巧

轮换解决的是“旧 Token 不该继续可用”,复用检测解决的是“旧 Token 为什么又出现了”。


双 Token 并不等于绝对安全

它能降低风险,但不能替代其他安全措施。

1. 防不住所有 XSS

如果 Access Token 存在 localStorage,发生 XSS 时仍可能被读走。双 Token 只是把风险从“长期泄漏”压缩成“短期泄漏”。

2. 需要考虑 CSRF

如果 Refresh Token 放在 Cookie 里,而刷新接口又没有配好 SameSite、CSRF 防护或来源校验,那么刷新接口本身可能受到攻击。

常见保护手段:

  • Cookie 配 SameSite=LaxSameSite=Strict
  • 刷新接口仅允许 POST
  • 校验 Origin / Referer
  • 对高风险场景增加 CSRF Token

3. 退出登录不能只删前端变量

只把浏览器里的 Access Token 清空,并不代表会话真的失效。必须同时让服务端的 Refresh Token 失效。


常见存储方案对比

方案Access TokenRefresh Token特点
方案 AlocalStoragelocalStorage实现简单,但最容易被 XSS 一锅端
方案 B内存localStorage比 A 好一些,但 Refresh Token 风险仍高
方案 C内存HttpOnly Cookie前后端分离中较常见,安全性和体验较平衡
方案 DCookie Session服务端 Session更偏传统 Session 方案,不是典型双 JWT 方案

实际项目里,通常优先考虑:

  • Access Token 放内存
  • Refresh TokenHttpOnly Cookie

双 Token 和 Session 有什么区别?

很多人会把“登录态”混在一起理解,其实它们的思路不同:

方案状态主要放哪服务端是否必须保存会话典型特点
Session + Cookie服务端易控、易失效、扩展依赖会话存储
JWT 单 Token客户端无状态,简单,但回收困难
双 Token客户端 + 服务端控制点通常建议部分保存兼顾体验与安全,但实现更复杂

注意:双 Token 不一定必须用 JWT。很多系统会:

  • Access Token 用 JWT
  • Refresh Token 用随机字符串 + 数据库存储

这其实往往比“双 JWT”更容易做吊销和轮换控制。


设计双 Token 时的几个关键细节

1. Access Token 不要太长寿

常见建议:

  • 后台管理系统:15 分钟到 2 小时
  • 安全要求高的系统:5 分钟到 30 分钟

2. Refresh Token 不要无限期有效

即使是 Refresh Token,也应该有绝对过期时间,比如 7 天、15 天、30 天,而不是永久有效。

3. 刷新接口要限流

避免异常脚本高频调用刷新接口,造成刷库、撞库或资源浪费。

4. 尽量做单设备或多设备隔离

不同设备上的会话最好能分别管理。这样用户只退出某一个设备时,不会影响全部设备,也便于风控。

5. 给 Token 增加会话标识

比如增加:

  • jti:唯一令牌 ID
  • sid:会话 ID
  • deviceId:设备标识

这些信息对吊销、审计、追踪都很有帮助。


一套推荐实践

如果你要在真实项目中落地双 Token,可以优先按下面这套思路:

  1. Access Token 短期有效,只用于业务接口访问
  2. Refresh Token 长期有效,只用于刷新接口
  3. Refresh TokenHttpOnly + Secure + SameSite Cookie
  4. 刷新接口使用 POST,并校验来源
  5. 服务端保存 Refresh Token 的会话记录
  6. 开启 Refresh Token Rotation
  7. 发现旧 Token 复用时,立即吊销整条会话
  8. 退出登录时,服务端主动失效 Refresh Token

常见误区

误区一:双 Token 就不怕 XSS 了

不是。它只能降低长期凭证泄漏的风险,不能消灭 XSS。

误区二:Refresh Token 可以直接访问业务接口

不可以。Refresh Token 的职责应该严格限制在“刷新令牌”。

误区三:只要用了 JWT,就不需要存库

不严谨。尤其是 Refresh Token,如果完全不存库,会很难做吊销、轮换、复用检测。

误区四:退出登录只需要前端删 Token

不够。服务端必须让 Refresh Token 失效,否则会话还能被继续刷新。


面试高频问答

Q1:什么是双 Token 策略?

答: 双 Token 策略是把登录凭证拆成两个令牌:Access TokenRefresh Token。前者短期有效,用于访问业务接口;后者长期有效,用于在 Access Token 过期后换取新的 Access Token。它的目标是在安全性和用户体验之间做平衡。

Q2:为什么不直接把 Access Token 的过期时间设得很长?

答: 因为 Access Token 一旦泄漏,攻击者就能在较长时间内持续冒用用户身份。双 Token 的做法是让 Access Token 短期有效,把长期续期能力交给保护更严格的 Refresh Token,从而降低长期泄漏风险。

Q3:Access Token 和 Refresh Token 的区别是什么?

答:

  • Access Token 用来访问普通业务接口,生命周期短,发送频率高
  • Refresh Token 用来换新 Access Token,生命周期长,发送频率低
  • Refresh Token 只应该发送给刷新接口,不应该参与普通业务请求

答: 因为 HttpOnly Cookie 不能被前端 JavaScript 直接读取,能降低 XSS 窃取 Refresh Token 的风险。同时浏览器可以在调用刷新接口时自动携带 Cookie,前端实现也更自然。

Q5:什么是 Refresh Token Rotation?

答: 就是每次刷新成功后,旧的 Refresh Token 立即作废,并签发一个新的 Refresh Token。这样即使旧 Token 泄漏,后续再次使用时也能被识别为异常,有助于防止长期会话被劫持。

Q6:双 Token 一定要配合 JWT 吗?

答: 不一定。常见做法是 Access Token 用 JWT,而 Refresh Token 用随机字符串并存储在数据库中。这样服务端更容易做吊销、轮换和复用检测。

Q7:退出登录时应该做什么?

答: 不仅要清除前端保存的 Access Token,还要让服务端保存的 Refresh Token 失效,并清理浏览器中的相关 Cookie。否则攻击者仍可能用旧的 Refresh Token 继续刷新出新的 Access Token。