粉笔说
46.98M · 2026-03-25
在现代 Web 应用中,用户登录后通常会获得一对 Token:
当 Access Token 过期时,理想状态是:前端自动用 Refresh Token 换取新 Token,并重试原请求——整个过程用户无感,页面不跳转、操作不中断。
但现实呢?
今天,我们就来彻底搞懂:如何真正实现“无感刷新”Token?为什么 90% 的实现都有致命缺陷?
// 千万别这么写!
fetch('/api/user')
.then(res => {
if (res.status === 401) {
// 重新登录 or 刷新 token?
window.location.href = '/login';
}
});
问题在哪?
这是目前最“主流”的错误方案:
// 伪代码:看似聪明,实则危险
axios.interceptors.response.use(
res => res,
async (error) => {
if (error.response.status === 401) {
const newToken = await refreshToken(); // 获取新 token
saveToken(newToken);
// 用新 token 重试原请求
return axios(error.config);
}
}
);
表面看没问题,但隐藏三大坑:
当页面刚加载,10 个接口同时发起,而此时 Token 已过期 ——
→ 10 个请求全部返回 401 → 触发 10 次 refreshToken() → 后端收到 10 个刷新请求!
后果:
如果前端把 Refresh Token 存在 localStorage,一旦 XSS 攻击成功,攻击者可长期盗用账号。
但上述方案要求前端“拿到新 token”,这就逼你把 Refresh Token 暴露给 JS —— 安全与功能不可兼得?
如果 refreshToken() 本身也返回 401(比如 Refresh Token 也过期了),
→ 重试原请求 → 又 401 → 再刷新 → 再 401 → ……
浏览器卡死,内存飙升。
要实现真正的无感刷新,必须同时解决:
HTTP/1.1 200 OK
Set-Cookie: refreshToken=abc123; HttpOnly; Secure; SameSite=Strict; Path=/auth
前端永远拿不到 refreshToken,但每次请求会自动携带。
let isRefreshing = false;
let refreshPromise = null;
const failedQueue = [];
// 重试队列中的请求
const processQueue = (error, token = null) => {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
} else {
resolve(token);
}
});
failedQueue.length = 0;
};
axios.interceptors.response.use(
response => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 已在刷新中,将请求加入队列,等待新 token
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(token => {
originalRequest.headers['Authorization'] = `Bearer ${token}`;
return axios(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// 调用刷新接口(后端从 Cookie 读 refreshToken)
const { data } = await axios.post('/auth/refresh');
const newAccessToken = data.accessToken;
// 通知所有排队的请求
processQueue(null, newAccessToken);
// 重试当前请求
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
return axios(originalRequest);
} catch (refreshError) {
// 刷新失败:清空本地身份,跳转登录
clearAuth();
processQueue(refreshError, null);
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
refreshPromise = null;
}
}
return Promise.reject(error);
}
);
| 机制 | 作用 |
|---|---|
isRefreshing 锁 | 确保同一时间只发起一次刷新 |
failedQueue 队列 | 缓存所有因 401 失败的请求,等新 token 到手后批量重试 |
_retry 标记 | 防止重试后的请求再次进入刷新逻辑 |
| HttpOnly Cookie | 保护 Refresh Token 不被 XSS 窃取 |
| Token 类型 | 推荐存储方式 | 原因 |
|---|---|---|
| Access Token | 内存(JS 变量)或 sessionStorage | 短期有效,避免持久化泄露 |
| Refresh Token | HttpOnly Cookie | 前端不可读,防 XSS |
/auth/refresh?“无感刷新 Token”不是炫技,而是对用户体验和系统安全的基本尊重。
那些让用户频繁重新登录的产品,不是技术做不到,而是没把用户当回事。
真正的专业,藏在细节里:
一个锁、一个队列、一个 HttpOnly Cookie —— 就是 10% 正确方案 与 90% 错误实现的分水岭。
欢迎转发给那个总说“Token 过期就让用户重新登录”的同事。
各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!