做全栈这五年里,但凡涉及中后台系统或移动端 Web,身份认证(Authentication)总是一个绕不开的话题。
很多刚接触前后端分离的同学,做 Token 刷新时逻辑往往很简单:前端调接口,报 401 了就跳登录页。这种做法最稳妥,开发成本最低,但对用户最不友好——填表单填到一半,Token 过期,一点提交,数据全丢,直接回到登录框。
为了体验,我们通常会引入「无感刷新」(Silent Refresh)。核心逻辑听起来轻巧:一个短期的 Access Token,一个长期的 Refresh Token;Access Token 没了,暗地里用 Refresh Token 换一个新的,用户感知不到。
但这事一旦落地到生产环境,并发请求拦截和前后端状态同步往往是踩坑重灾区。今天聊聊我们在全栈视角下,如何做取舍和落地方案。
一、 存储位置的权衡:安全性与便利性
在写并发控制前,先得定好 Token 放哪。这里没有最优解,只有取舍:
- 方案 A:Access Token 与 Refresh Token 都存 LocalStorage
- 优点:前端完全掌控,开发极其顺手,移动端混合开发也方便。
- 缺点:XSS 面前毫无防线,脚本一旦注入,凭证直接被打包带走。
- 方案 B:Access Token 放内存(Pinia / Vuex / Redux),Refresh Token 放 HttpOnly Cookie
- 优点:安全性最高。即使页面有 XSS,黑客拿不到 Refresh Token;刷新页面虽然内存清空,但可以通过 Cookie 自动走一次无感刷新还原登录态。
- 缺点:跨域处理繁琐,后端需要配置
SameSite和CORS凭证支持,服务端渲染(SSR)下还需要处理 Cookie 透传。
我们团队目前的折中方案是 方案 B。安全是底线,把长效凭证交给浏览器底层的 Cookie 机制保护,多写几行网络配置是划算的。
二、 前端核心痛点:并发请求的「惊群效应」
假设页面的 Dashboard 初始化,并发调用了 4 个接口:获取用户信息、获取未读消息、获取权限列表、获取业务数据。
此时 Access Token 恰好过期。这 4 个请求几乎同时发出,也几乎同时收到 401 响应。
如果不做控制,前端会连续发出 4 次刷新 Token 的请求。这不仅给鉴权服务徒增压力,如果后端开启了 Refresh Token Rotation(每次刷新废弃旧的 Refresh Token),第 2、3、4 次刷新请求甚至会被后端判定为「凭证重放攻击」,直接强制登出。
解决思路:Promise 队列 + 拦截挂起
我们需要在 Axios(或其他网络库)的响应拦截器里立一把「锁」。当检测到第一个 401 时,锁上门,去调刷新接口;在此期间到来的后续 401 请求,通通推入一个队列里挂起,等待新 Token 拿到后,挨个重新发请求。
示例代码实现如下:
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
// 基础实例
const apiClient = axios.create({
baseURL: '/api',
withCredentials: true // 允许携带 HttpOnly Cookie
});
// 状态标识与队列
let isRefreshing = false;
let retryQueue: Array<(token: string) => void> = [];
// 内存中的 Access Token
let memoryAccessToken: string | null = null;
export const setAccessToken = (token: string | null) => {
memoryAccessToken = token;
};
// 请求拦截器:带上内存里的 Token
apiClient.interceptors.request.use((config) => {
if (memoryAccessToken && config.headers) {
config.headers.Authorization = `Bearer ${memoryAccessToken}`;
}
return config;
});
// 响应拦截器:处理无感刷新
apiClient.interceptors.response.use(
(response: AxiosResponse) => response,
async (error) => {
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
// 只拦截 401 且未重试过的请求,同时排除刷新接口本身
if (error.response?.status === 401 && !originalRequest._retry && originalRequest.url !== '/auth/refresh') {
if (isRefreshing) {
// 如果正在刷新,返回一个挂起的 Promise,并把 resolve 逻辑推进队列
return new Promise((resolve) => {
retryQueue.push((newToken: string) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
}
resolve(apiClient(originalRequest));
});
});
}
// 标记当前请求已重试,开启刷新锁
originalRequest._retry = true;
isRefreshing = true;
try {
// 请求刷新接口,Refresh Token 由浏览器通过 HttpOnly Cookie 自动携带
const { data } = await apiClient.post('/auth/refresh');
const newAccessToken = data.accessToken;
setAccessToken(newAccessToken);
// 消化队列里的并发请求
retryQueue.forEach((callback) => callback(newAccessToken));
retryQueue = [];
// 重新发起当前触发 401 的请求
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
}
return apiClient(originalRequest);
} catch (refreshError) {
// 刷新彻底失败(Refresh Token 也过期了或被吊销)
retryQueue = [];
setAccessToken(null);
// 清理现场,跳回登录页
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
这段逻辑把并发的处理转变成了一个异步队列。用户侧完全无感,控制台可能会闪现几个 401,但紧接着都会被正确的结果覆盖。
三、 后端协同避坑:Token 轮转与微小容错
很多全栈开发者在前端把队列写完美了,发现线上偶尔还是会偶发性退登。问题往往出在后端。
现代认证体系推荐使用 Refresh Token Rotation (RTR):每次客户端调用 /auth/refresh,后端颁发新的 Access Token 同时,也作废旧的 Refresh Token,并返回一个新的 Refresh Token。
理想很丰满,但网络环境不是真空。考虑一种极端情况: 前端正在发起刷新,由于弱网,请求超时了,前端判定失败或者用户手动点了一下刷新页面;但实际上请求已经到了后端,后端把旧的 Refresh Token 删了。下一次请求过来,拿着已经失效的凭据,就直接触发了安全策略,导致用户被踢下线。
后端的解法是引入「宽限期」(Grace Period):
- Redis 标记而不是物理删除:当一个 Refresh Token 被使用后,不要立刻从 Redis 删掉,而是将它标记为
expired,并赋予一个很短的生存时间(比如 15 秒到 30 秒)。 - 安全窗口:在这 30 秒内,如果后端又收到了这个已经作废的 Refresh Token,后端不报错,而是直接返回刚刚已经生成的新 Token 对(或者允许它继续完成这次刷新)。
- 熔断兜底:如果超过宽限期还有人拿着这个旧 Token 来刷新,说明凭证大概率遭到了窃取重放,此时后端应该立即把该用户的所有 Refresh Token 全部拉黑,强制全端下线。
这种前后端配合的方式,用 30 秒的安全窗口换取了系统在复杂网络抖动下的极强韧性。
四、 避无可避的边界情况
做工程就是做容错,还有两个边界细节需要考虑:
- 多标签页同步:
如果用户开了五个浏览器标签页,Tab A 触发了刷新,此时 Tab B 内存里的 Access Token 还是旧的。虽然 Tab B 发请求时依然能靠 401 流程再次刷新,但更好的方式是用
BroadcastChannel或window.addEventListener('storage', ...)在标签页间同步刷新动作,避免多标签页重复打刷新接口。 - 刷新死循环:
务必确保
/auth/refresh接口本身的 401 响应不会再触发刷新逻辑(示例代码中已做过滤)。一旦死循环,短时间内就是成百上千次网络请求,轻则浏览器卡死,重则被后端 WAF 直接封禁 IP。
五、 总结
无感刷新表面上是一个 HTTP 状态码的重试机制,本质上是前后端关于状态机同步与安全模型的协议设计。
- 前端用 拦截器 + Promise 挂起队列 解决客户端内部的并发竞争。
- 后端用 短期宽限期(Grace Period) 解决分布式网络不可靠导致的时序颠倒。
把这两块拼图拼完整,整个认证体系才算兼顾了安全与顺滑。技术选型没有银弹,如果业务本身安全性要求不高,内部系统甚至可以直接调大 Token 有效期;但一旦要做无感刷新,这套前后端协同的防抖与容错机制就必须成套落地。
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


