双 Token 策略
面试速答
- 这里说的双 Token 策略,通常指:
- Access Token:短期令牌,负责访问业务接口
- Refresh Token:长期令牌,负责在 Access Token 过期后换新
- 设计目标不是“更炫”,而是解决两个现实矛盾:
- 令牌有效期太长,泄露后风险大
- 令牌有效期太短,用户频繁掉登录
- 核心思路:让高频使用的令牌短命,让低频使用的令牌专职续期,并把续期动作收口到更少的接口上。
- 常见推荐:
- Access Token 放内存,请求时放
Authorization头 - Refresh Token 尽量放
HttpOnly + SecureCookie - 刷新接口做轮换、失效控制、并发收敛
- Access Token 放内存,请求时放
先回答“为什么要双 Token”
如果只有一个长期有效的 token,会有两个直接问题:
1. 安全风险高
一旦 token 泄露,攻击者能在很长时间内直接调用业务接口。
2. 用户体验差
如果把单 token 的有效期压得很短,用户又会频繁重新登录。
双 Token 的本质就是把“访问能力”和“续期能力”拆开:
- Access Token 暴露面大,因为它几乎每个请求都要用,所以应当短期有效
- Refresh Token 使用频率低,只在续期接口中出现,所以可以相对长效,并配合更严格控制
心智模型
一句话记忆:
- Access Token 管访问
- Refresh Token 管续命
一个完整流程
1. 登录阶段
用户登录成功后,服务端返回:
- 一个短期有效的 Access Token
- 一个相对长效的 Refresh Token
常见返回方式:
- Access Token 放响应体,由前端接收后存到内存
- Refresh Token 通过
Set-Cookie写入HttpOnlyCookie
示例:
HTTP/1.1 200 OK
Set-Cookie: refresh_token=xxx; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh
Content-Type: application/json
{
"accessToken": "xxx",
"expiresIn": 900
}
2. 访问业务接口
前端每次请求时,主动把 Access Token 放到请求头:
Authorization: Bearer <access_token>
3. Access Token 过期
当服务端返回 401 或明确的过期错误码时,前端不应立刻把用户踢下线,而是尝试走一次刷新流程。
4. 调刷新接口
前端请求 /auth/refresh,浏览器会自动携带 Refresh Token Cookie。服务端校验通过后,返回新的 Access Token。
5. 继续原请求
前端把拿到的新 Access Token 覆盖旧值,再重放刚才失败的业务请求。
时序图
为什么 Access Token 通常要短期有效
因为它是“高频暴露”的令牌:
- 每个接口都带
- 前端代码直接参与注入
- 日志、抓包、代理工具、异常上报都可能碰到它
所以工程上常见做法是把 Access Token 设计成:
- 几分钟到几十分钟有效
- 无状态校验快
- 过期后必须刷新
面试里建议你直接给出判断逻辑,而不是背固定分钟数:
- 业务越敏感,Access Token 越应该短
- 刷新链路越稳定,Access Token 越可以短
为什么 Refresh Token 不能像“永久登录凭证”一样随便放
很多人以为双 Token 只是“再发一个更长的 token”。这不够严谨。
Refresh Token 虽然使用频率低,但它的权限很特殊:
- 它不能直接访问业务接口
- 但它能不断换出新的 Access Token
所以它一旦泄露,影响仍然很大。正确理解是:
- Refresh Token 暴露面更小
- 但价值更高
- 因此要放在更不容易被前端脚本拿到的位置
这也是为什么常见推荐是:
- Refresh Token 放
HttpOnly Cookie - Access Token 放内存
推荐存储方案
| 令牌 | 常见存储位置 | 原因 | 主要风险 |
|---|---|---|---|
| Access Token | 内存 | 不落盘,生命周期最短,主动加请求头 | 页面刷新丢失,需要配合刷新链路 |
| Refresh Token | HttpOnly Cookie | 前端 JS 读不到,适合自动带到刷新接口 | 需要注意 CSRF、防重放与轮换 |
为什么不推荐把两个 token 都塞进 localStorage
因为这会把安全边界拉低到“只要出现 XSS,两个令牌都可被直接拿走”。尤其是 Refresh Token 一旦被拿走,攻击者就不只是“趁你当前会话作恶”,而是可能长期续期。
为什么 Access Token 不一定适合放 Cookie
如果业务接口也依赖 Cookie 自动带上 Access Token,那你又会重新面对 CSRF 问题,很多接口都要承担跨站请求风险。
前端应该怎么实现
1. 请求注入
let accessToken: string | null = null;
export function setAccessToken(token: string | null) {
accessToken = token;
}
export async function request(input: RequestInfo, init: RequestInit = {}) {
const headers = new Headers(init.headers);
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`);
}
return fetch(input, {
...init,
headers,
credentials: 'include',
});
}
这里 credentials: 'include' 的作用不是给业务接口带 Access Token,而是确保在需要时浏览器能带上刷新 Cookie。
2. 失败后自动刷新
let refreshingPromise: Promise<string | null> | null = null;
async function refreshAccessToken() {
const response = await fetch('/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) {
setAccessToken(null);
return null;
}
const data = await response.json();
setAccessToken(data.accessToken);
return data.accessToken;
}
export async function authRequest(input: RequestInfo, init: RequestInit = {}) {
let response = await request(input, init);
if (response.status !== 401) {
return response;
}
if (!refreshingPromise) {
refreshingPromise = refreshAccessToken().finally(() => {
refreshingPromise = null;
});
}
const newToken = await refreshingPromise;
if (!newToken) {
throw new Error('登录状态已失效,需要重新登录');
}
response = await request(input, init);
return response;
}
这个实现解决了一个非常高频的追问:
- 多个接口同时 401 怎么办?
答案是:
- 不要让每个请求都各自刷新一次
- 应该做并发收敛
- 让所有失败请求共用同一个刷新 Promise
服务端通常要配合什么
前端只会说“401 了我去 refresh”是不够的,完整答案要把服务端配合说出来。
1. Refresh Token 轮换
每次刷新成功,不只返回新的 Access Token,也要签发新的 Refresh Token,并让旧的 Refresh Token 失效。
这叫 Refresh Token Rotation。
意义在于:
- 降低长期重放风险
- 更容易识别盗用链路
2. 维护设备或会话维度的令牌状态
例如:
- token ID
- 用户 ID
- 设备 ID
- 过期时间
- 是否已吊销
这样才能支持:
- 单设备退出
- 全局下线
- 风险设备封禁
3. 刷新接口要更严格
因为它是“续命入口”,所以可以比普通业务接口更谨慎:
- 更严的限流
- 设备指纹或会话绑定
- 异常地域/IP 风险识别
- 可疑时要求重新登录
双 Token 解决了什么,没解决什么
解决了什么
- 把“高频暴露令牌”的生命周期缩短
- 减少用户频繁重新登录
- 让会话续期过程更可控
- 让风控、注销、设备管理更容易落地
没解决什么
- 不能自动防 XSS
- 如果 Access Token 放
localStorage,XSS 仍可直接读取
- 如果 Access Token 放
- 不能自动防 CSRF
- 如果 Refresh Token 放 Cookie,刷新接口仍要考虑 CSRF
- 不能自动支持服务端强制下线
- 如果系统完全无状态,只靠长 JWT,自然很难精细吊销
面试里最加分的一句话是:
- 双 Token 是会话管理方案,不是万能安全方案。
Refresh 接口为什么也要考虑 CSRF
这是一个很容易被忽略的边界。
如果 Refresh Token 放在 Cookie 中,浏览器访问刷新接口时会自动带上它。这意味着刷新接口本身也可能成为 CSRF 目标。
常见应对方式:
- Refresh Cookie 设置
SameSite=Strict或至少Lax - 刷新接口限制为同站使用
- 刷新接口额外校验 CSRF Token 或来源头
- Refresh Cookie 的
Path尽量收窄到刷新接口
例如:
Set-Cookie: refresh_token=xxx; HttpOnly; Secure; SameSite=Strict; Path=/auth/refresh
这里的 Path=/auth/refresh 不是绝对安全手段,但能减少不必要的发送范围。
常见追问
1. Access Token 放内存,页面一刷新不就丢了吗?
答:对,所以页面初始化时可以尝试静默调一次刷新接口,用 Cookie 里的 Refresh Token 重新换取 Access Token。这样既避免长期把 Access Token 落盘,又能保留登录体验。
2. 为什么不把 Refresh Token 也放内存?
答:页面刷新就丢了,用户体验会明显变差。而且它的主要诉求是“稳定续期”,更适合放在前端 JS 读不到的位置,比如 HttpOnly Cookie。
3. 为什么不只用一个很短的 token,加上频繁重新登录?
答:理论上可行,但体验通常不可接受。双 Token 的价值就在于把“重新登录”变成“后台续期”,只有续期失败才真正要求用户重新认证。
4. 双 Token 一定要用 JWT 吗?
答:不一定。Access Token 可以是 JWT,也可以是服务端保存的随机串;Refresh Token 也可以是随机串。重点不在 token 长什么样,而在生命周期、校验方式和吊销能力。
5. 如何处理用户主动退出登录?
答:前端清空内存里的 Access Token,服务端吊销当前 Refresh Token,并删除对应 Cookie。否则用户虽然前端“看起来退出了”,但刷新链路可能还活着。
6. 多标签页怎么同步登录状态?
答:可以结合 BroadcastChannel、storage 事件或应用级事件总线,把“刷新成功”“退出登录”“强制下线”等状态同步到其他标签页,避免各页令牌状态不一致。
易错点
- 把双 Token 理解成“两个都长期有效”。这会失去短期令牌的意义。
- 把 Refresh Token 直接放
localStorage。一旦 XSS,损失通常比 Access Token 泄露更重。 - 遇到
401就无限刷新重试。这样会制造死循环,正确做法是设置重试上限,并在刷新失败时明确退出登录。 - 不做并发收敛,导致一波接口同时过期时疯狂刷新。
- 刷新成功后不轮换 Refresh Token,给重放留下更大窗口。
- 只讲前端,不讲服务端吊销、轮换和会话状态。面试里这会显得答案过浅。
速记要点
- Access Token 短命,负责访问。
- Refresh Token 长一些,负责续期。
- 推荐:Access Token 放内存,Refresh Token 放
HttpOnly Cookie。 - 刷新链路要做并发收敛、轮换、吊销。
- 双 Token 提升的是会话管理质量,不是自动免疫所有安全问题。