Refresh Token 实战指南:解决前端 token 过期自动续签难题
Refresh Token自动续期,结果登录态莫名其妙丢了
今天上线前测登录流程,发现用户明明没点退出、也没关页面,过15分钟再操作一下接口,直接401跳回登录页——但控制台里明明刚刷新过token,Network里也能看到refresh请求返回了新access_token和新refresh_token,状态码200,响应体也正常。我盯着DevTools看了三分钟,喝了一口已经凉透的咖啡,心想:这玩意儿它不讲武德。
先说结论,核心就三件事
不是后端没返回新refresh_token,也不是前端没存,是我在调用refresh接口时,把旧的refresh_token塞进了请求头Authorization,而服务端校验逻辑写死了只认Bearer开头的token(也就是access_token),导致refresh请求本身被中间件拦下来返回401,但我在前端居然还把它当成功处理了……
这里我踩了个坑:以为只要fetch返回200就是成功,压根没看response.statusText或业务code字段。后端返回的是{"code":401,"msg":"Invalid token type"},但我只判断了res.ok,然后直接解构data,拿到空对象,再存进localStorage——于是新token全丢了,下次请求继续用过期的access_token,自然401滚雪球。
折腾了半天发现,问题根本不在续期逻辑,而在“怎么发起续期请求”
一开始我以为是并发问题:比如两个tab同时到期,都去refresh,后发的那个把前一个覆盖了,导致其中一个tab拿着过期的access_token还在用。试了加锁、加时间戳防重、甚至本地存version做乐观锁……全没用。后来加了一堆console.log,发现关键日志根本没打出来——refresh函数压根没执行完就结束了?
查了一下,原来我用了async/await包装refresh,但在拦截器里直接return了Promise.resolve(),没等refresh完成就放行了原请求,结果原请求带着过期token发出去,401,触发二次refresh,无限套娃……
后来试了下发现,axios的request interceptor里不能直接return一个还没resolve的Promise,得用await或者.then()链式接管。我之前写的伪代码长这样:
// ❌ 错误示范:没等refresh完成就放行
instance.interceptors.request.use(config => {
if (isAccessTokenExpired()) {
refreshToken().then(newToken => {
config.headers.Authorization = Bearer ${newToken};
return config; // 这里return没用!config已经发出去了
});
}
return config;
});
这代码看着像那么回事,实际就是个摆设。config早就被发出去了,你后面改header纯属自嗨。
核心代码就这几行(亲测有效,已上线三天没再丢登录态)
我把refresh逻辑单独抽成一个带锁的函数,所有需要续期的地方统一走这个入口,而且强制串行。关键点有三个:
- 用一个Promise变量缓存当前正在进行的refresh请求,避免并发
- 每次refresh成功后,原子化更新localStorage里的access_token和refresh_token
- 在请求拦截器里,对401响应统一捕获,触发refresh,然后重放原请求(注意:是重放,不是retry)
下面是最终落地的refresh逻辑(基于axios):
let refreshPromise = null;
async function refreshToken() {
if (refreshPromise) {
return refreshPromise;
}
const storedRefreshToken = localStorage.getItem('refresh_token');
if (!storedRefreshToken) {
throw new Error('No refresh token found');
}
refreshPromise = fetch('https://jztheme.com/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 注意!这里不用Authorization,而是用专门的refresh_token字段
'X-Refresh-Token': storedRefreshToken,
},
})
.then(async (res) => {
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.msg || Refresh failed: ${res.status});
}
return res.json();
})
.then((data) => {
if (!data.access_token || !data.refresh_token) {
throw new Error('Missing tokens in refresh response');
}
// 原子更新
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
return data.access_token;
})
.finally(() => {
refreshPromise = null;
});
return refreshPromise;
}
然后是axios拦截器部分:
instance.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newAccessToken = await refreshToken();
originalRequest.headers.Authorization = Bearer ${newAccessToken};
return instance(originalRequest); // 重放原请求
} catch (refreshError) {
// 刷新失败,清空登录态
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
这里注意两点:
originalRequest._retry = true是防死循环的关键,不然401重放后还是401,一直递归下去- refresh接口我让后端开了个专用header
X-Refresh-Token,彻底和access_token的Bearer机制解耦,再也不怕类型校验翻车
踩坑提醒:这三点一定注意
第一,别信res.ok。后端只要返回HTTP 200,哪怕body里写着{“code”:500},res.ok也是true。一定要读body,校验业务code字段。
第二,localStorage更新必须原子。我之前是先存access_token,再存refresh_token,中间如果页面崩溃,就会出现access_token新、refresh_token旧的情况,下次refresh又会失败。现在改成一次set两个,靠JS单线程特性保证顺序,够用了。
第三,不要在拦截器里手动拼接headers。axios的config.headers是引用对象,你直接改它没问题,但别搞错作用域——我最初把originalRequest.headers.Authorization写成config.headers.Authorization,结果在response拦截器里改的是另一个config副本,白忙活。
最后说个小遗憾
目前这套方案在Safari里偶尔会遇到refresh请求被静默取消(network面板显示cancelled),查了下是iOS Safari对background tab的fetch有节流策略。暂时没完美解法,我的妥协是:加个3秒超时重试,最多试两次,再失败就跳登录页。不影响主流程,只是小概率多点一次登录——反正用户也不在乎多输一次密码,只要别动不动就登出就行。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案,比如用service worker接管refresh请求、或者用IndexedDB替代localStorage做token持久化,欢迎评论区交流。

暂无评论