
先说一个我踩过的真实场景后端同学给接入方发了 access_token有效期写的是 2 小时refresh_token 给的是 30 天。结果用户用着用着刚打开页面还没点两下接口突然报“token is invalid”前端一看是 401没办法只能把人踢回登录页。用户当然不爽产品也来问“token 不是能自动续吗为什么还会掉登录”。原因很简单access_token 是短期的refresh_token 是用来换新 access_token 的但很多项目根本没做“自动换”这一步只在 access_token 失效后返回 401然后让用户重新登录。这种问题在前后端分离应用、小程序、第三方开放平台对接里特别常见。解决思路其实不复杂业界也有成熟套路主要分两种一种是在 token 快过期之前主动刷新叫定时前置续期另一种是请求发出去发现 401 再刷新并重放叫请求时懒刷新。这两套方案都有各自的适用场景也都有不少隐藏的坑。下面我把原理、代码、踩坑点一次性说清楚。1. token为什么会过期“无痛刷新”到底在解决什么1.1 从“短期房卡”和“身份证”说起先理解 token 设计里最基础的两个角色。access_token 是访问资源用的相当于酒店房卡。房卡有效期短通常几十分钟到几小时因为它要频繁在网络里传输被截获的风险高所以不能让它活太久。refresh_token 是换取新房卡用的凭证相当于身份证。它只在刷新 token 的时候出现在客户端和服务端之间使用频率很低所以有效期可以很长比如几天、几周甚至一个月。客户端拿着 access_token 去请求业务接口服务端验证 access_token 没问题就放行。一旦 access_token 过期服务端返回 401。理论上客户端应该拿着 refresh_token 去换一个新的 access_token然后再继续请求。可太多项目的实际表现是401 之后没有“再换一次”的逻辑而是直接跳登录页。这就是“无痛刷新”要解决的核心问题让 token 更新这件事自动发生用户在整个使用过程中完全感知不到凭证过期。1.2 无痛刷新适用的三类场景这里说的场景不限于自建后端。我在实际项目里总结下来主要就三类第一类是自建 JWT 登录体系。后端给前端发 access_token 和 refresh_token前端需要自己维护 token 的存储、过期判断和刷新逻辑。这是最常见的场景。第二类是对接第三方 OAuth2.0 平台。比如接入第三方开放平台、企业微信、飞书、GitHub OAuth 等第三方的 access_token 也有过期时间同样需要刷新。第三类是服务端服务之间的认证。比如微服务 A 调用微服务 B中间通过 token 做身份校验或者内部系统调用第三方 API此时 token 过期会导致整条调用链失败刷新逻辑往往要写在 SDK 或封装层里。不管是哪种场景无痛刷新的核心都是在 token 失效前后用 refresh_token 换到新的 access_token并把所有正在等待的请求用新 token 重新发出去。2. 方案一定时前置续期到点就换新2.1 设计思路与适用场景第一种方案很直白access_token 不是有时效吗我就在它过期之前主动调用刷新接口换一个新 token 存起来。这样业务请求在访问的时候手里的 token 永远是有效的。这里涉及一个关键参数提前量。不能等 token 已经过期了再去刷那就晚了。一般会设置一个阈值比如 access_token 有效期是 2 小时我提前 5 分钟或者 10 分钟去刷新。判断条件就是当前时间 token过期时间 - 提前量。这种方案适合场景相对简单、单客户端、用户活跃度稳定的系统。比如公司内部的 ERP 系统用户打开页面后会用一段时间定时刷新完全够用。再比如一些销售端 App用户操作频率不算高也不需要极低的请求延迟定时续期就很合适。但定时前置续期也有一个天然缺陷它依赖定时器能准时执行而且刷新动作和用户实际发请求的时刻之间存在一个“时间差”。如果 timing 设置不好很可能出现 token 提前刷了结果用户在 token 还有效的最后几分钟里发请求依然遇到 token 过期。所以实际落地时我通常会把定时方案作为兜底而不是唯一方案。2.2 前端定时刷新的完整实现以一个 Vue/React 前端项目为例核心思路这样走登录成功后把 access_token、refresh_token、expired_at过期时间戳存起来。页面启动时计算距离过期还剩多少毫秒如果小于阈值立即刷新。否则用 setTimeout 设置一个定时器到点后执行刷新。刷新成功后更新本地 token 和过期时间并按照新的过期时间重新设置定时器。下面是一段简化实现const TOKEN_KEY token_info; function getTokenInfo() { return JSON.parse(localStorage.getItem(TOKEN_KEY) || {}); } function saveTokenInfo(info) { localStorage.setItem(TOKEN_KEY, JSON.stringify(info)); } async function refreshAccessToken() { const { refresh_token } getTokenInfo(); const res await fetch(/api/auth/refresh, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ refresh_token }) }); if (!res.ok) { throw new Error(refresh failed); } const data await res.json(); // 约定后端返回新的 access_token 和有效秒数 saveTokenInfo({ ...getTokenInfo(), access_token: data.access_token, expires_in: data.expires_in, expired_at: Date.now() data.expires_in * 1000 }); return data.access_token; } function scheduleRefresh() { const { expired_at, refresh_token } getTokenInfo(); if (!expired_at || !refresh_token) return; // 设置提前量5分钟 const aheadMs 5 * 60 * 1000; const delay expired_at - Date.now() - aheadMs; if (delay 0) { refreshAccessToken().then(scheduleRefresh).catch(() { // 刷新失败时可以尝试延后30秒再试 setTimeout(scheduleRefresh, 30 * 1000); }); return; } setTimeout(async () { try { await refreshAccessToken(); } catch (e) { // 失败不直接踢下线先重试 setTimeout(scheduleRefresh, 30 * 1000); return; } scheduleRefresh(); }, delay); }这段代码里有几个点我在实际使用中专门调整过一是刷新失败不能用“静默失败”处理。定时刷新如果失败access_token 还在缓存里业务请求可能会继续用旧 token然后撞上 401。所以失败后要尽快重试重试间隔可以取 30 秒或者 1 分钟。二是本地存储的expired_at最好以后端返回的剩余有效时间计算不要前端自己猜。有的服务端返回的是expires_in有效秒数可以直接用Date.now() expires_in * 1000。有的服务端返回的是绝对时间那更好直接用。三是单页面应用还需要处理浏览器后台标签页的定时器问题。浏览器为了省资源会把后台标签页的setTimeout降低执行频率甚至暂停。这时定时续期很可能不执行等用户切回页面时token 已经过期了。我一般会再监听visibilitychange事件页面从后台切回前台时立刻检查一次剩余时间并决定是否刷新。document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { scheduleRefresh(); } });2.3 定时前置续期的三个坑第一个坑是多个标签页同时刷新。用户开了两个标签页两个页面都各自跑定时器到点后同时调刷新接口。如果后端 refresh_token 采用了轮换策略也就是每次刷新都让旧 refresh_token 失效那么两个标签页里只有一个能成功另一个拿到 401。解决思路要么是后端允许短时间内并发刷新要么前端用 BroadcastChannel 之类的机制做一个跨标签页锁同一时间只有一个页面去刷。第二个坑是服务端时钟和前端时钟不一致。前端用Date.now()算剩余时间如果用户手机时间被改过或者服务器时间有偏差会导致提前量判断不准。最稳妥的做法是后端返回一个server_time和expires_in前端用server_time expires_in计算过期时间。第三个坑是刷新接口被拦截器自己也拦了。如果你在项目里做了请求拦截器一定要把刷新接口放进白名单否则刷新请求自己也去检查 token 有效性形成无限递归。这个我后面会再强调。3. 方案二请求拦截 单飞刷新3.1 定时方案不够那就“用到再说”定时前置续期虽然简单但它在复杂前端场景里并不够稳。更常见的做法是不在固定的时间点去刷而是在每一次实际发请求前检查 token 是否即将过期如果快过期了就先刷新再发请求。这个思路叫“懒刷新”因为只有真正用到 token 的时候才去关心它还有多久过期。懒刷新还有一个变体请求先发出去如果返回 401就统一走一遍刷新流程然后把失败请求重新发一次。这样做的好处是即使 token 判断逻辑出现偏差也能在 401 阶段兜底。但懒刷新有一个必须处理好的并发问题。用户打开页面可能同时触发多个接口请求比如进入首页时需要并行请求用户信息、菜单、消息列表如果这些请求都发现 token 过期了它们不能各自都去调一次刷新接口否则会重复刷新。正确做法是只让一个请求承担刷新任务其他请求等待同一个刷新 Promise 完成。这个模式在业内叫“单飞刷新”single-flight。3.2 基于 Axios 的单飞刷新实现我实际项目里用的最多的是 Axios 拦截器方案。这里直接给一份相对完整的代码并解释关键点。import axios from axios; let isRefreshing false; // 当前是否正在刷新 let waitQueue []; // 等待刷新完成后的请求队列 let refreshPromise null; // 复用同一个刷新 Promise const service axios.create({ timeout: 15000 }); // 判断是否应该刷新 function shouldRefresh() { const tokenInfo getTokenInfo(); if (!tokenInfo.expired_at || !tokenInfo.refresh_token) return false; // 提前 30 秒刷新 const thresholdMs 30 * 1000; return Date.now() thresholdMs tokenInfo.expired_at; } // 刷新 token返回新的 access_token async function doRefreshToken() { const tokenInfo getTokenInfo(); const res await axios.post(/api/auth/refresh, { refresh_token: tokenInfo.refresh_token }); const data res.data; saveTokenInfo({ ...tokenInfo, access_token: data.access_token, expires_in: data.expires_in, expired_at: Date.now() data.expires_in * 1000 }); return data.access_token; } // 统一刷新入口防止并发请求重复刷新 function refreshToken() { if (!refreshPromise) { refreshPromise doRefreshToken() .finally(() { refreshPromise null; }); } return refreshPromise; } // 请求拦截器 service.interceptors.request.use(async (config) { // 刷新接口本身不需要附带旧 token if (config.url.includes(/api/auth/refresh)) { return config; } const tokenInfo getTokenInfo(); if (shouldRefresh()) { try { const newToken await refreshToken(); config.headers[Authorization] Bearer newToken; } catch (e) { // 刷新失败让请求继续走由响应拦截器统一处理 401 } } else if (tokenInfo.access_token) { config.headers[Authorization] Bearer tokenInfo.access_token; } return config; }); // 响应拦截器 service.interceptors.response.use( (response) response, async (error) { const { config, response } error; if (!response || response.status ! 401) { return Promise.reject(error); } // 刷新接口自身 401 直接拒绝避免死循环 if (config.url.includes(/api/auth/refresh)) { clearToken(); window.location.href /login; return Promise.reject(error); } // 如果这个请求已经重放过一次就不再重放 if (config._retry) { clearToken(); window.location.href /login; return Promise.reject(error); } config._retry true; try { const newToken await refreshToken(); config.headers[Authorization] Bearer newToken; return service(config); } catch (e) { clearToken(); window.location.href /login; return Promise.reject(e); } } );这段代码里refreshToken()是整套逻辑的关键。它内部用一个refreshPromise变量缓存当前正在执行的刷新 Promise并发请求调用的都是同一个 Promise所以只发出一次刷新请求。等刷新完成所有等待中的请求都会拿到同一个新 token然后继续走各自的逻辑。请求拦截器里还做了一个“先判断、再刷新”的操作。如果 token 剩余时间不足 30 秒就先刷新再发请求这样就大量减少 401 重放的次数。响应拦截器里的 401 重放是兜底它负责处理那些“判断时还有效真正到服务端已过期”的请求。3.3 非浏览器环境下的类似做法这套思路不只能用到浏览器里。小程序里如果用的是wx.request可以把同样的逻辑封装进一个requestWithToken方法。Python 后端调用第三方接口时也可以用requests.Session配合线程锁实现。我在一个 Python 异步服务里做过简化版思路是维护一个全局刷新锁多个协程发现 token 过期后只有一个协程进入刷新流程其他协程通过asyncio.Condition等待刷新完成。import asyncio class TokenManager: def __init__(self, refresh_func): self._refresh_func refresh_func self._lock asyncio.Lock() self._refreshing False self._condition asyncio.Condition() async def get_token(self): if self._need_refresh(): async with self._condition: if self._refreshing: await self._condition.wait() else: self._refreshing True try: await self._refresh_func() finally: self._refreshing False self._condition.notify_all() return self._access_token这里用asyncio.Condition让等待的协程在刷新完成后被唤醒逻辑和浏览器端用 Promise 等待是一样的。4. 两个方案怎么选对比、边界与注意事项4.1 一张表看懂差异对比维度定时前置续期请求拦截 单飞刷新实现复杂度低setTimeout 即可中高需要处理并发和重放用户体验基本无感知无感知但第一个请求可能稍慢对请求延迟的影响无过期后的首批请求需要等刷新完成多端并发刷新容易互相踩掉内部有锁但多端外部仍可能冲突配置刷新接口白名单需要需要兜底能力弱定时器被节流就漏刷强401 兜底重试适合场景后台系统、单页面工具复杂前端、移动端、微服务封装从实现成本来看定时前置续期是最容易上手的适合对体验要求不高、用户量不大的后台类系统。请求拦截 单飞刷新更适合正式对外产品尤其是移动端、小程序和微服务场景。我在实际项目里的选型通常是如果只是接入一个第三方 API脚本单线程跑用定时或手动刷新都行如果是用户侧产品坚决用懒刷新。4.2 落地时的通用注意事项不管选哪套方案下面这些细节都值得注意。刷新接口必须放在白名单里。请求拦截器通常会统一给所有请求加 Authorization 头但刷新接口可能需要用旧 refresh_token 去换新 token它不应该走“检查 access_token 是否过期”的逻辑否则容易出现死循环。刷新的阈值不要设太大。前端如果提前 10 分钟就去刷新那 access_token 可能刚发出来 50 分钟就被替换了虽然不影响流程但请求频率会变高。我一般设在 30 秒到 5 分钟之间。如果 access_token 生命周期是 15 分钟就设 60 秒如果是 2 小时就设 5 分钟。不要让前端自己算服务器还剩多少时间。我在 2.2 里已经说过服务端最好返回expires_in秒数前端用接收时刻加秒数的方式算出过期时间。如果需要更精确后端可以额外返回一个server_time前端用这个值做基准。失败后的处理要有退避。刷新接口偶发失败很正常但不能失败一次就直接把用户踢下线。可以在失败后设置一个较短的定时器重试。只有连续多次失败或者刷新接口明确返回“refresh_token 已失效”时才清掉本地状态并跳登录页。4.3 refresh_token 轮换与多端踢下线很多系统会启用 refresh_token 轮换机制每次刷新成功后服务端不仅返回新的 access_token还会返回一个新的 refresh_token旧的 refresh_token 立即失效。这种策略安全性很高但客户端实现稍微马虎就会出问题。比如用户手机上有两个 APP 都使用了同一套登录凭证或者同一个账号在两个设备上登录两个设备同时去刷新 token后刷新成功的那个会让先刷新的 refresh_token 失效。先刷新的设备下一次刷新时就报 401用户不得不重新登录。这个问题没有银弹。从服务端角度可以做成 refresh_token 短时间内的“旧凭证容错”比如刷新成功后 60 秒内旧 refresh_token 仍然能再换一次。从客户端角度更安全的做法是不存储 refresh_token 在本地或者把它放进系统级 Keychain降低被窃和重复使用的风险但多设备同时使用同一个 refresh_token 依然是产品层面的设计问题最好在登录时让每个设备都有自己的 refresh_token而不是共享一个。5. 常见失败信息与排查实录5.1 本地 token 失效类错误怎么定位项目里最常遇到的一类报错是401 Unauthorized {code:30014,data:null,message:token is invalid.}这个报错一般意味着请求头里的 access_token 缺失、过期或格式不对。排查顺序我建议是这样的先看请求头里的 Authorization 是不是Bearer token。很多情况是前端存储 token 的 key 写错了或者刷新后新 token 没写回 storage导致后续请求还在用旧值。再看刷新逻辑到底有没有跑。在浏览器 Network 面板里直接搜/api/auth/refresh如果页面运行很久都没有一次刷新请求说明定时器或拦截判断压根没触发。这时候要检查expired_at是否被正确设置。最后看刷新接口的响应。如果刷新接口返回 400提示invalid refresh_token: empty string就是 refresh_token 没有被正确发送。这个原因通常很简单刷新时从本地存储里取 refresh_token但本地已经存过了过期时间或者用户手动清过一次 localStorage导致 refresh_token 为空。5.2 第三方 OAuth token exchange 失败的处理思路对接第三方 OAuth 时比自建 token 更容易出问题。网络上有不少类似的报错sign-in could not be completed token exchange failed: error sending request for url (https://auth.example.com/oauth2/token)这是 OAuth 授权码流程里用授权码 code 换取 access_token 这一步失败了。“error sending request”表示客户端到 token 端点的网络请求本身出错了。排查重点通常是授权码 code 是否已经使用过一次。OAuth 的 code 大多数是一次性的重复使用会失败。回调地址 redirect_uri 是否和发起授权时一致。一致指的是协议、域名、端口、路径全部一致差一个字符都不行。PKCE 的 code_verifier 是否被正确携带。如果授权请求里用了 code_challengetoken 请求就必须传 code_verifier否则服务端校验不过。客户端密钥 client_secret 是否过期或权限不足。还有一种报错是token exchange failed: token endpoint returned status 403 forbidden: country, region, or territory not supported这通常是服务提供方对请求来源地区做了限制属于服务端风控策略和应用代码无关。遇到这种状态码代码层面再调参数也没用只能查看服务方的文档和支持渠道确认当前地区是否在开放范围内。不需要在客户端反复重试重试只会浪费请求配额。5.3 一个通用的定位思路我排查 token 相关问题时会先回答三个问题现在是在哪个阶段报错是登录阶段、刷新阶段还是业务请求阶段请求头和请求体里有没有带上预期参数服务端返回的响应体里有没有额外的错误原因这三个问题对非常见错误特别有效。很多 token 问题的报错信息看起来是“token 问题”实际是回调地址写错、PKCE 参数丢失、refresh_token 为空之类很底层的原因。举个例子我看到有人的报错是your access token could not be refreshed. please log out and sign in again.第一反应是 refresh_token 失效了。仔细一问他本地根本没有保存 refresh_token页面一旦刷新所有凭证都丢了。这种情况要么是后端没返回 refresh_token要么是前端没正确存储和刷新逻辑本身没多大关系。我个人在实际操作中比较喜欢用“请求拦截 单飞刷新”作为主方案同时保留一个定时器做兜底定时器只负责“在 token 剩余时间小于 30 秒时主动刷一次”其余时间都交给请求拦截器。这样既有兜底又不会产生太多无谓的刷新请求。另外想分享一个小技巧刷新接口的响应里除了access_token和expires_in如果服务端还返回了refresh_token前端一定要把新的refresh_token也存下来。很多刷新失败都是因为只更新了 access_token旧的 refresh_token 一旦被服务端轮换掉下一次刷新就会直接失败用户还是逃不掉“被迫重新登录”的命运。