news 2026/9/15 16:28:39

无痛刷新Token:双Token机制与滑动续期方案全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
无痛刷新Token:双Token机制与滑动续期方案全解析

1. 为什么token过期会让用户“痛”——先把问题看透

做过登录模块的都知道,token这个东西天生就会过期。用户正填着一个长表单,突然请求弹了个401;或者页面刚加载完,前端拿着一个过期的token去拉数据,后端毫不留情地甩回来一句“未登录”。这种体验放在今天的产品里,基本等于劝退。我最早接手一个后台管理系统时,就因为token过期处理得粗糙,每个自然日都能收到好几条“为什么我刚登录没几分钟就被踢了”的反馈。

先说清楚问题的本质:token机制为什么非要设有效期?根本原因是安全。一个长期有效的token一旦被截获,等于把账号永久交给了别人,令牌被盗的风险窗口会被无限拉长。所以现在主流做法是给token一个相对短的生命周期,比如15分钟、30分钟、2小时。但有效期设得短,就意味着用户会被频繁打断。要解决这个矛盾,核心思路就是“无痛刷新”:让token在后台悄悄换新,用户全程无感知。

换句话说,无痛刷新解决的并不是“token别过期”,而是“token过期了,但用户仍然能顺畅继续操作”。这个诉求在JWT、OAuth2.0、微信小程序会话、前后端分离的Vue/React项目中几乎都会遇到。适合谁来参考?只要是自己在维护登录态、写鉴权中间件、或者遇到“token失效/请重新登录”这类问题的前端、后端、全栈开发,都能在这套思路里找到对应的落地办法。

我在这篇文章里准备分享两个方案,一个是最主流的双Token刷新机制,另一个是很多项目里偷偷在用的滑动续期方案。两个方案各有优缺点,适用场景也不同。我会把原理、代码骨架、踩过的坑一块儿写清楚,方便你直接照着改。

2. 方案一:双Token刷新机制——把“身份证明”和“续期凭证”分开

2.1 核心思路:为什么一次登录要发两个token

最早做登录,很多团队是一个token走天下,过期了就重新登录。后来大家发现太粗暴,于是有了双Token设计:登录成功后,后端一次性返回两个东西。

  • access token:短期有效的身份凭证,比如30分钟或2小时过期,请求接口时放在请求头里让后端校验。
  • refresh token:长期有效的续期凭证,比如7天或30天过期,平时不参与业务接口调用,只在access token快过期或已过期时,拿去换一个新的access token。

这个设计的核心考量是把“风险”和“体验”分开管理。access token有效期短,就算被偷了,攻击者也只有很短的操作窗口;refresh token虽然有效期长,但它只走一个固定的刷新接口,不读业务数据,风险面被控制住。用户在refresh token有效期内持续使用产品,理论上可以一直不重新登录。

在这个机制里有个关键点容易被新人忽略:access token过期后,前端不应该直接跳登录页,而应该先尝试用refresh token换新。只有refresh token也失效了,才真正需要重新登录。我在项目里经常看到前端拿到401就无脑清空localStorage然后弹登录框,这就是典型的把“token过期”和“refresh token过期”混为一谈。

2.2 后端实现:签发与刷新别看漏这两个细节

后端要实现双Token,流程其实很清晰:登录成功后签发一对token,刷新接口校验refresh token后签发新的access token。我用Node.js + jsonwebtoken举个例子。

const jwt = require('jsonwebtoken'); const ACCESS_SECRET = process.env.ACCESS_SECRET; const REFRESH_SECRET = process.env.REFRESH_SECRET; const ACCESS_EXPIRES = '30m'; const REFRESH_EXPIRES = '7d'; // 登录成功 function issueTokenPair(user) { const accessToken = jwt.sign( { userId: user.id, role: user.role }, ACCESS_SECRET, { expiresIn: ACCESS_EXPIRES } ); const refreshToken = jwt.sign( { userId: user.id, tokenType: 'refresh' }, REFRESH_SECRET, { expiresIn: REFRESH_EXPIRES } ); return { accessToken, refreshToken }; } // 刷新接口 function refreshAccessToken(refreshToken) { const payload = jwt.verify(refreshToken, REFRESH_SECRET); if (payload.tokenType !== 'refresh') { throw new Error('invalid refresh token type'); } const newAccessToken = jwt.sign( { userId: payload.userId, role: payload.role || 'user' }, ACCESS_SECRET, { expiresIn: ACCESS_EXPIRES } ); return newAccessToken; }

