这套方案既保障了接口访问的安全性,又实现了用户完全无感知的登录续期体验,是当前前后端分离架构中 Token 管理的标准实践。在实际落地时,建议结合 Refresh Token 轮换、令牌吊销等安全措施,构建完整的企业级身份认证体系。
一、为什么需要无感刷新
在前后端分离架构中,基于 Token 的 JWT(JSON Web Token)鉴权是目前最主流的身份认证方案。然而,传统单 Token 模式存在一个明显的固有矛盾:
- 若 Token 有效期设置较短(如 15~30 分钟),用户正常操作中会频繁遭遇 Token 过期被强制下线,严重影响使用体验;
- 若 Token 有效期设置较长(如 7 天甚至更久),一旦令牌被盗,攻击者可在长时间内冒用身份,存在极高的安全漏洞。
无感刷新的核心价值在于:通过引入双 Token 机制,将安全性与用户体验这对矛盾彻底解耦------用短期令牌保障接口安全,用长期令牌负责身份续期,在用户完全无感知的前提下自动静默续期登录状态,实现"永久在线"的流畅体验。
二、双 Token 机制原理
2.1 核心设计思想
双 Token 机制借鉴了 OAuth 2.0 的令牌管理思想,将登录凭证拆分为两种职责不同的令牌,各司其职、互不干扰:
| 令牌类型 | 用途 | 建议有效期 | 特点 |
|---|---|---|---|
| Access Token(访问令牌) | 业务接口鉴权 | 15~60 分钟 | 短时效,降低泄露风险窗口 |
| Refresh Token(刷新令牌) | 仅用于刷新 Access Token | 7~30 天 | 长时效,负责身份续期,不参与业务请求 |
Access Token 一般放在 HTTP 请求的 Authorization 头部(Bearer Token),每次请求业务接口时都会携带。由于 JWT 天然支持分布式------用户信息直接编码在 Token 中,服务端无需依赖会话存储即可解析验证,因此在微服务和分布式系统中被广泛采用。
Refresh Token 则承担"免密续签"的职责。有了它之后,客户端不需要让用户重新输入用户名密码就能获取新的 Access Token,只要 Refresh Token 在有效期内,用户就无需重新登录。
2.2 完整工作流程
整套无感刷新的运行机制遵循以下闭环逻辑:
- 登录阶段:用户登录成功后,服务端同时返回 Access Token 和 Refresh Token,前端将其持久化存储;
- 正常请求:所有业务请求自动携带 Access Token 完成鉴权;
- Token 过期 :Access Token 失效后,接口返回
401 Unauthorized状态码; - 自动刷新 :前端拦截
401错误,自动调用刷新接口,携带 Refresh Token 申请新的 Access Token; - 请求重试:刷新成功,更新本地双令牌,自动重试之前失败的业务请求,用户操作全程无感知;
- 降级处理:若刷新失败(Refresh Token 也过期),清空本地缓存,跳转登录页引导用户重新授权。
三、前端核心实现(Axios 拦截器方案)
3.1 实现方案对比
在 Axios 中实现无感刷新,主要有两种思路:
方案一:请求前拦截(预判过期) 。在发送请求前,根据本地存储的 expires_in 判断 Token 是否即将过期,如果已过期则先刷新再发送请求。优点是理论上可节省一次失败的请求,但缺点明显------依赖客户端本地时间,若用户篡改系统时间可能导致判断错误,实现也相对复杂。
方案二:响应后拦截(失败重试) 。先正常发送请求,如果后端返回 401,再触发 Token 刷新流程,刷新成功后用新 Token 重试原请求。优点是逻辑清晰可靠,不依赖客户端时间,是更通用的生产实践。本文及业界主流方案均采用此思路。
3.2 登录逻辑:双令牌获取与持久化
登录成功后,服务端会返回一对令牌,前端需要安全地将它们存储到本地:
javascript
const login = async () => {
const { data } = await userLogin({
username: 'user',
password: 'pass'
});
// 持久化双令牌
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
};
⚠️ 安全提示 :生产环境中,Refresh Token 建议存储在
httpOnlyCookie 中以防止 XSS 攻击窃取,Access Token 可存储在内存或localStorage中。
3.3 请求拦截器:统一挂载鉴权头部
创建 Axios 实例,并通过请求拦截器自动为每个请求添加 Bearer Token:
javascript
import axios from 'axios';
const apiClient = axios.create({
baseURL: '/api',
timeout: 30000,
headers: { 'Content-Type': 'application/json' }
});
apiClient.interceptors.request.use(config => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
这一步确保了所有业务请求都能自动携带最新的 Access Token,无需在每个接口调用处手动添加。
3.4 响应拦截器:核心刷新逻辑
这是整个无感刷新机制中最关键的部分,需要同时解决三个核心挑战:
- 避免重复刷新 :多个并发请求同时返回
401,只应触发一次刷新; - 请求排队与重试 :刷新期间到达的其他
401请求需要"排队等待",拿到新 Token 后逐一重试; - 防止死循环 :刷新接口本身返回
401时(Refresh Token 也过期),不应再尝试刷新。
以下为生产级完整实现:
javascript
// ── 全局状态管理 ──
let isRefreshing = false; // 刷新锁:控制同一时间仅执行一次刷新
let requestQueue = []; // 请求队列:存储刷新期间失败的业务请求
// ── 响应拦截器 ──
apiClient.interceptors.response.use(
// 正常响应直接透传
res => res,
// 统一处理异常响应
async error => {
const { response, config } = error || {};
const status = response?.status;
const isRefreshApi = config.url === '/refresh';
// 非 401 错误或刷新接口自身报错,直接抛出,不做刷新处理
if (status !== 401 || isRefreshApi) {
return Promise.reject(error);
}
// ── 若当前没有正在进行的刷新操作 ──
if (!isRefreshing) {
isRefreshing = true;
try {
// 调用刷新接口
const refreshToken = localStorage.getItem('refresh_token');
const { data } = await axios.post('/auth/refresh', {
refresh_token: refreshToken
});
// 更新本地存储的双令牌
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
// 更新当前请求的 Authorization 头并重试
config.headers.Authorization = `Bearer ${data.access_token}`;
// 逐一重试队列中所有等待的请求
requestQueue.forEach(callback => callback(data.access_token));
requestQueue = [];
return apiClient(config);
} catch (refreshError) {
// Refresh Token 也过期,清空缓存并跳转登录页
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
// ── 若已有刷新正在进行中 ──
// 将当前失败请求放入队列,等待刷新完成后再重试
return new Promise(resolve => {
requestQueue.push(newToken => {
config.headers.Authorization = `Bearer ${newToken}`;
resolve(apiClient(config));
});
});
}
);
这段实现参考了业界成熟的 Promise 队列方案,其核心思路是通过 isRefreshing 锁和 requestQueue 请求队列,将并发的 401 请求"串行化"为一次刷新、一次重试。
3.5 并发请求处理的完整流程解析
假如同一个页面同时发起了 5 个接口请求,此时 Access Token 恰好过期,完整处理时序如下:
yaml
请求 ① ─→ 401 ─→ isRefreshing: false ─→ 触发 refreshToken()
请求 ② ─→ 401 ─→ isRefreshing: true ─→ 推入 requestQueue(排队)
请求 ③ ─→ 401 ─→ isRefreshing: true ─→ 推入 requestQueue(排队)
请求 ④ ─→ 401 ─→ isRefreshing: true ─→ 推入 requestQueue(排队)
请求 ⑤ ─→ 401 ─→ isRefreshing: true ─→ 推入 requestQueue(排队)
刷新完成 ─→ 更新 Token ─→ 遍历 requestQueue 逐一重试
└→ 所有请求用新 Token 执行完毕
这样无论同时有多少个请求返回 401,刷新接口只会被调用一次,成功后将队列中的所有请求用新 Token 重新发送,用户在页面上完全感受不到任何中断。
四、关键边界问题与解决方案
4.1 防止死循环
刷新 Token 接口(/auth/refresh)本身也可能返回 401------这意味着 Refresh Token 也已过期。此时必须直接终止流程、引导用户重新登录,否则会陷入"请求刷新 → 返回 401 → 再次请求刷新"的死循环。解决方案是在响应拦截器中增加判断:若当前请求的 URL 就是刷新接口,则直接抛出错误,跳过刷新逻辑。
javascript
const isRefreshApi = config.url === '/refresh';
if (status !== 401 || isRefreshApi) {
return Promise.reject(error);
}
4.2 统一错误提示
当 Refresh Token 过期导致多个并发请求同时失败时,如果每个失败的请求都弹出一个错误提示,用户会看到多个重复的报错弹窗,体验极差。推荐方案是通过请求队列管理器统一处理失败场景,只显示一次友好的提示信息,然后跳转登录页。
4.3 独立刷新实例
建议为刷新 Token 的请求创建一个独立的 Axios 实例,不使用业务请求的实例。这样可以避免刷新请求本身受到响应拦截器的影响(例如,刷新请求若走业务实例的拦截器,可能触发递归刷新逻辑),同时也能对刷新请求设置不同的超时时间和配置。
javascript
const refreshInstance = axios.create({
baseURL: '/api',
timeout: 30000,
headers: { 'Content-Type': 'application/json' }
});
// 刷新函数使用独立实例,避免拦截器递归
const refreshToken = () => {
const rt = localStorage.getItem('refresh_token');
return refreshInstance.post('/auth/refresh', { refresh_token: rt });
};
4.4 Token 存储时机优化
在刷新成功后,务必先持久化新 Token 再重试队列请求 ,否则若重试过程中出现异常导致 Token 丢失,会影响后续所有请求。此外,可配合 localStorage 的 storage 事件实现跨标签页的 Token 同步------当 A 标签页刷新 Token 后,B 标签页能感知到变化并同步更新。
五、生产级最佳实践
5.1 Access Token 的安全有效期
业界推荐将 Access Token 有效期设置为 15~60 分钟。短时效设计可以大幅缩小令牌泄露的风险窗口------即使 Token 被窃取,攻击者也只能在极短的时间窗口内滥用,随后 Token 便自动失效。对于金融、支付等高敏感场景,建议取更短的有效期(如 15 分钟)。
5.2 Refresh Token 的轮换机制
Refresh Token 的使用频率远低于 Access Token,但一旦泄露后果也更严重。生产环境建议启用 Refresh Token Rotation(刷新令牌轮换) 机制:每次使用 Refresh Token 获取新 Access Token 时,服务端同步下发一个新的 Refresh Token,并立即使旧的 Refresh Token 失效。这样即使旧 Refresh Token 被盗,也无法被再次利用,有效防范重放攻击。
5.3 退出登录时的令牌吊销
用户主动退出时,不应仅清除本地存储,还应调用服务端的令牌吊销接口,使该用户的所有 Refresh Token 立即失效,防止令牌在有效期内被他人盗用。
5.4 推荐的开源方案
如果不想从零实现,社区已有成熟的轻量级方案可供选用:
- axios-auth-refresh-queue:仅 641 Bytes(压缩后),零配置即可处理竞态条件、死循环和刷新失败等边界情况,提供内置 Debug 日志模式,TypeScript 完整支持;
- axios-auth-refresh:经典的 Axios 拦截器封装库,支持自定义刷新逻辑和状态码判断。
这些库经过了社区验证,能够覆盖绝大多数业务场景,适合在生产环境中直接使用。
六、总结
Axios Token 无感刷新机制的核心在于双 Token 架构与 Promise 请求队列的有机结合:
- 双 Token 分工:Access Token(短时效,业务鉴权)+ Refresh Token(长时效,身份续期),解决安全与体验的矛盾;
- 拦截器驱动 :通过 Axios 响应拦截器监听
401状态码,自动触发刷新流程; - 队列机制 :利用
Promise队列管理并发请求,保证多请求同时过期时只触发一次刷新,刷新完成后统一重试; - 防御性编程:区分刷新接口错误防止死循环,统一异常提示,使用独立实例,确保边界场景稳定可靠。