双 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 的工作流程
登录阶段
用户登录成功后,服务端通常会:
- 生成短期
Access Token - 生成长期
Refresh Token - 把
Access Token返回给前端 - 把
Refresh Token写入安全 Cookie,或返回给前端后立即受控保存
访问接口阶段
前端访问普通接口时,只携带 Access Token。
如果 Access Token 还没过期,请求正常通过;如果已过期,服务端返回 401 Unauthorized。
刷新阶段
当前端发现 Access Token 过期时,会调用刷新接口,带上 Refresh Token,换取新的 Access Token。
退出登录阶段
服务端需要让 Refresh Token 失效,同时前端清除本地登录态。这样用户才算真正退出。
一个常见的落地方案
这是实际项目里比较稳妥的一种组合:
Access Token:放内存里,例如 React 状态、Zustand、Redux StoreRefresh 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,服务端存储它的状态:
| 字段 | 说明 |
|---|---|
jti | Token 唯一 ID |
userId | 归属用户 |
expiresAt | 过期时间 |
revoked | 是否已吊销 |
replacedBy | 被哪个新 Token 替换 |
deviceId | 可选,所属设备 |
当刷新成功时:
- 把旧记录标记为
revoked - 生成新的
jti - 存入新记录
- 返回新的 Refresh Token
什么是 Refresh Token 重放攻击?
所谓重放攻击,就是攻击者拿着已经偷到的 Token,重复发送原本有效的请求。
在双 Token 场景里,最危险的是:
- 攻击者窃取了某个
Refresh Token - 用户正常刷新了一次,系统已经发了新 Token
- 攻击者又拿旧
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=Lax或SameSite=Strict - 刷新接口仅允许
POST - 校验
Origin/Referer - 对高风险场景增加 CSRF Token
3. 退出登录不能只删前端变量
只把浏览器里的 Access Token 清空,并不代表会话真的失效。必须同时让服务端的 Refresh Token 失效。
常见存储方案对比
| 方案 | Access Token | Refresh Token | 特点 |
|---|---|---|---|
| 方案 A | localStorage | localStorage | 实现简单,但最容易被 XSS 一锅端 |
| 方案 B | 内存 | localStorage | 比 A 好一些,但 Refresh Token 风险仍高 |
| 方案 C | 内存 | HttpOnly Cookie | 前后端分离中较常见,安全性和体验较平衡 |
| 方案 D | Cookie Session | 服务端 Session | 更偏传统 Session 方案,不是典型双 JWT 方案 |
实际项目里,通常优先考虑:
Access Token放内存Refresh Token放HttpOnly 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:唯一令牌 IDsid:会话 IDdeviceId:设备标识
这些信息对吊销、审计、追踪都很有帮助。
一套推荐实践
如果你要在真实项目中落地双 Token,可以优先按下面这套思路:
Access Token短期有效,只用于业务接口访问Refresh Token长期有效,只用于刷新接口Refresh Token放HttpOnly + Secure + SameSiteCookie- 刷新接口使用
POST,并校验来源 - 服务端保存 Refresh Token 的会话记录
- 开启 Refresh Token Rotation
- 发现旧 Token 复用时,立即吊销整条会话
- 退出登录时,服务端主动失效 Refresh Token
常见误区
误区一:双 Token 就不怕 XSS 了
不是。它只能降低长期凭证泄漏的风险,不能消灭 XSS。
误区二:Refresh Token 可以直接访问业务接口
不可以。Refresh Token 的职责应该严格限制在“刷新令牌”。
误区三:只要用了 JWT,就不需要存库
不严谨。尤其是 Refresh Token,如果完全不存库,会很难做吊销、轮换、复用检测。
误区四:退出登录只需要前端删 Token
不够。服务端必须让 Refresh Token 失效,否则会话还能被继续刷新。
面试高频问答
Q1:什么是双 Token 策略?
答: 双 Token 策略是把登录凭证拆成两个令牌:Access Token 和 Refresh 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 只应该发送给刷新接口,不应该参与普通业务请求
Q4:为什么推荐把 Refresh Token 放到 HttpOnly Cookie 中?
答: 因为 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。