有几个细节容易被忽略。

第一,refresh token的payload里要加一个tokenType标记。原因很实际:access token和refresh token如果不加区分,理论上可以互相冒充。你拿一个access token去调刷新接口,如果payload里没有类型校验,接口可能会把它当成合法的refresh token用。加一个类型标记,刷新接口里随手就能拦掉这种误用。

第二,access token里不要放太多业务信息,userId、role这类就够了。有些人图方便,把用户昵称、头像、手机号全塞进JWT里,结果token体积越来越大,每个请求都在重复传这些无用数据。而且JWT默认只是Base64编码,不是加密,把敏感信息塞进去等于明文传输,这是安全上的一道硬伤。

第三,refresh token建议存数据库,至少存一个hash值,不要只依赖JWT本身的签名。为什么不直接靠签名就够了?因为JWT一旦签发,在自然过期前是无法主动吊销的。如果用户改了密码、被封号、或者怀疑token泄露,你没法让已签发的token立刻失效。把refresh token登记在库里,刷新时校验是否存在、是否有效,能实现主动踢人。实现了这个,才算把双Token机制用完整。

2.3 前端实现:用axios拦截器把“无感”落到代码里

后端的部分解决了“能刷新”,前端的部分要解决“怎么让用户无感”。我用Vue + axios举例,思路在React里完全一样。

前端无痛刷新的关键动作有三个:拦截401响应、拿refresh token换新access token、重放失败的请求。

// request.js import axios from 'axios'; let isRefreshing = false; let pendingQueue = []; function handleQueue(error, token = null) { pendingQueue.forEach(cb => cb(token, error)); pendingQueue = []; } const service = axios.create({ timeout: 10000 }); service.interceptors.request.use(config => { const token = localStorage.getItem('accessToken'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); service.interceptors.response.use( response => response.data, error => { const { response } = error; const originalRequest = error.config; if (response && response.status === 401 && !originalRequest._retry) { const refreshToken = localStorage.getItem('refreshToken'); if (!refreshToken) { window.location.href = '/login'; return Promise.reject(error); } originalRequest._retry = true; if (isRefreshing) { // 已有请求在刷新token,把后续请求排队 return new Promise((resolve, reject) => { pendingQueue.push((token, err) => { if (err) return reject(err); originalRequest.headers.Authorization = `Bearer ${token}`; resolve(service(originalRequest)); }); }); } isRefreshing = true; return axios.post('/api/refresh', { refreshToken }) .then(res => { const { accessToken } = res.data; localStorage.setItem('accessToken', accessToken); originalRequest.headers.Authorization = `Bearer ${accessToken}`; handleQueue(null, accessToken); return service(originalRequest); }) .catch(err => { handleQueue(err); localStorage.clear(); window.location.href = '/login'; return Promise.reject(err); }) .finally(() => { isRefreshing = false; }); } return Promise.reject(error); } );

这段代码我实际用了挺久,这里说几个核心判断。

最值得讲的是isRefreshing和pendingQueue这两段。很多项目一开始只写了“401就刷新token然后重放当前请求”,结果并发请求一多就炸了。比如页面刚打开时同时发出5个请求,5个都因为access token过期返回401,前端就会同时发起5次刷新请求。这不是浪费接口的问题,而是刷新接口可能同一秒换出5个新token,旧token在数据库里被顶掉或失效,后续操作反而更乱。用一个isRefreshing开关加一个请求队列,保证同一时刻只发一次刷新请求,其他并发请求等token更新完再重放,这才叫无痛。

originalRequest._retry这个标记也很关键。它防止请求重放后再次401导致死循环。如果没有这个标记,刷新后的请求如果因为别的原因又返回401,代码会再次触发刷新,形成无限循环,严重时浏览器直接卡死。

