跳到主要内容

双 Token 策略

面试速答

  • 这里说的双 Token 策略,通常指:
    • Access Token:短期令牌,负责访问业务接口
    • Refresh Token:长期令牌,负责在 Access Token 过期后换新
  • 设计目标不是“更炫”,而是解决两个现实矛盾:
    • 令牌有效期太长,泄露后风险大
    • 令牌有效期太短,用户频繁掉登录
  • 核心思路:让高频使用的令牌短命,让低频使用的令牌专职续期,并把续期动作收口到更少的接口上。
  • 常见推荐:
    • Access Token 放内存,请求时放 Authorization
    • Refresh Token 尽量放 HttpOnly + Secure Cookie
    • 刷新接口做轮换、失效控制、并发收敛

先回答“为什么要双 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 写入 HttpOnly Cookie

示例:

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 TokenHttpOnly Cookie前端 JS 读不到,适合自动带到刷新接口需要注意 CSRF、防重放与轮换

为什么不推荐把两个 token 都塞进 localStorage

因为这会把安全边界拉低到“只要出现 XSS,两个令牌都可被直接拿走”。尤其是 Refresh Token 一旦被拿走,攻击者就不只是“趁你当前会话作恶”,而是可能长期续期。

如果业务接口也依赖 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 仍可直接读取
  • 不能自动防 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. 多标签页怎么同步登录状态?

:可以结合 BroadcastChannelstorage 事件或应用级事件总线,把“刷新成功”“退出登录”“强制下线”等状态同步到其他标签页,避免各页令牌状态不一致。


易错点

  • 把双 Token 理解成“两个都长期有效”。这会失去短期令牌的意义。
  • 把 Refresh Token 直接放 localStorage。一旦 XSS,损失通常比 Access Token 泄露更重。
  • 遇到 401 就无限刷新重试。这样会制造死循环,正确做法是设置重试上限,并在刷新失败时明确退出登录。
  • 不做并发收敛,导致一波接口同时过期时疯狂刷新。
  • 刷新成功后不轮换 Refresh Token,给重放留下更大窗口。
  • 只讲前端,不讲服务端吊销、轮换和会话状态。面试里这会显得答案过浅。

速记要点

  • Access Token 短命,负责访问。
  • Refresh Token 长一些,负责续期。
  • 推荐:Access Token 放内存,Refresh Token 放 HttpOnly Cookie
  • 刷新链路要做并发收敛、轮换、吊销。
  • 双 Token 提升的是会话管理质量,不是自动免疫所有安全问题。