ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

无痛刷新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标签页刷新了tokenB标签页还拿着旧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当朋友、把请求队列当基础设施、把并发竞态当日常你的登录体系基本就稳了。
返回列表