前端双Token机制无感刷新原理与关键点详解
本文档深入剖析前端双Token(Access Token + Refresh Token)无感刷新机制的设计原理、完整流程、核心实现细节及常见踩坑点,帮助开发者构建安全且用户体验无中断的鉴权体系。
目录
- 为什么需要双Token机制
- [Access Token 与 Refresh Token 的角色分工](#Access Token 与 Refresh Token 的角色分工 "#2-access-token-%E4%B8%8E-refresh-token-%E7%9A%84%E8%A7%92%E8%89%B2%E5%88%86%E5%B7%A5")
- 完整刷新流程
- 核心实现:请求拦截器与响应拦截器
- 并发请求处理:如何避免重复刷新
- [Token 存储策略](#Token 存储策略 "#6-token-%E5%AD%98%E5%82%A8%E7%AD%96%E7%95%A5")
- 安全考量
- 与后端的协作约定
- 完整代码示例
- 常见踩坑与最佳实践
1. 为什么需要双Token机制
1.1 单Token的困境
传统单Token方案存在两难选择:
| 策略 | 优点 | 缺点 |
|---|---|---|
| Token 有效期短(如 15min) | 泄露后影响窗口小 | 用户频繁掉线,体验差 |
| Token 有效期长(如 7 天) | 用户不用频繁登录 | 一旦泄露,长时间可用 |
1.2 双Token解决思路
核心思想:将"鉴权"和"续期"两个职责分离到两个不同的Token上。
arduino
Access Token → 短有效期(15min ~ 2h) → 用于鉴权,每次请求携带
Refresh Token → 长有效期(7d ~ 30d) → 仅用于换取新的 Access Token
一句话总结: Access Token 过期了,前端用 Refresh Token 静默换一个新的,用户完全无感知。
2. Access Token 与 Refresh Token 的角色分工
2.1 Access Token
makefile
定位: 一线战士,直接参与每次战斗
内容: 通常为 JWT,携带用户标识、权限等
有效期: 短(分钟级)
携带方式:Authorization: Bearer <access_token>
暴露频率:每次请求都发送,暴露面大
泄露后果:影响窗口仅数分钟,风险可控
2.2 Refresh Token
定位: 后勤补给,不参与战斗,只负责签发新Token
内容: 通常为随机字符串(不透明Token),不含业务信息
有效期: 长(天/周级)
携带方式:仅刷新接口使用,请求体中传递
暴露频率:极低(仅在Access Token过期时使用一次)
泄露后果:可长期伪造身份,需配合其他机制防护
2.3 为什么Refresh Token不能替代Access Token
markdown
安全层面:
- Access Token 随每次请求发送,暴露面极大
- 如果 Refresh Token 替代 Access Token 高频传输,泄露风险等同单Token长有效期
性能层面:
- Access Token(JWT)可本地解析校验,无需查库
- Refresh Token 每次使用必须查库验证是否被撤销
- 高频查库带来不必要的性能开销
3. 完整刷新流程
3.1 正常请求流程
kotlin
┌─────────┐ ┌──────────┐ ┌─────────┐
│ 前端 │ │ 网关/BFF │ │ 后端服务 │
└────┬────┘ └────┬─────┘ └────┬────┘
│ 请求 + Access │ │
│ Token (有效) │ │
├──────────────────►│ │
│ │ 转发 + Access │
│ │ Token │
│ ├───────────────────►│
│ │ │
│ │ 200 OK + data │
│ │◄───────────────────┤
│ 200 OK + data │ │
│◄──────────────────┤ │
│ │ │
3.2 Token过期刷新流程
scss
┌─────────┐ ┌──────────┐ ┌─────────┐
│ 前端 │ │ 网关/BFF │ │ 后端服务 │
└────┬────┘ └────┬─────┘ └────┬────┘
│ 请求 + Access │ │
│ Token (已过期) │ │
├──────────────────►│ │
│ │ 401 Unauthorized │
│ 401 Unauthorized │◄───────────────────┤
│◄──────────────────┤ │
│ │ │
│ 请求 + Refresh │ │
│ Token (刷新接口) │ │
├──────────────────►│ │
│ ├───────────────────►│
│ │ 校验 Refresh │
│ │ Token 有效性 │
│ │◄───────────────────┤
│ 新 Access Token │ │
│ + 新 Refresh │ │
│ Token (可选) │ │
│◄──────────────────┤ │
│ │ │
│ 用新 Access │ │
│ Token 重试原请求 │ │
├──────────────────►│ │
│ ├───────────────────►│
│ │ │
│ 200 OK + data │ │
│◄──────────────────┤ │
│ │ │
3.3 Refresh Token也过期
前端发起刷新请求 → 后端返回 401(Refresh Token 也过期)
→ 清除本地 Token → 跳转登录页
4. 核心实现:请求拦截器与响应拦截器
4.1 架构示意
vbscript
用户代码
│
▼
请求拦截器(Request Interceptor)
│ └─ 给每个请求自动注入 Access Token
▼
HTTP 请求
│
▼
响应拦截器(Response Interceptor)
│ ├─ 200:直接返回
│ ├─ 401:触发刷新逻辑
│ │ ├─ 成功 → 更新Token → 重试原请求
│ │ └─ 失败 → 清除Token → 跳转登录
│ └─ 其他:直接 reject
▼
用户代码拿到结果
4.2 关键判断逻辑
markdown
拦截器收到 401 后的决策树:
401 状态码
│
├─ 请求URL是否是刷新接口本身?
│ ├─ 是 → 直接 reject(避免死循环)
│ └─ 否 → 继续判断
│
├─ 当前是否正在刷新中?
│ ├─ 是 → 将当前请求加入等待队列,等刷新完成后重试
│ └─ 否 → 发起刷新请求
│
└─ 发起刷新 → 结果?
├─ 成功 → 更新本地Token → 重试原请求
└─ 失败 → 清除Token → 跳转登录
5. 并发请求处理:如何避免重复刷新
5.1 问题场景
假设页面上同时发起了 5 个接口请求,而 Access Token 恰好过期:
yaml
时间线:
t0: 请求A发送 → 401
t1: 请求B发送 → 401(刷新还未完成)
t2: 请求C发送 → 401
t3: 请求D发送 → 401
t4: 请求E发送 → 401
如果不在拦截器层面做并发控制,会同时发起 5 次刷新请求,造成:
- 后端压力倍增
- 多个刷新请求可能互相覆盖Token,导致状态混乱
- 浪费资源
5.2 解决方案:Promise缓存 + 请求队列
核心思路:用一个全局Promise缓存正在进行的刷新操作,让后续401请求等待同一个刷新Promise完成,而不是各自发起刷新。
css
┌───────────────────────────────────────────┐
│ 并发请求处理模型 │
├───────────────────────────────────────────┤
│ │
│ 请求A → 401 → 发起刷新 │
│ │ │
│ │ refreshPromise = 刷新请求 │
│ │ │
│ 请求B → 401 → 检测到刷新中 │
│ │ │
│ │ 加入等待队列 │
│ │ return refreshPromise │
│ │ .then(() => 重试B) │
│ │ │
│ 请求C → 401 → 同B │
│ 请求D → 401 → 同B │
│ 请求E → 401 → 同B │
│ │ │
│ 刷新成功 ←───┘ │
│ 更新Token │
│ refreshPromise = null │
│ 逐一重试 B、C、D、E │
│ │
└───────────────────────────────────────────┘
5.3 伪代码实现
javascript
// 全局状态
let isRefreshing = false;
let pendingRequests = []; // 等待刷新完成的请求队列
function addPendingRequest(resolve, reject) {
pendingRequests.push({ resolve, reject });
}
function resolvePendingRequests() {
pendingRequests.forEach(({ resolve }) => resolve());
pendingRequests = [];
}
function rejectPendingRequests(error) {
pendingRequests.forEach(({ reject }) => reject(error));
pendingRequests = [];
}
// 响应拦截器核心逻辑
async function handle401(error) {
const originalRequest = error.config;
// 1. 避免刷新接口自己死循环
if (originalRequest.url === '/auth/refresh') {
return Promise.reject(error);
}
// 2. 已经在刷新中,排队等待
if (isRefreshing) {
return new Promise((resolve, reject) => {
addPendingRequest(() => {
// 用新Token重试原请求
resolve(axiosInstance(originalRequest));
});
});
}
// 3. 首次遇到401,发起刷新
isRefreshing = true;
try {
const { accessToken, refreshToken } = await refreshAccessToken();
// 更新本地Token
setTokens(accessToken, refreshToken);
// 唤醒等待队列
resolvePendingRequests();
// 重试原请求
return axiosInstance(originalRequest);
} catch (refreshError) {
// 刷新失败,清空Token,跳转登录
clearTokens();
rejectPendingRequests(refreshError);
redirectToLogin();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
6. Token 存储策略
6.1 常见存储方式对比
| 存储位置 | XSS防护 | CSRF防护 | 持久化 | 适用场景 |
|---|---|---|---|---|
localStorage |
✗ 可被XSS读取 | ✓ JS读取不受Cookie影响 | ✓ | 对XSS防护有信心时 |
sessionStorage |
✗ 可被XSS读取 | ✓ | 仅当前标签页 | 临时会话场景 |
Cookie (HttpOnly) |
✓ JS不可读 | ✗ 需配合CSRF Token | ✓ | 推荐,安全性最高 |
内存变量 |
✓ JS闭包内 | ✓ | ✗ 刷新丢失 | 辅助存储 |
6.2 推荐策略
ini
Access Token:
└─ 首选 HttpOnly Cookie(后端 Set-Cookie)
└─ 备选 内存变量 + sessionStorage 做刷新恢复
Refresh Token:
└─ 首选 HttpOnly + Secure + SameSite=Strict Cookie
└─ 路径限制为 /auth/refresh,减少暴露面
6.3 Cookie 属性建议
ini
Set-Cookie: access_token=xxx;
HttpOnly; ← JS不可读,防止XSS窃取
Secure; ← 仅HTTPS传输
SameSite=Strict; ← 防止CSRF
Path=/; ← Access Token 所有路径都需要
Set-Cookie: refresh_token=xxx;
HttpOnly;
Secure;
SameSite=Strict;
Path=/auth/refresh; ← Refresh Token 仅刷新接口路径
7. 安全考量
7.1 Token 泄露后的防护措施
markdown
层级防御模型:
第一层:缩短 Access Token 有效期(15min)
└─ 即使泄露,影响窗口仅15分钟
第二层:Refresh Token 轮换(Rotation)
└─ 每次刷新后,旧的 Refresh Token 立即失效
└─ 如果攻击者用旧Token刷新,合法用户的Refresh Token
也被标记为已用,系统检测到异常 → 全员下线
第三层:Refresh Token 复用检测(Reuse Detection)
└─ 如果已被使用过的 Refresh Token 再次出现
└─ 说明有人重放 → 标记该用户的所有 Refresh Token 失效
└─ 强制重新登录
第四层:设备指纹 + IP变化检测
└─ Refresh Token 绑定签发时的设备/IP
└─ 环境变化 → 要求额外验证
7.2 Refresh Token Rotation 详解
这是当前业界最推荐的安全实践:
正常流程:
用户持有 R1(Refresh Token)
刷新时提交 R1,后端签发 A2 + R2,同时标记 R1 已失效
下次刷新用 R2,签发 A3 + R3,标记 R2 失效
攻击场景:
攻击者窃取了 R1
合法用户使用 R1 刷新 → 获得 A2 + R2,R1 被标记失效
攻击者用 R1 刷新 → 后端检测到 R1 已被使用(复用检测触发)
后端标记该用户所有Token无效 → 强制重新登录
合法用户和攻击者都被踢出 → 合法用户重新登录后恢复正常
8. 与后端的协作约定
8.1 后端需要提供的接口
| 接口 | 方法 | 说明 |
|---|---|---|
/auth/login |
POST | 登录,返回 Access Token + Refresh Token |
/auth/refresh |
POST | 刷新,接收 Refresh Token,返回新 Access Token(+ 新 Refresh Token) |
/auth/logout |
POST | 登出,通知后端废止 Refresh Token |
8.2 后端返回的格式约定
json
// 登录成功
{
"accessToken": "eyJhbGciOi...",
"refreshToken": "dGhpcyBpcyBh...",
"expiresIn": 3600 // Access Token 有效期(秒)
}
// 刷新成功
{
"accessToken": "eyJhbGciOi...",
"refreshToken": "bmV3IHJlZnJl...", // Token轮换时返回新的
"expiresIn": 3600
}
// 401 响应(需统一格式便于前端判断)
{
"code": 401,
"message": "Token expired"
}
8.3 前后端时间差处理
问题:
后端说 Access Token 有效期 15 分钟
但客户端时钟慢了 5 分钟 → 认为Token还有效,实际上已过期
方案:
不要依赖客户端判断Token是否过期
让服务端通过 401 统一告知前端
前端被动响应 401 触发刷新,而非主动预估过期时间
备选方案(减少一次 401 往返):
前端根据 expiresIn 提前刷新(如在过期前 2 分钟主动刷新)
但必须配合服务端 401 兜底
9. 完整代码示例
9.1 Axios 拦截器完整实现
javascript
import axios from 'axios';
import { getAccessToken, setTokens, clearTokens } from './token-store';
import { redirectToLogin } from './router';
// 创建 Axios 实例
const http = axios.create({
baseURL: '/api',
timeout: 10000,
});
// ============ 全局刷新状态 ============
let isRefreshing = false;
let pendingRequests = [];
// ============ 请求拦截器:注入 Access Token ============
http.interceptors.request.use(
(config) => {
const token = getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// ============ 响应拦截器:捕获 401 ============
http.interceptors.response.use(
(response) => response.data, // 正常返回,直接透传
async (error) => {
const originalRequest = error.config;
// 情况1:不是 401,直接 reject
if (error.response?.status !== 401) {
return Promise.reject(error);
}
// 情况2:刷新接口本身 401,清除状态跳转登录(避免死循环)
if (originalRequest.url === '/auth/refresh') {
clearTokens();
redirectToLogin();
return Promise.reject(error);
}
// 情况3:已经在刷新中,排队等待
if (isRefreshing) {
return new Promise((resolve) => {
pendingRequests.push(() => {
// 刷新完成后用新Token重试
originalRequest.headers.Authorization =
`Bearer ${getAccessToken()}`;
resolve(http(originalRequest));
});
});
}
// 情况4:首次遇到401,发起刷新
isRefreshing = true;
try {
const { accessToken, refreshToken } = await refreshTokens();
// 更新本地Token
setTokens(accessToken, refreshToken);
// 更新当前请求的Token
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
// 处理等待队列中的请求
pendingRequests.forEach((callback) => callback());
pendingRequests = [];
// 重试原请求
return http(originalRequest);
} catch (refreshError) {
// 刷新失败:清空Token、清空队列、跳转登录
pendingRequests = [];
clearTokens();
redirectToLogin();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
// ============ 刷新Token的API调用 ============
async function refreshTokens() {
const response = await axios.post('/auth/refresh', {
refreshToken: getRefreshToken(),
});
return response.data;
}
export default http;
9.2 Token 存储模块
javascript
// token-store.js
const ACCESS_KEY = 'access_token';
const REFRESH_KEY = 'refresh_token';
export function getAccessToken() {
return localStorage.getItem(ACCESS_KEY);
}
export function getRefreshToken() {
return localStorage.getItem(REFRESH_KEY);
}
export function setTokens(accessToken, refreshToken) {
localStorage.setItem(ACCESS_KEY, accessToken);
if (refreshToken) {
localStorage.setItem(REFRESH_KEY, refreshToken);
}
}
export function clearTokens() {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
}
9.3 主动刷新策略(可选优化)
结合被动 401 响应 + 主动提前刷新,减少一次 401 往返:
javascript
// 在每次请求成功后,检查Token剩余有效期
http.interceptors.response.use((response) => {
const token = getAccessToken();
if (token && shouldRefreshToken(token)) {
// 提前异步刷新,不阻塞当前请求
refreshTokensQuietly();
}
return response.data;
});
function shouldRefreshToken(token) {
const payload = parseJWT(token);
const now = Date.now() / 1000;
const expireTime = payload.exp;
const threshold = 120; // 提前2分钟刷新
return expireTime - now < threshold;
}
// 静默刷新,失败也不影响用户(等下次401触发被动刷新)
async function refreshTokensQuietly() {
try {
const { accessToken, refreshToken } = await refreshTokens();
setTokens(accessToken, refreshToken);
} catch {
// 静默失败,交给401拦截器处理
}
}
10. 常见踩坑与最佳实践
10.1 踩坑清单
| 坑点 | 现象 | 原因 | 解决 |
|---|---|---|---|
| 刷新死循环 | 页面卡死,请求不断 | 刷新接口本身也返回401,触发自身刷新 | 判断 url === '/auth/refresh' 时直接 reject |
| 并发刷新 | 同时发了N个刷新请求 | 多个请求同时遇到401,各自触发刷新 | 用 isRefreshing 锁 + Promise 队列机制 |
| 请求时序错乱 | 旧Token覆盖新Token | 第一个刷新请求返回前,用户操作触发了第二个刷新 | 使用刷新锁,保证同一时间只有一个刷新 |
| Token丢失后不清除 | 旧Token残留,请求一直401 | 刷新失败后没有 clean up | 刷新失败必须调用 clearTokens() |
| 页面刷新后空白 | 用户刷新页面后未登录 | Access Token 存在内存中,F5后丢失 | Token 持久化存储(localStorage/Cookie) |
| 多标签页Token不同步 | TabA刷新Token后TabB还在用旧的 | 各标签页 localStorage 独立 | 用 storage 事件或 BroadcastChannel 同步 |
10.2 多标签页同步方案
javascript
// 使用 BroadcastChannel API 跨标签页同步Token
const channel = new BroadcastChannel('token-sync');
// 刷新Token成功后,通知其他标签页
channel.postMessage({
type: 'TOKEN_UPDATED',
accessToken: newAccessToken,
});
// 监听其他标签页的通知
channel.onmessage = (event) => {
if (event.data.type === 'TOKEN_UPDATED') {
setTokens(event.data.accessToken);
}
if (event.data.type === 'FORCE_LOGOUT') {
clearTokens();
redirectToLogin();
}
};
10.3 最佳实践总结
markdown
1. 不要在前端解析JWT判断过期,让服务端401来决定
(或作为辅助优化,但不能替代服务端判断)
2. Refresh Token 必须配合 Rotation + Reuse Detection
单靠长有效期 Refresh Token 而没有轮换机制是不安全的
3. 刷新锁(isRefreshing)必不可少
这是防止并发刷新导致状态混乱的关键
4. 刷新接口的URL要单独判断
避免刷新接口自身的401再次触发刷新逻辑,形成死循环
5. 谨慎处理框架路由守卫
在路由守卫中依赖Token状态时,要确保刷新逻辑不会导致守卫死循环
6. 刷新失败后要彻底清理
清空Token、清空队列、中止所有pending请求、跳转登录页
7. 考虑无网络/网络恢复场景
断网期间Token过期,网络恢复后重试应能正常触发刷新
8. Token存储遵循最小暴露原则
Access Token 可用内存变量(配合持久化备份)
Refresh Token 强烈建议 HttpOnly Cookie
参考阅读:
- OAuth 2.0 RFC 6749 - Refresh Token 规范
- Auth0 - Refresh Token Rotation
- JWT Best Practices (IETF)