跳到主要内容

鉴权:双 Token 机制

背景:为什么要“双 token”

  • Access token:短生命周期(例如 5~15 分钟),用于访问业务 API。
  • Refresh token:长生命周期(例如 7~30 天),用于在 access token 过期后换取新的 access token。

目标通常是:

  • 访问令牌泄露后可快速失效(降低风险面)。
  • 用户体验上尽量无感续期(不频繁登录)。

浏览器是否携带 Cookie,不只取决于域名,还会进行路径匹配。把 Refresh Token 设置为:

Set-Cookie: refresh_token=...; Path=/auth/refresh; HttpOnly; Secure; SameSite=Lax

之后,浏览器只会在请求路径匹配 /auth/refresh 时自动附带它,例如:

  • /auth/refresh:携带
  • /auth/refresh/rotate:携带
  • /api/orders:不携带
  • /api/profile:不携带
  • /auth/login:不携带

这相当于把 Refresh Token 从“全站通用 Cookie”收窄成“刷新接口专用凭证”。

它具体减少了哪些暴露面

  1. 减少无意义的网络传输

    业务请求通常远多于刷新请求。若使用 Path=/,Refresh Token 会随着每个同域请求发送;限制路径后,它只在少量刷新请求中出现。

  2. 降低日志、链路与代理层的意外暴露概率

    Cookie 可能经过网关、反向代理、APM、调试日志或错误采集。虽然这些组件原则上不应记录敏感头,但“不发送”比“发送后要求所有环节正确脱敏”更稳妥。

  3. 缩小服务端可接触该凭证的接口范围

    普通业务路由不会收到 Refresh Token,可降低错误中间件、误打印和无关处理逻辑接触长期凭证的机会。

  4. 明确凭证用途,便于安全审计

    Refresh Token 只出现在刷新端点,服务端可以对该端点单独实施限流、Rotation、复用检测和审计。

Path 不是安全边界

  • Path 只控制浏览器在什么请求中自动携带 Cookie,不能阻止攻击者向 /auth/refresh 发请求。
  • XSS 即使因为 HttpOnly 无法读取 Refresh Token,仍可能调用刷新接口,并利用返回的 Access Token。因此仍需 CSP、输入输出转义等 XSS 防护。
  • CSRF 防护仍依赖 SameSite、Origin/Referer 校验或 CSRF Token,不能由 Path 替代。
  • 服务端仍必须校验 Refresh Token 的签名、过期时间、撤销状态、客户端信息与 token family 状态。
  • Cookie 名不要使用要求 Path=/__Host- 前缀;可考虑 __Secure-refresh_token,并确保设置 Secure

路径设计建议

刷新接口最好使用一个专用且稳定的精确路径,例如 POST /auth/refresh,Cookie 同样设置为 Path=/auth/refresh。避免把路径设得过宽,例如 /auth/;也避免在这个路径前缀下承载不相关接口。

HttpOnly / Secure / SameSite 的配合

仅靠 Path 不是安全边界(只是“减少暴露面/减少携带”),通常会组合:

  • HttpOnly:防止 JS 直接读取 refresh token(降低 XSS 直接窃取的概率)。
  • Secure:仅 HTTPS 传输。
  • SameSite=Lax/Strict(或必要时 None; Secure):降低 CSRF 风险(取决于是否需要跨站)。
  • 服务器端绑定/轮换 refresh token:例如 refresh token 一次性使用、每次刷新都轮换(rotation),并保存旧 token 的撤销状态(防重放)。

典型流程(简化)

  1. 登录成功:
  2. 正常调用业务 API:
  3. access token 过期:

注意点 / 常见坑

  • 同域名下的其它路径不会带 refresh token,但如果你的刷新接口路径设计得太宽(例如 Path=/),那 refresh token 会被所有请求携带,暴露面变大。
  • Domain、子域名策略要谨慎:避免 refresh token 被不必要的子域接触到。
  • 如果前端和 API 在不同子域(如 app.example.comapi.example.com),Cookie 的 DomainSameSite 策略会更复杂,可能需要改成同站点架构或使用不同的 token 存储/传递方式。

双 token 机制:通过设置 refresh token 的 Cookie Path,降低其可见性,避免每次请求都自动携带。


前端实现代码

Axios 拦截器自动刷新

import axios from "axios";

const api = axios.create({ baseURL: "/api" });
let isRefreshing = false;
let failedQueue = [];

function processQueue(error, token = null) {
failedQueue.forEach(({ resolve, reject }) => {
error ? reject(error) : resolve(token);
});
failedQueue = [];
}

api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = "Bearer " + token;
return api(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post("/auth/refresh");
const newToken = data.accessToken;
processQueue(null, newToken);
originalRequest.headers.Authorization = "Bearer " + newToken;
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError);
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);

