告别粗暴的401跳转:Token无感刷新完整解决方案
刷新Token不是“过期就重新登录”,而是让用户毫无感知地继续使用。
可惜,大多数项目还在用401跳登录粗暴处理——这根本不是用户体验,这是放弃治疗。
一、为什么需要无感刷新
在现代Web应用中,用户登录后通常会获得一对Token:
Access Token(短期有效,如15分钟)
Refresh Token(长期有效,如7天)
当Access Token过期时,理想状态是:前端自动用Refresh Token换取新Token,并重试原请求——整个过程用户无感,页面不跳转、操作不中断。
但现实呢?
“Token过期 → 弹出登录框 → 用户骂一句‘怎么又登出了’ → 关掉页面走人。”
今天,我们就来彻底搞懂:如何真正实现“无感刷新”Token?为什么90%的实现都有致命缺陷?
二、错误做法一:在每个接口里手动判断401
// ❌ 千万别这么写!
fetch('/api/user')
.then(res => {
if (res.status === 401) {
window.location.href = '/login';
}
});问题在哪?
每个接口都要重复写逻辑
如果多个请求同时401,会触发多次刷新,甚至多次跳登录
完全无法做到“无感”
三、错误做法二:全局拦截401后直接刷新Token并重试一次
这是目前最“主流”的错误方案:
// ❌ 伪代码:看似聪明,实则危险
axios.interceptors.response.use(
res => res,
async (error) => {
if (error.response.status === 401) {
const newToken = await refreshToken();
saveToken(newToken);
return axios(error.config);
}
}
);表面看没问题,但隐藏三大坑。
坑1:并发请求雪崩
当页面刚加载,10个接口同时发起,而此时Token已过期:
→ 10个请求全部返回401 → 触发10次refreshToken() → 后端收到10个刷新请求
后果:
后端可能拒绝重复刷新(安全策略)
Refresh Token被提前消耗,后续真失效
用户反而被踢下线
坑2:Refresh Token泄露风险
如果前端把Refresh Token存在localStorage,一旦XSS攻击成功,攻击者可长期盗用账号。
安全最佳实践:Refresh Token应仅存于HttpOnly Cookie,前端不可读。
但上述方案要求前端“拿到新token”,这就逼你把Refresh Token暴露给JS——安全与功能不可兼得。
坑3:无限重试死循环
如果refreshToken()本身也返回401(比如Refresh Token也过期了):
→ 重试原请求 → 又401 → 再刷新 → 再401 → …… 浏览器卡死,内存飙升。
四、正确方式:用“锁机制 + 队列 + 安全存储”三位一体
要实现真正的无感刷新,必须同时解决:
并发控制(只刷一次)
安全存储(Refresh Token不暴露给JS)
失败兜底(Refresh失败时优雅降级)
第一步:后端配合 —— Refresh Token存HttpOnly Cookie
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存储建议
| Token类型 | 推荐存储方式 | 原因 |
|---|---|---|
| Access Token | 内存(JS变量)或sessionStorage | 短期有效,避免持久化泄露 |
| Refresh Token | HttpOnly Cookie | 前端不可读,防XSS |
切勿将任何Token存入localStorage! 这是XSS攻击的黄金目标。
六、如何测试你的刷新逻辑
手动将Access Token设为过期
快速点击多个按钮,触发并发请求
观察Network面板:
是否只调用了一次/auth/refresh?
所有原请求是否最终成功?
模拟Refresh Token失效,是否跳转登录?
七、结语
“无感刷新Token”不是炫技,而是对用户体验和系统安全的基本尊重。
那些让用户频繁重新登录的产品,不是技术做不到,而是没把用户当回事。
真正的专业,藏在细节里:一个锁、一个队列、一个HttpOnly Cookie——这就是10%正确方案与90%错误实现的分水岭。
你的项目还在用“401就跳登录”吗?是时候升级了。
本文内容仅供个人学习、研究或参考使用,不构成任何形式的决策建议、专业指导或法律依据。未经授权,禁止任何单位或个人以商业售卖、虚假宣传、侵权传播等非学习研究目的使用本文内容。如需分享或转载,请保留原文来源信息,不得篡改、删减内容或侵犯相关权益。感谢您的理解与支持!