JWT过期后如何自动刷新而不让用户重新登录?

Top丶露宜 阅读 23

我用JWT做用户认证,token一小时过期。现在的问题是,用户操作到一半突然跳回登录页,体验太差了。我看别人说可以用refresh token自动续期,但具体怎么在前端安全地实现?

我试过在每次请求前检查token是否快过期,如果是就先调用刷新接口,但有时候还是会因为并发请求导致多个刷新同时发生,甚至拿到旧的access token。有没有靠谱的做法?

比如我的刷新逻辑大概是这样:

const refreshToken = async () => {
  const res = await fetch('/api/refresh', {
    method: 'POST',
    credentials: 'include' // refresh token在httpOnly cookie里
  });
  const data = await res.json();
  localStorage.setItem('accessToken', data.accessToken);
  return data.accessToken;
};

但多个API请求几乎同时触发这个函数,就会重复刷新,还可能覆盖掉最新的token。该怎么解决?

我来解答 赞 6 收藏
二维码
手机扫码查看
1 条解答
程序员子萱
这个问题的关键是并发请求导致的竞态条件,你的刷新逻辑本身没问题,但缺了一个"锁"机制。

先说为什么会这样。假设页面加载时同时发了5个请求,每个请求都检测到token快过期了,然后各自调用refreshToken函数。这5个请求几乎同时到达服务器,服务器返回5个新的access token,但只有最后一个存进localStorage的是有效的,前面4个已经失效了。更糟糕的是,这4个请求用的可能是旧token,直接401。

解决思路是用一个全局的Promise来做"锁"。第一个请求触发刷新后,后续的请求不再发起刷新请求,而是等待第一个刷新完成,拿到同一个新token。这样不管多少并发请求,实际只刷新一次。

看代码,我给你写一个完整的封装:

// 全局状态管理
let isRefreshing = false;
let refreshPromise = null;

// 刷新token的核心函数(带锁机制)
const refreshToken = async () => {
// 如果已经在刷新中,直接返回那个Promise,所有并发请求共享同一个Promise
if (isRefreshing && refreshPromise) {
return refreshPromise;
}

// 标记为刷新中
isRefreshing = true;

// 创建刷新Promise并缓存
refreshPromise = fetch('/api/refresh', {
method: 'POST',
credentials: 'include' // httpOnly cookie自动带上
})
.then(res => {
if (!res.ok) {
throw new Error('Refresh failed');
}
return res.json();
})
.then(data => {
localStorage.setItem('accessToken', data.accessToken);
return data.accessToken;
})
.finally(() => {
// 无论成功失败,都要清除状态,让下次可以重新刷新
isRefreshing = false;
refreshPromise = null;
});

return refreshPromise;
};

// 带自动刷新的请求封装
const fetchWithAuth = async (url, options = {}) => {
const token = localStorage.getItem('accessToken');

// 检查token是否存在
if (!token) {
throw new Error('No token, redirect to login');
}

// 发起请求
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': Bearer ${token}
}
});

// 如果返回401,尝试刷新token后重试
if (response.status === 401) {
try {
// 这里会自动处理并发问题,所有401的请求都会等待同一个刷新结果
const newToken = await refreshToken();

// 用新token重试原请求
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': Bearer ${newToken}
}
});
} catch (refreshError) {
// 刷新失败,清空token,跳转登录
localStorage.removeItem('accessToken');
window.location.href = '/login';
throw refreshError;
}
}

return response;
};


使用的时候就很简单了:

// 正常发请求就行,不用管token的事
const getUserInfo = () => fetchWithAuth('/api/user/info');
const getOrders = () => fetchWithAuth('/api/orders');

// 并发请求也没问题,token刷新只会发生一次
Promise.all([getUserInfo(), getOrders(), getNotifications()]);


这里有几个细节需要注意。第一,refresh token必须放在httpOnly cookie里,你做对了,这样可以防止XSS攻击窃取refresh token。第二,access token虽然放在localStorage,但可以考虑也用cookie,不过localStorage方便前端读取判断过期时间。

再说一个进阶的优化。如果你想在请求发出前就判断token快过期,主动刷新而不是等401,可以加个预刷新逻辑:

// 解析JWT获取过期时间(不验证签名,只是读取payload)
const getTokenExpireTime = (token) => {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000; // 转成毫秒
} catch {
return 0;
}
};

// 检查是否需要刷新(提前5分钟刷新)
const shouldRefreshToken = (token) => {
const expireTime = getTokenExpireTime(token);
const now = Date.now();
const buffer = 5 * 60 * 1000; // 5分钟缓冲
return expireTime - now < buffer;
};

// 增强版请求函数
const fetchWithAutoRefresh = async (url, options = {}) => {
let token = localStorage.getItem('accessToken');

if (!token) {
throw new Error('No token');
}

// 主动检查是否快过期,提前刷新
if (shouldRefreshToken(token)) {
try {
token = await refreshToken();
} catch (e) {
// 预刷新失败不影响,后面401再处理
console.warn('Pre-refresh failed:', e);
}
}

// 后面的逻辑跟之前一样...
const response = await fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': Bearer ${token}
}
});

if (response.status === 401) {
const newToken = await refreshToken();
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': Bearer ${newToken}
}
});
}

return response;
};


这样双重保障,主动刷新加被动刷新,基本不会出现用户突然掉线的情况。

最后提醒一个坑,后端的refresh token也要有过期时间和单次使用机制。每次刷新后旧的refresh token应该失效,防止被重放攻击。如果检测到已使用的refresh token再次被使用,说明可能被窃取,应该直接让所有该用户的token失效,强制重新登录。这个需要后端配合,前端控制不了。
点赞 2
2026-03-01 09:15