Token过期是几乎所有接入OAuth 2.0授权体系的应用都会遇到的问题。当access token到期后,客户端拿着过期令牌去请求资源服务器,得到的往往是401状态码;而当我们尝试用refresh token去换新的令牌时,有时又会收到invalid_grant错误,直接把用户踢回登录页。这个错误其实是授权服务器在告诉你:你提供的授权凭证已经不被接受了。理解它的触发条件,并设计一套不打扰用户的静默刷新机制,是保证登录态稳定的关键。

invalid_grant错误的常见触发原因
按照OAuth 2.0规范RFC 6749的定义,invalid_grant表示提供的授权授予(比如授权码、refresh token)无效、过期、被撤销,或者与授权请求中使用的重定向URI不匹配。落到实际场景中,最常见的有下面这几种情况。
第一种是refresh token本身过期。很多授权服务器会给refresh token设置有效期,比如30天或90天,超过这个期限用户长时间未使用应用,refresh token就失效了。第二种是refresh token被轮换后旧令牌被撤销。出于安全考虑,不少服务端(比如Auth0、Keycloak)在每次刷新时会返回一个新的refresh token并立即作废旧的,如果客户端因网络重试等原因把旧token又发了一次,就会得到invalid_grant。第三种是客户端与服务器时钟偏移过大,某些实现会校验请求时间戳,偏差超过阈值会直接拒绝。第四种是redirect_uri或client_id不一致,这在用授权码换token的阶段尤其常见。排查时第一步应该是把错误响应体完整打出来看error_description字段,很多服务器会在里面写明具体原因。
// 捕获完整的错误响应,方便定位问题
try {
const res = await axios.post('/oauth/token', {
grant_type: 'refresh_token',
refresh_token: savedRefreshToken,
client_id: 'my-client'
});
localStorage.setItem('access_token', res.data.access_token);
} catch (err) {
console.error('错误码:', err.response?.data?.error);
console.error('错误描述:', err.response?.data?.error_description);
if (err.response?.data?.error === 'invalid_grant') {
// refresh token已失效,只能引导用户重新登录
redirectToLogin();
}
}需要特别提醒一点:收到invalid_grant时不要无脑重试。这个错误意味着授权已经不可恢复地失效了,重试一万次结果也一样,反而可能触发服务端的防暴力破解机制。正确做法是清理本地存储的令牌,跳转登录页重新走一遍授权流程。
静默刷新的三种实现思路对比
所谓静默刷新(Silent Refresh),就是在用户无感知的情况下完成令牌续期,避免页面突然跳转登录。目前主流的实现方式有三种,各有适用场景。
第一种是定时器预刷新。客户端在拿到access token时顺便解析expires_in字段,提前在过期前的一段时间(比如提前60秒)用setTimeout或setInterval触发刷新。这种方式实现简单,但缺点是刷新时机可能与服务端状态不同步,比如用户休眠电脑后定时器暂停,醒来时token早已过期。
第二种是请求拦截器被动刷新。在HTTP客户端的响应拦截器里统一拦截401错误,发现401后先尝试刷新token,成功后重放刚才失败的请求,失败则跳转登录。这种方式对时机把握最准确,也是目前SPA应用采用最广泛的方案。
第三种是隐藏iframe静默授权,这是传统上针对隐式流程的方案,利用prompt=none参数在隐藏iframe中重新走一遍授权,第三方Cookie被浏览器逐步禁用后,这种方式在跨站场景下已经越来越难走通,Google、Auth0等厂商都推荐迁移到带PKCE的授权码流程配合refresh token。如果你的系统还在用隐式流程,建议尽早规划迁移。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 定时器预刷新 | 实现简单,请求前就绪 | 时机可能与实际过期不同步 | 桌面端长驻应用 |
| 拦截器被动刷新 | 时机准确,覆盖所有接口 | 需处理并发请求排队 | 绝大多数SPA应用 |
| iframe静默授权 | 无需存储refresh token | 依赖第三方Cookie,逐渐失效 | 旧系统过渡方案 |
拦截器方案的完整实现与并发处理
拦截器方案最关键的坑在于并发请求。假设页面同时发出了五个请求,token恰好在这时过期,五个请求全部收到401,如果每个401都触发一次刷新,就会发起五次刷新请求。前面提到过,如果服务端启用了refresh token轮换,第二次使用旧token就会被判定为invalid_grant,直接导致整个登录态崩溃。解决办法是用一个共享的刷新Promise,让所有并发的401都等待同一次刷新完成。
let isRefreshing = false;
let pendingQueue = []; // 等待刷新完成的请求队列
const api = axios.create({ baseURL: '/api' });
api.interceptors.request.use(config => {
const token = localStorage.getItem('access_token');
if (token) config.headers.Authorization = 'Bearer ' + token;
return config;
});
api.interceptors.response.use(
res => res,
async err => {
const original = err.config;
if (err.response?.status !== 401 || original._retried) {
return Promise.reject(err);
}
// refresh接口本身401说明refresh token已失效
if (original.url.includes('/oauth/token')) {
localStorage.clear();
location.href = '/login';
return Promise.reject(err);
}
if (isRefreshing) {
// 已有刷新在进行中,当前请求入队等待
return new Promise((resolve, reject) => {
pendingQueue.push({ resolve, reject, config: original });
});
}
original._retried = true;
isRefreshing = true;
try {
const { data } = await axios.post('/oauth/token', {
grant_type: 'refresh_token',
refresh_token: localStorage.getItem('refresh_token'),
client_id: 'my-client'
});
localStorage.setItem('access_token', data.access_token);
if (data.refresh_token) {
// 服务端做了轮换,必须保存新的refresh token
localStorage.setItem('refresh_token', data.refresh_token);
}
pendingQueue.forEach(({ resolve, config }) => {
config.headers.Authorization = 'Bearer ' + data.access_token;
resolve(api(config)); // 重放排队的请求
});
return api(original);
} catch (refreshErr) {
pendingQueue.forEach(({ reject }) => reject(refreshErr));
localStorage.clear();
location.href = '/login';
return Promise.reject(refreshErr);
} finally {
pendingQueue = [];
isRefreshing = false;
}
}
);这段代码里有几个细节值得注意。首先,_retried标记防止重放的请求再次401时陷入死循环;其次,刷新成功后要检查响应里是否包含新的refresh_token,有轮换机制的服务端必须及时更新,否则下一次刷新必然报invalid_grant;最后,无论是成功还是失败,finally块里都要重置isRefreshing和队列,否则一次异常会让整个刷新机制永久卡死。
服务端与客户端的配合优化建议
单靠客户端技巧无法根治问题,服务端的合理设计能让刷新机制稳定得多。第一个建议是服务端在返回access token时给出准确的expires_in,并在token即将过期前保留一个缓冲窗口,客户端据此决定提前刷新的时机。第二个建议是针对refresh token轮换提供短暂的宽限期:旧token在轮换后的30秒内仍然有效(Google的OAuth实现就是这样做的),这样网络抖动导致的重复刷新不会直接把用户踢下线,对弱网环境的移动端尤其友好。
第三个建议是客户端的存储策略。把refresh token放在localStorage虽然方便,但容易被XSS窃取。如果条件允许,可以把refresh token存到HttpOnly Cookie里,由后端网关统一负责刷新和注入Authorization头,前端只感知一个会话状态,这样既安全又省去了前端处理并发刷新的复杂度。当然这要求网关与授权服务器在同一站点或做好CORS配合。
最后,完善的可观测性不可缺少。建议客户端在刷新失败时上报埋点,带上error_description和服务端返回的附加字段,你可以据此统计invalid_grant的真实分布。实践中你会发现,相当一部分invalid_grant来自多标签页同时刷新的竞态,解决办法是通过BroadcastChannel或storage事件让多标签页共享同一个刷新动作,某一页刷新成功后广播通知其他页更新令牌,从源头减少冲突。
总结一下,处理OAuth Token过期的正确姿势是:把invalid_grant当成不可恢复错误,引导重新登录;把401当成可恢复错误,用拦截器加请求队列做静默刷新;再通过服务端宽限期和多标签页协调把竞态问题压到最低。做到这三点,用户的登录体验会稳定很多。
OAuth Token过期invalid_grant静默刷新修改时间:2026-09-09 22:50:55