
Token失效需要重新登录这几乎是每个带账号体系的系统都绕不开的坎。别看它只是“跳回登录页”这么一个小动作真要做得好用、做得稳里头的弯弯绕绕还真不少。我见过太多项目在这个环节翻车——有的Token一过期就白屏有的疯狂弹提示框更常见的是多个请求同时失败导致刷新Token的接口被刷爆。这篇文章我就结合自己做过的项目把这个“重新登录”的实现方案完整拆一遍从后端的过期策略到前端的拦截逻辑再到并发请求的处理都给你捋清楚。1. 项目概述与整体设计思路1.1 核心需求解析一个“重新登录”到底藏着多少细节先说清楚“Token失效需重新登录”这个需求到底包含什么。表面上它就是检测到Token失效后把用户踢回登录页。但如果只做到这一步你会发现用户体验特别糟糕用户可能正在填一个很长的表单结果提交时突然被踢出去填的内容全没了或者用户在A页面操作Token失效后系统没有任何提示点击任何按钮都没反应以为是系统坏了。所以一个合格的“Token失效重登”方案至少要覆盖这几层逻辑Token过期的判断前端和后端都要能判断Token是否失效不能只靠一方。失效后的用户引导跳转登录页之前要给用户明确的提示别让人一头雾水。登录态的恢复如果系统支持“记住我”或者有刷新令牌要先尝试静默续期实在续不上才让用户重新登录。多请求并发场景的处理这是最容易被忽略的点。一次页面操作可能同时发四五个请求Token失效时会同时收到多个401如果每个401都触发一次跳转刷新会出现竞态问题。安全性兜底不能因为图省事把Token放在localStorage里就什么都不管了一些基本的安全策略还是要有的。1.2 技术选型为什么用JWT 拦截器这套组合拳现在绝大部分Web系统的Token方案都是基于JWTJSON Web Token的。JWT的好处不用多说无状态、可携带用户信息、跨域友好但它的失效机制也一直是大家头疼的点——因为无状态服务端没法主动让Token失效只能等它自然过期。我自己的项目里用的是“JWT Refresh Token”双令牌方案前端用Axios拦截器统一处理。选这套方案的理由很实际Access Token访问令牌短时效比如设置成2小时减少Token泄露后的风险窗口。Refresh Token刷新令牌长时效比如7天或者30天用来在Access Token过期后静默换取新的访问令牌用户无感知续期。Axios拦截器统一处理401所有接口请求都经过拦截器在拦截器里做失效判断和跳转控制不用每个接口单独写错误处理代码量能省一大半。这套方案的核心思路就是能用刷新令牌解决的绝不打扰用户刷新令牌也失效了才让用户重新登录。注意我这里说的都是基于HTTP协议的常规实现不涉及任何非法的网络传输方式。JWT本身是一种公开的标准RFC 7519用于在各方之间安全地传输JSON对象很多正规系统都在用。2. 核心细节解析Token的生成、校验与生命周期管理2.1 JWT的生成与过期时间设置参数背后的计算逻辑先看后端怎么签发Token。我用的是Node.js jsonwebtoken这个库别的语言也差不多逻辑是相通的。签发Access Token的代码大致这样// Node.js jsonwebtoken 签发Access Token const jwt require(jsonwebtoken); function generateAccessToken(user) { return jwt.sign( { userId: user.id, username: user.username, roles: user.roles }, process.env.JWT_SECRET, { expiresIn: 2h } // 2小时过期 ); } function generateRefreshToken(user) { return jwt.sign( { userId: user.id, tokenType: refresh }, process.env.REFRESH_SECRET, { expiresIn: 7d } // 7天过期 ); }这里有两个关键决策点。第一是expiresIn的选择Access Token设2小时、Refresh Token设7天这是我个人比较常用的组合。为什么Access Token不能设太长因为JWT是无状态的服务端不能主动撤销一旦泄露在过期之前谁拿到都能用。2小时还算是个相对安全的窗口。Refresh Token之所以设7天是为了平衡用户体验和安全性——太短了用户天天登录太长了安全隐患大。第二是Access Token的payload里放哪些字段。我只放userId、username、roles这些不敏感的基础信息千万别把密码、手机号、身份证号这些敏感字段塞进去。JWT虽然带签名防篡改但payload本身是Base64编码的谁都能解码看到内容放敏感信息等于裸奔。2.2 后端校验逻辑如何识别Token过期与非法后端有了签发令牌的能力还得有校验中间件。我的做法是写一个统一的认证中间件对所有需要登录态的接口做校验// Node.js 认证中间件 function authenticate(req, res, next) { const authHeader req.headers.authorization; if (!authHeader || !authHeader.startsWith(Bearer )) { return res.status(401).json({ code: 401, message: 未登录或Token缺失 }); } const token authHeader.split( )[1]; try { const decoded jwt.verify(token, process.env.JWT_SECRET); req.user decoded; next(); } catch (err) { if (err.name TokenExpiredError) { return res.status(401).json({ code: 401, subCode: TOKEN_EXPIRED, message: 登录已过期 }); } return res.status(401).json({ code: 401, subCode: TOKEN_INVALID, message: Token无效 }); } }这里有一个细节很多人会忽略返回状态码要区分“过期”和“无效”。Token过期和Token被篡改是两回事过期可以通过刷新令牌来恢复无效则需要重新登录。我习惯在返回体里加一个subCode字段来区分这两种情况前端拦截器拿到subCode后就能分场景处理。还有一点刷新令牌的校验要单独写一个接口不能复用认证中间件。因为刷新接口的入参是Refresh Token和处理业务请求的验证逻辑完全不同// 刷新Token接口 app.post(/api/auth/refresh, (req, res) { const { refreshToken } req.body; if (!refreshToken) { return res.status(400).json({ code: 400, message: 缺少刷新令牌 }); } try { const decoded jwt.verify(refreshToken, process.env.REFRESH_SECRET); if (decoded.tokenType ! refresh) { return res.status(401).json({ code: 401, subCode: TOKEN_INVALID, message: 无效的刷新令牌 }); } // 生成新的Access Token const user findUserById(decoded.userId); const newAccessToken generateAccessToken(user); return res.json({ code: 0, data: { accessToken: newAccessToken } }); } catch (err) { return res.status(401).json({ code: 401, subCode: REFRESH_EXPIRED, message: 登录已过期请重新登录 }); } });另外涉及密码修改、账号冻结等安全性要求较高的场景我还会在Redis里维护一个“Token黑名单”一旦用户修改密码或封号立即把这个用户的所有令牌拉黑弥补JWT无法主动失效的短板。2.3 发布一个“重新登录协议”前后端协商一致的约定这块非常关键是“重新登录”功能正常运作的基础——前后端必须协商好一套统一的失效响应协议不能各搞各的。我们项目里约定的协议是场景HTTP状态码响应体subCode前端动作未携带Token401TOKEN_MISSING跳转登录页Token过期401TOKEN_EXPIRED尝试刷新Token刷新失败则跳转登录页Token无效/被篡改401TOKEN_INVALID清理本地登录态跳转登录页Refresh Token过期401REFRESH_EXPIRED清理本地登录态跳转登录页账号被踢出401TOKEN_KICKED提示“账号在其他设备登录”跳转登录页有了这份协议前端拦截器就能对着subCode写处理逻辑了。有一点要特别提醒不要只依赖HTTP 401状态码做判断一定要结合subCode分场景处理。因为401只代表“未认证”具体原因还得看业务码。如果你只判断401就直接跳登录页那上面的TOKEN_EXPIRED场景就没法实现静默续期了用户会每隔两小时被强制踢一次体验很差。3. 实操环节前端拦截器完整实现与关键代码解析3.1 Axios响应拦截器的完整实现先处理后端返回的401前端这边我用的是Vue Axios技术栈但思路在React、原生小程序上也通用。大家看我下面的实现重点不是抄代码而是理解处理流程的设计。// request.js - Axios实例配置 import axios from axios // 创建独立的axios实例 const service axios.create({ baseURL: /api, timeout: 15000 }) // 请求拦截器自动携带Token service.interceptors.request.use( (config) { const token localStorage.getItem(accessToken) if (token) { config.headers.Authorization Bearer ${token} } return config }, (error) Promise.reject(error) ) // 响应拦截器核心逻辑 service.interceptors.response.use( (response) { // HTTP 2xx时正常返回 const res response.data if (res.code 0) { return res } // 业务码非0按业务错误处理 return Promise.reject(new Error(res.message || 请求失败)) }, async (error) { const { response, config } error if (!response) { // 网络错误或超时 return Promise.reject(error) } if (response.status 401) { const subCode response.data?.subCode try { // 尝试刷新Token const newToken await refreshAccessToken() // 刷新成功用新Token重发原请求 config.headers.Authorization Bearer ${newToken} return service(config) } catch (refreshError) { // 刷新失败跳转登录 handleLogoutRedirect(subCode) } } return Promise.reject(error) } )这段代码里有几个设计要点值得细说。第一为什么响应拦截器里要写成async/await而不是直接返回Promise.reject因为我们需要在拦截器里做异步操作——调用刷新Token的接口。只有等刷新接口返回结果后才能决定是“重发原请求”还是“跳登录页”。用async/await可以很自然地表达这个流程。第二为什么刷新成功后用service(config)重发原请求因为config保存了原始请求的所有信息URL、参数、headers等直接把它重新传给同一个axios实例Axios会自动走一遍请求拦截器重新带上新的Token。这样用户那次因为Token过期而失败的请求能在无感知的情况下自动重试成功不需要用户手动再点一次按钮。3.2 刷新令牌防并发如何避免请求风暴很多新手会在这里踩坑。假设用户打开一个页面页面同时发起了5个接口请求这5个请求都带着已经过期的Token都会得到401。如果每个401都触发一次refreshAccessToken()那刷新Token的接口就会被同时调用5次。其中4次会刷新失败刷新一次后旧的Refresh Token可能就失效了或者返回多个新Token导致状态不一致而且白白增加服务器压力。解决办法是让多个请求共享同一个刷新Promise核心思路是加一个“正在刷新”的锁// 刷新Token防并发处理 let refreshPromise null function getRefreshTokenPromise() { const refreshToken localStorage.getItem(refreshToken) if (!refreshToken) { return Promise.reject(new Error(No refresh token)) } // 调用刷新接口 return service.post(/auth/refresh, { refreshToken }) .then((res) { const { accessToken } res.data localStorage.setItem(accessToken, accessToken) return accessToken }) } function refreshAccessToken() { if (!refreshPromise) { // 如果当前没有刷新请求在进行就发起一个新的 refreshPromise getRefreshTokenPromise() .finally(() { // 无论成功失败最后都要释放锁 refreshPromise null }) } return refreshPromise }这段代码的精髓在于refreshPromise这个变量。第一次401进来时refreshPromise还是null所以会发起一个真实的刷新请求并把它赋值给refreshPromise。后续的401进来时发现refreshPromise已经有了值就直接返回这个已有的Promise不再发起新的刷新请求。这样一来无论同时有多少个请求因为Token过期而失败最终只会有一个刷新请求发出去其他请求都在等待这个刷新结果。刷新成功后这些请求都能拿到新的Token并各自重发刷新失败后它们会一起走跳转登录的逻辑。3.3 重发请求的特殊处理这些请求不能盲目重发虽然“重发原请求”这个方案很优雅但有些请求是不能盲目重发的这一点非常容易踩坑。第一类是不能重发的请求是POST请求且接口不是幂等的。比如用户提交订单、转账这种操作如果第一次请求其实已经到了服务端只是返回响应时Token过期了导致前端收到401这时候重发就会造成重复下单、重复扣款。我在实际项目中就遇到过这种问题后来加了一个白名单机制对于非幂等接口Token过期后不自动重发而是直接提示用户“登录已过期请重新登录”让用户自己确认操作。第二类是刷新Token接口本身。如果刷新接口返回401说明Refresh Token也过期了这时候不能再拿它去刷新自己否则会陷入死循环。在我的拦截器代码里刷新逻辑用的是service.post(/auth/refresh, ...)如果这个接口也返回401会再次进入响应拦截器然后再次调用refreshAccessToken()形成无限循环。解决办法是给刷新接口的请求加一个标记在拦截器里识别到这个标记就直接放行不做刷新处理// 请求拦截器里对刷新接口标记 if (config.url.includes(/auth/refresh)) { config._isRefreshRequest true return config } // 响应拦截器里判断 if (response.status 401 config._isRefreshRequest) { // 刷新接口本身401直接走登出逻辑不触发刷新 handleLogoutRedirect(REFRESH_EXPIRED) return Promise.reject(error) }3.4 登出跳转的正确姿势清理数据后再跳别直接跳拿到“需要重新登录”的结论后跳转也是一门学问。我见过不少项目直接window.location.href /login就完事了结果用户登录后还能看到之前页面的残留数据或者因为上一个路由的状态没清干净出现各种诡异问题。我这边推荐的流程是先清理本地存储localStorage.removeItem(accessToken)、localStorage.removeItem(refreshToken)、localStorage.removeItem(userInfo)。再清空内存中的状态如果你用了Vuex或Pinia要把用户信息和Token状态全部置空。记录当前页面的路由地址方便用户重新登录后跳回原页面。最后才跳转到登录页。// 统一登出处理 function handleLogoutRedirect(subCode) { // 1. 清理本地存储 localStorage.removeItem(accessToken) localStorage.removeItem(refreshToken) localStorage.removeItem(userInfo) // 2. 清空应用状态以Pinia为例 const userStore useUserStore() userStore.resetState() // 3. 记录当前路由用于登录后跳回 const currentRoute router.currentRoute.value const redirectPath encodeURIComponent(currentRoute.fullPath) // 4. 带参数跳转登录页 router.replace({ path: /login, query: { redirect: redirectPath } }) }关于提示用户的方式我的经验是如果是一次静默续期失败即后台自动刷新失败用Message组件弹一个轻提示“登录已过期请重新登录”就够了不要弹那种必须用户点确认的Modal对话框。因为用户可能正在专注操作别的事情强制弹窗会打断他。但如果是因为“账号在其他设备登录”被挤下线建议用Modal因为这件事本身就比较严重需要用户明确知道发生了什么。4. 实战记录从“崩溃重登”到“无感续期”的演进过程4.1 第一版方案复盘没做并发控制时踩了哪些坑我们项目的第一个版本其实特别简单粗暴响应拦截器里判断到401就直接跳登录页。当时测试环境基本没发现问题因为测试的时候通常就是一个请求一个请求地操作很少遇到并发场景。上线后第一个问题就爆了很多用户反映填了半个小时的表单提交时莫名其妙被踢出去实际是Token已经过期了但系统没有任何提示点击提交直接跳登录页填写的内容全丢。第二个问题是并发导致的刷新风暴。我们后来改了逻辑让401去刷新Token但没做防并发处理。结果用户在用一些密集型操作的页面上一次能触发十几个刷新请求把认证接口的QPS瞬间拉高几十倍数据库都被打到了。日志里全是“Refresh Token已使用”的报错因为刷新令牌设计成了一次性的多个并发刷新请求只有一个能成功。4.2 无感续期方案有效期快过期时提前刷新除了在收到401后再去刷新Token我们后来还加了一个“主动续期”的机制在Access Token过期前的前10分钟如果检测到用户正在操作就先主动调用刷新接口换新的Access Token。这样能大大减少401的出现频率用户体验会更好。实现原理不复杂客户端在本地存一份Token的过期时间戳每次发请求前检查一下当前时间和过期时间的关系// 主动续期检测 function checkTokenExpiry() { const expireAt localStorage.getItem(tokenExpireAt) if (!expireAt) return const now Date.now() const SIXTY_SECONDS 60 * 1000 if (expireAt - now SIXTY_SECONDS) { // 剩余时间不足60秒默默刷新 refreshAccessToken().catch(() { // 静默刷新失败不处理等401再走兜底 }) } }这里要注意的是主动续期也必须要做防并发否则多个请求同时发起还是会触发多次刷新。我们直接复用了上面写过的refreshAccessToken()函数因为它内部已经做了Promise复用天然是并发安全的。4.3 多端登录与“被踢下线”的处理实践我们系统后来支持多端同时登录Web端、移动端这就引出了一个新的需求同账号在不同设备登录时怎么处理。我们当时是这么设计的每个设备登录时颁发独立的Access Token和Refresh Token。当我们收到“在该设备上执行了密码修改”或“管理员强制下线该账号”时后端在Redis里给该用户的所有Refresh Token加黑名单同时给当前登录的所有设备推送一个下线事件。各端收到下线事件后前端自动携带本地的Refresh Token请求一个“下线确认”接口其实这个接口就是校验Refresh Token是否在黑名单里返回401后走统一的“重新登录”流程。这样一来即使JWT本身无法由服务端主动失效我们通过“Refresh Token黑名单 前端主动校验”的组合方式实现了类似“服务端踢人”的效果。当然这个方案的复杂度会上来一些具体要不要做就看你的业务到底需不需要“管理员能强制踢人”这种功能了。5. 常见问题与排查技巧实录5.1 问题速查表遇到这些情况怎么定位我把自己做过的一个真实项目里遇到的高频问题整理成了下面这张表基本覆盖了实践中的大部分场景症状可能原因排查方法解决办法每隔固定时间就跳登录页Access Token过期但Refresh Token流程没生效看Network面板确认401后有没有发/auth/refresh请求检查拦截器里刷新逻辑是否被触发注意接口URL是否正确刷新Token接口被反复调用刷新逻辑没有做防并发控制看Network面板确认同一个刷新URL是否出现多次采用Promise复用方案见上文refreshPromise的写法刷新接口一直401死循环刷新接口本身也被响应拦截器拦截了在刷新接口的请求头上打标记在拦截器里判断该标记后直接放行给刷新请求加_isRefreshRequest标记401时直接登出Token没失效但请求401refreshToken存的是旧值或者服务端密钥更新过对比后端JWT_SECRET和签发时的旧配置用jwt.io解码看过期时间统一密钥配置发布策略上避免运行时切换密钥刷新成功后原请求未重发没有在刷新成功后重新调用service(config)在拦截器里打断点看刷新完之后代码有没有继续走确认使用return service(config)或return axios(config)重发用户被踢后残留页面数据登出时只清Token没清应用状态检查登出函数是否调用了store的reset方法在handleLogoutRedirect里执行状态清理用户重新登录后没跳回原页面登录页跳转时没处理redirect参数看登录成功后跳转代码是否读取了query中的redirect登录失败不弹错误提示成功后读redirect回跳5.2 一个隐蔽BugToken刷新导致的白屏问题这里分享一个很有意思的排查经历。当时有个同事反馈说有时候用户在页面上停留超过2小时再操作点击某个按钮后页面会白屏。当时第一反应是路由跳转出了问题排查半天也没定位到。后来我在本地模拟了“Token过期后点击按钮”的场景通过Chrome开发者工具打了断言才发现问题出在响应拦截器和路由守卫的配合上。流程是这样的用户点击按钮触发请求 → 请求401 → 拦截器里调用刷新接口 → 刷新接口也401Refresh Token也过期了→ 进入handleLogoutRedirect去router.replace(/login)。但这时候页面里的请求还没被catch处理抛出的错误往上冒泡传到了组件里组件又把这个错误传入一个全局错误处理的某个地方那个地方误判为“页面初始化失败”直接把应用卸载了于是白屏。解决方式其实很简单在跳转登录页之前把拦截器返回的错误统一处理成一种业务错误类型让上层组件知道“这个错误已经被处理过了不要再向上抛”。用Axios的机制来理解就是给错误对象打一个标记// 在响应拦截器里 catch (refreshError) { handleLogoutRedirect(subCode) error._handled true // 标记已处理 return Promise.reject(error) } // 上层组件的catch里 .catch((error) { if (error._handled) return // 已被拦截器处理不处理 // 正常错误逻辑 })后来我把这个方案写成了团队内部的规范凡是拦截器里已经处理过的错误都必须加_handled标记防止上层二次处理。这条规范帮我们避免了好几个类似的坑。5.3 关于Token存储的位置我说点经验很多教程会说Token放在localStorage里就行方便省事。但实际上localStorage有XSS脚本注入的风险——只要页面里任何一个环节被注入了脚本Token就可能被偷走。所以我个人更推荐把Access Token放在内存变量里刷新页面即失效把Refresh Token放在HttpOnly Cookie里浏览器脚本无法读取。这样做的代价是用户刷新页面后内存里的Access Token会丢失需要拿HttpOnly Cookie里的Refresh Token重新换一个Access Token。多了一步“初始化时静默恢复登录态”的逻辑但这个代价是值得的安全等级明显上了一个台阶。如果你的项目已经用了localStorage存储Token至少要确保开启httpOnly来防止脚本读取Cookie里的关键信息然后给前端发布一个安全编码规范尽量避免使用innerHTML、document.write之类的直接DOM操作从源头上降低XSS风险。5.4 日志与监控怎么定位“用户被莫名踢出”的问题最后一个实战经验是关于日志的。线上环境出了“用户被踢出登录”的客诉你第一反应是想看用户当时的登录状态、Token是什么时候签发的、Refresh流程到底走了没有。但这些信息如果在代码里没打日志就完全不可查。我的做法是做一个简单的登录态事件埋点把关键节点都记录下来事件记录内容请求发出请求URL、请求时间、是否携带Token收到401401的subCode、请求URL、当时本地Token是否存在刷新开始刷新请求URL、当时Refresh Token是否存在刷新成功新Token的有效期刷新失败失败原因Refresh过期/Refresh无效登出跳转跳转原因、跳转来源页面这些日志不用收集得很全只需要打到浏览器Console或者通过一个轻量级的日志上报接口传到服务端。后面排查问题时只要让客诉用户复现一次场景把时间点发过来对着日志一查基本都能定位到问题。这套东西花不了多少时间但排查疑难杂症时是真的救命。6. 一些额外的思考怎么把“重新登录”做成加分项正常的“重新登录”实现了其实是及格线。要想做到优秀还得在细节上多花点心思。我在项目里亲测有效的一个小优化是在登录页回填用户的手机号/用户名。用户被踢出后跳转登录页登录页能通过一个参数比如rememberAccount把上一次登录的账号显示出来用户只需要输入密码就好。别小看这个体验细节能减少用户很多登录成本。另一个优化是登录页的文案差异化。是“登录已过期请重新登录”还是“账号已在其他设备登录”这两者给用户的心理感受完全不同。前者是正常的失效处理后者暗示了一个安全事件用户看到会紧张。所以我们的文案都是根据subCode动态显示的不是笼统一句“请重新登录”。再有一点如果前面提到的“无感续期”机制设计得当绝大多数情况下用户根本意识不到自己的Token曾经失效过。这就涉及到产品设计层面了“重新登录”这个动作本身应该是一个兜底方案而不是常见路径。如果你的产品里用户每天都得登录好几回那说明你的Token生命周期设计出了问题得回头检查有效期参数和刷新机制了。整体来说“Token失效需重新登录”这个功能看着不起眼但它是用户对系统稳定性和安全性的直接感受点。把这套逻辑理顺了代码质量和使用体验都能上去一大截。希望这篇梳理能给你在项目中做类似功能时提供一些参考少走点我踩过的弯路。