鉴权:双 Token 机制
背景:为什么要“双 token”
- Access token:短生命周期(例如 5~15 分钟),用于访问业务 API。
- Refresh token:长生命周期(例如 7~30 天),用于在 access token 过期后换取新的 access token。
目标通常是:
- 访问令牌泄露后可快速失效(降低风险面)。
- 用户体验上尽量无感续期(不频繁登录)。
重点:为什么要把 Refresh Token Cookie 的 Path 限制为 /auth/refresh
浏览器是否携带 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”收窄成“刷新接口专用凭证”。
它具体减少了哪些暴露面
-
减少无意义的网络传输
业务请求通常远多于刷新请求。若使用
Path=/,Refresh Token 会随着每个同域请求发送;限制路径后,它只在少量刷新请求中出现。 -
降低日志、链路与代理层的意外暴露概率
Cookie 可能经过网关、反向代理、APM、调试日志或错误采集。虽然这些组件原则上不应记录敏感头,但“不发送”比“发送后要求所有环节正确脱敏”更稳妥。
-
缩小服务端可接触该凭证的接口范围
普通业务路由不会收到 Refresh Token,可降低错误中间件、误打印和无关处理逻辑接触长期凭证的机会。
-
明确凭证用途,便于安全审计
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 的撤销状态(防重放)。
典型流程(简化)
- 登录成功:
- 正常调用业务 API:
- access token 过期:
注意点 / 常见坑
- 同域名下的其它路径不会带 refresh token,但如果你的刷新接口路径设计得太宽(例如
Path=/),那 refresh token 会被所有请求携带,暴露面变大。 Domain、子域名策略要谨慎:避免 refresh token 被不必要的子域接触到。- 如果前端和 API 在不同子域(如
app.example.com与api.example.com),Cookie 的Domain与SameSite策略会更复杂,可能需要改成同站点架构或使用不同的 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,常见故障顺序是:
- Tab A 和 Tab B 同时收到
401。 - 两者都使用 Refresh Token
R1请求刷新。 - Tab A 成功,服务端将
R1轮换为R2,并使R1失效。 - Tab B 随后使用
R1;服务端把它识别为旧 Token 复用。 - 若复用检测会撤销整个 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 也放在
HttpOnlyCookie 中,只需广播“刷新完成”,无需广播 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 分钟)