💡 关键:用队列 failedQueue 收集并发请求,避免同时发起多个 refresh 请求。刷新完成后统一用新 token 重试。

多 Tab 同时 Refresh 的竞态

单个页面里的 isRefreshing + failedQueue 只能协调当前 JavaScript 上下文,无法协调多个 Tab。每个 Tab 都有自己的变量,因此 Access Token 同时过期时,多个 Tab 可能携带同一个 Refresh Token 并发刷新。

如果服务端启用了 Refresh Token Rotation,常见故障顺序是:

  1. Tab A 和 Tab B 同时收到 401
  2. 两者都使用 Refresh Token R1 请求刷新。
  3. Tab A 成功,服务端将 R1 轮换为 R2,并使 R1 失效。
  4. Tab B 随后使用 R1;服务端把它识别为旧 Token 复用。
  5. 若复用检测会撤销整个 token family,Tab A 刚拿到的 R2 也可能失效,最终所有 Tab 被登出。

推荐方案:Web Locks + BroadcastChannel

  • Web Locks API:同一浏览器、同一源下只允许一个 Tab 进入 refresh 临界区。
  • BroadcastChannel:刷新成功后,把新的 Access Token 或“刷新已完成”的状态广播给其他 Tab。
  • 二次检查:等待锁的 Tab 获得锁后,必须先检查别的 Tab 是否已经刷新成功;不能无条件再刷新一次。
const authChannel = new BroadcastChannel("auth");
let accessToken: string | null = null;
let tokenVersion = 0;

function applyToken(token: string, version: number) {
if (version <= tokenVersion) return;
accessToken = token;
tokenVersion = version;
}

authChannel.onmessage = ({ data }) => {
if (data.type === "TOKEN_REFRESHED") {
applyToken(data.accessToken, data.version);
}
if (data.type === "LOGOUT") {
accessToken = null;
window.location.href = "/login";
}
};

async function refreshAcrossTabs() {
const observedVersion = tokenVersion;

return navigator.locks.request("auth-refresh", async () => {
// 等锁期间,其他 Tab 可能已经刷新并广播了新 Token。
if (tokenVersion > observedVersion && accessToken) {
return accessToken;
}

const response = await fetch("/auth/refresh", {
method: "POST",
credentials: "include",
});

if (!response.ok) {
authChannel.postMessage({ type: "LOGOUT" });
throw new Error("Refresh failed");
}

const { accessToken: nextToken } = await response.json();
const nextVersion = Date.now();
applyToken(nextToken, nextVersion);
authChannel.postMessage({
type: "TOKEN_REFRESHED",
accessToken: nextToken,
version: nextVersion,
});
return nextToken;
});
}

Axios 拦截器收到 401 后调用 refreshAcrossTabs(),再用返回的新 Access Token 重试原请求。每个 Tab 内仍可保留 failedQueue,用于合并当前 Tab 的并发请求;跨 Tab 则交给 Web Locks。

实现时的关键细节

  • Refresh 请求本身必须排除拦截器重试,否则可能无限递归。
  • 重试原请求最多一次,并用 _retry 标记。
  • 只有明确的认证失败(Refresh Token 过期、撤销或复用)才广播登出;网络超时和 5xx 不应立即让所有 Tab 退出。
  • Access Token 建议保存在内存中。若通过 BroadcastChannel 广播,它会短暂出现在其他 Tab 的 JS 内存中;在同源 XSS 威胁下,内存 Token 本来也可能被读取,因此核心仍是 XSS 防护。
  • 如果 Access Token 也放在 HttpOnly Cookie 中,只需广播“刷新完成”,无需广播 Token 本身。
  • 不支持 Web Locks 时,可用 SharedWorker/Service Worker 统一刷新。localStorage 的“锁 + 过期时间”只能作为退化方案,因为读写不是严格原子的,还要处理 Tab 崩溃和锁超时。

服务端仍需兜底

客户端互斥只能减少正常竞态,不能替代服务端安全设计:

  • Refresh Token Rotation 与 token family 复用检测必须保留。
  • 可为同一个 R1 的极短时间并发提供受控幂等窗口:返回同一轮换结果,而不是生成多个子 Token;窗口要短,并绑定会话/设备上下文。
  • 刷新端点应限流、记录审计事件,并区分正常并发、网络重试与真实复用攻击。

安全加固清单

  • Refresh Token 存 HttpOnly Cookie,JS 无法读取
  • Cookie Path 限制为 /auth/refresh,减少暴露面
  • Token Rotation:每次刷新后旧 token 立即失效
  • 服务端维护 token 黑名单(Redis)
  • 检测复用攻击:旧 refresh token 被使用时,撤销整个 token family
  • Access Token 有效期尽量短(5-15 分钟)