在前后端分离项目中,前端处理 Token 过期与自动刷新是保障用户体验的核心环节。以下将从 “如何确定 Token 过期时间”、“自动刷新 Token 的实现逻辑”、“关键细节与安全注意事项” 三个维度展开,结合实际代码示例说明。
一、如何确定 Token 过期时间?
Token 过期时间的获取方式,取决于后端返回的 Token 类型(如 JWT Token / 普通 Token),常见有两种方案:
1. 方案 1:从 JWT Token 本身解码过期时间(推荐)
如果后端使用 JWT Token(结构为 Header.Payload.Signature),其 Payload 中会自带过期时间字段(通常是 exp,表示 Unix 时间戳,单位:秒)。
前端可直接解码 Payload 拿到 exp,无需额外请求后端。
实现步骤:
⚠️ 注意:jwt-decode 仅用于解码字段,不验证 Token 签名有效性(签名验证必须由后端完成)。前端解码的 exp 仅作为 “触发刷新的参考”,最终 Token 是否有效以后端接口返回为准。
2. 方案 2:后端额外返回过期时间(适用于非 JWT Token)
如果后端使用普通 Token(如随机字符串 Token),Token 本身不包含过期信息,此时后端需在返回 Token 时,额外返回过期相关字段(如 expiresIn 或 expireTime)。
示例:后端返回格式
1 2 3 4 5 6 7 8 9 10
| { "code": 200, "data": { "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "defg123456...", "expiresIn": 7200, "expireTime": "2024-07-01 18:00:00" } }
|
前端处理逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const loginRes = await axios.post('/api/login', { username, password }); const { accessToken, refreshToken, expiresIn } = loginRes.data.data;
localStorage.setItem('accessToken', accessToken); localStorage.setItem('refreshToken', refreshToken);
const expireTime = new Date().getTime() + expiresIn * 1000; localStorage.setItem('accessTokenExpireTime', expireTime.toString());
const currentTime = new Date().getTime(); const storedExpireTime = Number(localStorage.getItem('accessTokenExpireTime')); const isAboutToExpire = storedExpireTime - currentTime < 30 * 1000;
|
二、自动刷新 Token 的实现逻辑
自动刷新的核心是:在 accessToken 过期前,用长期有效的 refreshToken 向后端请求新的 accessToken,避免用户感知到登录状态中断。
核心原理:Token 分层设计
后端通常会返回两种 Token:
- accessToken:短期有效(如 2 小时),用于接口请求的身份验证(放在请求头
Authorization: Bearer {accessToken})。
- refreshToken:长期有效(如 7 天),仅用于请求新的 accessToken(权限范围窄,即使泄露风险较低)。
实现步骤(以 Axios 拦截器为例)
前端通常通过 请求拦截器(添加 Token 到请求头)和 响应拦截器(处理 Token 过期错误)实现自动刷新,同时需解决 “并发请求重复刷新” 的问题。
1. 初始化 Axios 实例(封装基础配置)
1 2 3 4 5 6 7 8 9 10 11 12 13
| import axios from 'axios';
const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 5000 });
let isRefreshing = false;
let requestQueue = [];
|
2. 请求拦截器:添加 accessToken 到请求头
每次接口请求前,自动从存储中读取 accessToken 并添加到 Authorization 头。
1 2 3 4 5 6 7 8 9 10 11 12 13
| request.interceptors.request.use( (config) => { const accessToken = localStorage.getItem('accessToken'); if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; }, (error) => Promise.reject(error) );
|
3. 响应拦截器:处理 Token 过期与自动刷新
当后端返回 Token 过期错误(如 code: 401 或 code: 403)时,触发刷新逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| request.interceptors.response.use( (response) => response.data, async (error) => { const originalRequest = error.config;
if (originalRequest.url === '/api/refresh-token') { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); localStorage.removeItem('accessTokenExpireTime'); window.location.href = `/login?redirect=${window.location.pathname}`; return Promise.reject(error); }
const isTokenExpired = error.response?.data?.code === 401; if (isTokenExpired && !originalRequest._retry) { originalRequest._retry = true;
if (isRefreshing) { return new Promise((resolve) => { requestQueue.push(() => resolve(request(originalRequest))); }); }
isRefreshing = true; try { const refreshToken = localStorage.getItem('refreshToken'); const refreshRes = await axios.post( `${import.meta.env.VITE_API_BASE_URL}/api/refresh-token`, { refreshToken } ); const { accessToken: newAccessToken, expiresIn } = refreshRes.data.data;
localStorage.setItem('accessToken', newAccessToken); const newExpireTime = new Date().getTime() + expiresIn * 1000; localStorage.setItem('accessTokenExpireTime', newExpireTime.toString());
requestQueue.forEach(cb => cb()); requestQueue = [];
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; return request(originalRequest); } catch (refreshError) { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); localStorage.removeItem('accessTokenExpireTime'); window.location.href = `/login?redirect=${window.location.pathname}`; return Promise.reject(refreshError); } finally { isRefreshing = false; } }
return Promise.reject(error); } );
export default request;
|
4. 主动检测过期(可选,优化体验)
除了 “被动等待接口返回 401”,还可在 页面初始化 / 路由切换时 主动检测 Token 是否即将过期,提前触发刷新(避免用户操作时才发现过期)。
示例(Vue 路由守卫):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| import { createRouter, createWebHistory } from 'vue-router'; import request from '@/utils/request'; import jwtDecode from 'jwt-decode';
const router = createRouter({ history: createWebHistory(), routes: [] });
router.beforeEach(async (to, from, next) => { const accessToken = localStorage.getItem('accessToken'); const refreshToken = localStorage.getItem('refreshToken');
if (!accessToken && to.path !== '/login') { next({ path: '/login', query: { redirect: to.path } }); return; }
if (accessToken) { const decoded = jwtDecode(accessToken); const expireTime = decoded.exp * 1000; const currentTime = new Date().getTime(); const isAboutToExpire = expireTime - currentTime < 60 * 1000;
if (isAboutToExpire && refreshToken && !isRefreshing) { try { const refreshRes = await request.post('/api/refresh-token', { refreshToken }); const { accessToken: newAccessToken, expiresIn } = refreshRes.data.data; localStorage.setItem('accessToken', newAccessToken); localStorage.setItem('accessTokenExpireTime', new Date().getTime() + expiresIn * 1000); } catch (e) { localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); next({ path: '/login', query: { redirect: to.path } }); return; } } }
next(); });
export default router;
|
三、关键细节与安全注意事项
- Token 存储方式选择:
- 不推荐
localStorage:易受 XSS 攻击(恶意脚本可读取)。
- 推荐方案:
httpOnly + Secure Cookie:后端设置 Set-Cookie: refreshToken=xxx; HttpOnly; Secure; SameSite=Strict,前端无法通过 JS 读取,彻底防 XSS;但需处理跨域 Cookie 携带问题(后端配置 Access-Control-Allow-Credentials: true)。
- 若用
sessionStorage:关闭页面后 Token 失效,安全性高于 localStorage,适合 “一次会话内有效” 的场景。
- refreshToken 的安全策略:
- 后端需给 refreshToken 绑定用户设备(如记录设备 ID),避免被盗用后跨设备使用。
- 每次刷新 accessToken 时,可选择性更新 refreshToken(“Token 轮换”),降低 refreshToken 泄露后的风险。
- refreshToken 有效期不宜过长(如 7 天),过期后强制用户重新登录。
- 避免重复刷新:
通过 isRefreshing 标记和 requestQueue 队列,解决 “多个并发请求同时触发刷新” 的问题(否则会生成多个无效的 accessToken)。
- 后端验证不可少:
前端的过期时间判断仅为 “优化体验”,最终 Token 是否有效必须由后端验证(如 JWT 签名验证、Token 黑名单检查),不能信任前端传递的任何 Token 相关信息。
四、总结
前端自动刷新 Token 的核心流程:
- 获取过期时间:JWT 解码
exp 字段,或使用后端返回的 expiresIn。
- 拦截请求 / 响应:用 Axios 拦截器添加 Token、处理 401 错误。
- 刷新逻辑:用 refreshToken 请求新 accessToken,重试排队请求。
- 异常处理:刷新失败时清除 Token 并跳转登录页。
通过这套方案,可实现 “用户无感知续期登录状态”,同时兼顾安全性和体验。