2.4 一个关键细节:刷新接口并发竞态的处理

上面代码里已经把并发竞态处理掉了,但我还是想单独拎出来讲一下原因。互联网公司里分布式并发问题可能要靠Redis、分布式锁来解决,但在前端做刷新并发控制,一个简单布尔值加一个数组就足够了。本质上是把所有同时到达的401请求“阻塞”在同一个刷新Promise上,等刷新完成后一次性放行。

我见过一些写法,用window.axios.interceptor里的计数器,甚至有人在localStorage里存一个isRefreshingFlag做多标签页同步。localStorage版本考虑的是多标签页场景:用户开着两个标签页,A标签页刷新了token,B标签页还拿着旧token。这个场景确实存在,但处理起来比较复杂。我的经验是,这个方案对绝大多数后台管理系统是过度设计,毕竟一个用户同时在多个标签页操作同一个系统的情况不算高频。先把单页面的并发竞态处理好,多标签页的需求等真的被用户投诉了再上不迟。

3. 方案二:滑动续期方案——让token不复存在“过期瞬间”

3.1 原理:只要你在用,token就一直顺延有效期

滑动续期这个思路非常直接:不使用refresh token,而是每次请求成功或用户有交互时,后端重新签发一个新的access token;如果请求失败返回401,后端也不立即拉黑,而是宽容地允许前端带着当前会话去换一次新token。

用生活化的例子来理解:双Token机制像是你办了一张年卡,进门时每次都刷临时凭证,凭证过期就再去柜台拿新的,只要年卡没过期你就能一直进。滑动续期则是把“进门”这个动作本身当成续期凭证——你每刷一次门禁,系统就把你的有效时间往后顺延30分钟。只要你在场馆里活跃,就永远被当作有效用户;如果你长时间不动,系统才会认为人已经走了。

这个方案的核心代码其实比双Token简单得多。

// 后端中间件伪代码 function authMiddleware(req, res, next) { const token = req.headers.authorization?.replace('Bearer ', ''); try { const payload = jwt.verify(token, SECRET, { ignoreExpiration: true }); const now = Math.floor(Date.now() / 1000); if (payload.exp - now < 10 * 60) { // 距离过期不足10分钟,签发新token const newToken = jwt.sign( { userId: payload.userId, role: payload.role }, SECRET, { expiresIn: '30m' } ); res.setHeader('X-New-Token', newToken); } req.user = payload; next(); } catch (e) { return res.status(401).json({ message: 'token无效' }); } }

这个实现里最关键的一步是jwt.verify时加了ignoreExpiration: true,先不校验过期时间,而是自己读exp和当前时间做比较。为什么这么做?因为标准做法是让已经过期的token直接校验失败,但如果token在服务端已经校验失败了,那“滑动续期”就没有意义了——你根本来不及续,请求就先挂了。服务端要做的是先允许请求进来,再决定要不要发新token。这在语义上就是“过期了但还网开一面”,所以必须手动处理过期逻辑。

3.2 实现方式:前后端各需要做什么

服务端生成新token之后怎么告诉前端?有两个做法,我推荐用响应头X-New-Token,效果是明确的:前端每次请求完检查响应头,拿不到就不动,拿到了就更新localStorage里的token。另一个做法是让后端在响应体里返回新token,但这种做法侵入性太强,每个接口都要改返回结构,没人愿意这么干。

// axios响应拦截器 service.interceptors.response.use(response => { const newToken = response.headers['x-new-token']; if (newToken) { localStorage.setItem('accessToken', newToken); } return response.data; }, error => { // 处理非token相关错误 return Promise.reject(error); });

前端的改动量比双Token方案小很多:不用维护刷新接口、不用处理并发队列、不用重放请求。只要在响应拦截器里检查响应头,有新的就更新,没有就当无事发生。对前端来说,这几乎是“零成本接入”,最大的工作量其实在后端。

