跳到主要内容

双 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. 多标签页怎么同步登录状态?​

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


易错点​

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

速记要点​

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