配套的一个常见做法是前端加一个定时器,比如每20分钟主动调用一个轻量接口,让后端“顺手”续期一次。这不是必须的,但它能解决一个尴尬问题:用户一直停留在某个页面不发起新请求,token在页面停留期间静默过期了。等他看完一篇文章想点下一个“下一页”时,请求已经带着过期token出去了。如果后端只处理“请求成功时续期”,这个场景就会漏掉。加一个定时心跳接口,能兜住这部分场景。

3.3 适用场景和三个容易踩的坑

滑动续期方案最合适的是内部管理系统、B端后台、以及用户活跃度高的工具型产品。它做不了“踢人下线”的操作,因为过期时间永远在滑动。要是前端定时器每20分钟刷一次,token的过期时间就一直被往后推,理论上用户挂着不动,这个token也能一直续,被偷了之后就很难主动抹掉。所以对安全等级要求高的金融类App、涉及资金操作的系统,我不建议用这个方案,老老实实走双Token。

这个方案有三个典型的坑。

第一个坑:定时器在前台时会正常工作,一旦用户切到后台,浏览器会节流甚至暂停定时器。用户隔天回来打开页面,localStorage里的token早就无法通过校验了。处理办法是不要在启动时只设一个setInterval,可以在页面从后台切回前台时(监听visibilitychange),先发起一个静默请求换取新token。这一步很多人漏掉,导致“后台切回就掉线”的诡异问题。

第二个坑:服务端签发新token后,旧token在极短时间内可能会因为并发请求被后端的JWT校验拒绝。因为request A还没把新token带回前端,request B已经用旧token发出了,而旧token此时可能刚好过了原本的过期时间。处理办法是服务端在签发新token时,校验策略要放宽容忍窗口:比如每次校验时,只要exp距离当前时间还差5分钟,都不算失败,而是直接续期。这个5分钟窗口能兜住前端并发请求的间隙。

第三个坑:每次请求都检查“是否接近过期”这个逻辑本身,对高并发接口来说是一种微小的浪费。JWT的解密验签本身就有CPU开销,加上判断分支影响不大,但如果是每秒上万的网关层,建议把续期判断收敛到拦截器里统一做,不要在业务代码里到处写。

4. 两个方案怎么选——没有最好的,只有更合适的

4.1 双Token与滑动续期的完整对比

讲到这里,两个方案的画像已经很清晰了。为了让你看得更直观,我把它们的核心维度拉一个对比表。

对比维度双Token刷新机制滑动续期方案
实现复杂度较高,需刷新接口+并发队列较低,中间件改逻辑即可
刷新方式401后调用刷新接口换取新token请求成功时顺带续期或定时器触发
安全强度更高,refresh token可主动吊销较低,token无法主动踢人
主动下线能力支持,吊销refresh token即可不支持,只能等自然过期
并发要求需要处理并发刷新竞态需要容忍旧token短暂的“宽容期”
多标签页场景需要额外处理多标签同步相对更自然,新token写localStorage即可
适合的业务App、小程序、对外SaaS、金融等高安全场景后台管理、内部系统、活跃度高的工具应用
后端改造成本中等,需要新增刷新接口较低,改一个鉴权中间件
安全性风险泄露后影响面受refresh token过期时间控制泄露后影响面依赖“用户多久不活跃”

这个表不是一个死标准,但它基本说明了两个方案的气质差异:双Token方案是“安全优先”,用更多的代码换更可控的安全边界;滑动续期方案是“体验优先”,用更少的工作量换更顺滑的使用感受。

4.2 结合业务场景的落地选型建议

我在不同项目里两个方案都用过,说几个选型时的实际感受。

如果你在写微信小程序,用“wx.login拿code、后端拿code换openid”这种会话模型,我推荐直接上双Token方案。用户在小程序里长期不掉线是很重要的体验指标,而且小程序有专门的getUserInfo、button授权这种主动交互节点,非常适合做静默刷新。很多第三方登录、OAuth接入流程里经常报的“token exchange failed”,本质就是这个交换流程出了问题,用双Token模型更容易把问题排查出来。

如果你是开发Vue或React前后端分离的管理后台,用户群体是公司内部员工,滑动续期方案是我个人非常推荐的。这类系统用户粘性高、活跃度高、操作频繁,非常适合滑动续期的使用习惯。我做过一个运营后台,用了滑动续期后,基本再没收到过“被踢下线”的反馈,而且代码改动量比双Token方案少了一半还多。

如果你做的是面向C端用户的SaaS产品,用户可能一周才打开一次,那滑动续期就要格外慎重。用户隔了一周再回来,token肯定已经过期了,这时候如果前端没做好“token过期后的静默续期”,就会出现“刚打开就要重新登录”的糟糕体验。这种场景更建议双Token方案,refresh token给一个较长的有效期,至少能兜住用户以周为单位的访问频率。

5. 排查实录:那些年和token刷新相关的典型报错

5.1 常见报错速查表

做token刷新久了,遇到的报错基本能整理成一张速查表。这里我收集了实际项目中经常出现的几类错误,每一条都附上排查重点。

报错信息大概率原因处理建议
invalid refresh_token / empty string前端没有正确保存refresh_token,或刷新时传了空值检查登录态存储、刷新请求的请求体字段名
token exchange failed: token endpoint returned status 403第三方OAuth登录时授权码已过期或已使用检查授权码是否落到后端、是否重复使用、系统时间是否准确
token endpoint returned status 403 forbidden: country not supported调用方IP或账号所在地域不在服务范围确认服务商区域限制,或切换合规链路
failed to refresh token: 400 bad requestrefresh_token格式不合法或已被吊销检查refresh_token是否被clear过、服务端是否删除了记录
unexpected status 401 unauthorized: invalid tokenaccess token已过期且未触发刷新逻辑检查拦截器是否真的处理了401,_retry标记是否正常工作
token is invalid. code 30014token在服务端校验失败看是签名密钥不匹配还是过期时间判断错误
codex login token exchange failed第三方CLI工具在做OAuth码交换失败检查CLI配置中的client_id、client_secret、重定向地址是否一致
blocked deletion of token file本地token文件被占用无法删除关闭占用进程后重试

这张表里其实藏着一个通用经验:token类报错,60%以上是“前端没存好/没传对”,30%是“服务端密钥或有效期配置不一致”,剩下的才是网络和第三方服务问题。排查的时候不要一上来就怀疑加密算法和JWT库,先看数据流。

5.2 一个真实案例:axios拦截器引发的401循环

我接手过一个线上事故,现象是用户操作几分钟后页面就白屏,控制台里全是401报错,而且报错数量在快速增加。我第一时间怀疑是后端鉴权挂了,但后端日志里根本没有对应的请求记录。后来一排查,发现是前端拦截器写出了一个低级问题:拦截器里捕获到401后调刷新接口,但刷新接口本身也被axios实例拦截,而刷新接口恰好也返回了401,于是拦截器又触发了一次刷新,形成一个无限递归。请求量在几分钟内打满了一整个日志文件。

这个问题的根源是:刷新请求本身应该用一个独立的axios实例,不能走业务请求的同一条拦截器链。代码里一个常见的修复方式是在刷新请求里加一个特殊标记,拦截器判断到这个标记就直接放行,不再处理401逻辑。

我当时在文章里写过一句话,现在依然觉得很适用:处理好token刷新,本质上是在处理“程序对异常的容忍度”。一次401不算失败,两次401要警惕,三次401就应该果断收手去登录页,而不是让程序在后台无限打转。

5.3 两个经常被忽略的“隐形坑”

第一个坑是本地时间不准。JWT的exp判断依赖当前时间,如果用户电脑时间比真实时间快了10分钟,一个还有8分钟才过期的token,在本地可能已经被判为过期。这个问题在内部系统里经常出现,因为运维电脑的时钟同步策略没做好。前端如果直接用本地时间做token过期判断,就会造成误杀。我建议前端只把token当“透传数据”,不要用本地时间做判断,一切以服务端的401为准。

第二个坑是localStorage被清理。有些用户会用浏览器插件清理缓存,或者浏览器的无痕模式会阻止localStorage写入。这种情况下前端从localStorage里读到的token是null或undefined,请求头里可能根本没带上Authorization。这种现象和token过期表现一样,都是401报错,但根源完全不同。排查时可以打开DevTools的Application面板看一眼localStorage里有没有值,如果没有,基本就是存储被清掉或没写进去。

6. 写在最后的一点个人经验

这两个方案我都在生产环境里跑过,做一次总结性分享。

双Token方案更像一个“标准答案”,思路清晰、生态成熟,几乎所有主流前端框架都有人写过配套的拦截器封装,照着搬就能用。它的本质是用一个“长期凭证”换“短期凭证”,核心收益是可控制、可吊销。滑动续期方案则更像“巧劲”,代码写得简洁,用户体感也更顺滑,但安全性上会付出一些代价。

如果是一个新项目,我一般会这样起步:先做双Token方案,因为登录会话的模型一旦落地,后续加功能不会返工;等系统跑稳了,再根据用户反馈决定要不要在某些低危接口上用滑动续期做优化。如果是一个急着上线、后端资源有限的项目,滑动续期绝对够用,至少比“token过期就强制登录”好上一大截。

最后再分享一个小技巧。不管用哪个方案,一定要给前端留一个“强制登出”的开关。比如在响应拦截器里判断到refresh token也失效时,不要只做localStorage.clear(),最好调一次后端logout接口。这样服务端可以顺手把该用户的所有会话记录清理干净,避免失效refresh token堆积在数据库里。我见过很多项目漏了这一步,时间长了数据库里躺着几十万条从不过期的refresh token,每次刷新查库都慢半拍。

token刷新这块,踩过坑才算真正学会。把401当朋友、把请求队列当基础设施、把并发竞态当日常,你的登录体系基本就稳了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 16:28:17

企业微信授权登录全链路配置与国产系统适配指南

1. 为什么企业微信授权登录不是“套个SDK就完事”的简单活企业微信授权登录&#xff0c;听起来就是调个API、填个回调地址、拿个code换token——但我在给三家制造业客户做SaaS系统集成时发现&#xff0c;90%的失败不是出在代码上&#xff0c;而是栽在企业微信后台那几处不起眼的…

作者头像 李华
网站建设 2026/9/15 16:27:41

A股多因子选股实战:本土化因子设计与Python/通达信/ptrade协同落地

1. 这不是“黑箱选股”&#xff0c;而是用数据重新理解股票的逻辑多因子选股&#xff0c;这个词在量化交易圈里被说烂了&#xff0c;但绝大多数人连它真正解决的是什么问题都没搞清楚。我做量化策略开发和实盘管理整整11年&#xff0c;从最早用Excel手动回测&#xff0c;到后来…

作者头像 李华
网站建设 2026/9/15 16:27:01

AIGC检测工具对比与学术论文AI内容识别指南

1. 论文AIGC检测工具的必要性与挑战学术圈最近两年最热门的话题之一&#xff0c;就是如何辨别论文中AI生成内容&#xff08;AIGC&#xff09;的比例。去年Nature期刊统计显示&#xff0c;超过37%的投稿论文被检测出含有AI辅助写作痕迹。作为经常需要审阅学生论文的高校教师&…

作者头像 李华
网站建设 2026/9/15 16:26:15

RedHat 6.8安装Oracle 12c完整实战:从环境配置到建库全记录

干了这么多年Linux运维和Oracle DBA的活儿&#xff0c;最怕听到的一句话就是“帮我在服务器上装套Oracle”。Oracle安装本身并不难&#xff0c;难的是安装前那些绕不开的系统配置&#xff0c;还有各种依赖、权限、内核参数的坑。尤其是RedHat 6.8这种老系统&#xff0c;配Oracl…

作者头像 李华
网站建设 2026/9/15 16:25:23

阶次分析实战:角度重采样与变速工况振动频谱解析

简介&#xff1a;压缩包提供了一个基于MATLAB的阶次分析脚本&#xff0c;适用于旋转机械振动信号处理、状态监测与故障诊断等场景。核心功能包括转速信号读取、角度重采样以及阶次谱计算&#xff0c;配合steptdm相关思路&#xff0c;可将时域信号映射到角度域&#xff0c;帮助识…

作者头像 李华