
干前端这些年我被同行问得最多的问题之一就是“用户明明登录成功了一按 F5 就回到登录页后台查 session 也还在到底哪里错了”这个问题看着简单背后其实是登录状态刷新机制没捋清楚——状态存哪、过期怎么续、请求失败怎么重放一环扣一环。今天我把这套东西从原理到代码完整拆一遍适合正在做前后端分离项目或者被 token 过期折腾到失眠的同学。先说结论绝大多数刷新丢状态都不是后端登录接口的问题而是前端把登录态放错了地方以及缺少一套完整的过期续期策略。1. 登录状态为什么会刷新就丢先搞清楚状态到底存哪了1.1 刷新页面不等于登录失效但浏览器把内存清空了很多人以为“页面刷新”是一个轻量操作实际上刷新会重建整个页面执行环境。你用 Vuex、Pinia、Redux 或者一个全局变量保存的 token、用户信息全部都会被清空。浏览器只负责重新加载 JS 资源它不会帮你从后端恢复登录态除非你的代码里显式地做了“读持久化存储 - 重新放回内存 - 校验有效性”这个过程。所以判断标准很简单如果登录成功后token 只放在内存变量里刷新必然丢如果 token 放在 localStorage 里刷新后还在只不过你的代码没有把它重新取出来用从用户角度看还是“丢”了。我排查过很多项目后者的情况比前者更多。很多初学同学写登录跳转时直接在组件里存了个 token跳页根本不经过全局状态管理一旦刷新就断片。1.2 localStorage、sessionStorage、cookie谁才是适合放 token 的地方要解决刷新丢状态第一步就是选对存储位置。我按实际踩坑经验给你排个序。localStorage 是最常见的方案。它持久化在浏览器里刷新、关标签页、甚至关浏览器再打开都还在非常适合放 access token 和 refresh token。但它的弱点是同一源下的任何脚本都能读如果项目有 XSS 漏洞token 会被直接捞走。所以放 localStorage 的 token 有效期必须短最好配合 refresh token 一起用。sessionStorage 是很多人误用的地方。它的作用域是单个标签页刷新页面还在但你用另一个标签页打开同一个系统就是未登录状态关闭标签页再打开也没了。如果你发现自己“刷新一次登录一次开新标签页又要登录”多半是用了 sessionStorage。cookie 是最老牌的方式。它会被浏览器自动携带也可以设置 HttpOnly 让 JS 完全读不到。如果 token 走 HttpOnly cookieXSS 基本偷不到但这样前端没法手动把 token 塞进 Authorization 头后端要直接从 cookie 里读。如果是前后端分离且跨域cookie 的 SameSite、CORS 属性也要额外配稍微麻烦一点。存储方式刷新后关闭标签页后JS 可读主要风险内存变量丢失丢失是刷新即失sessionStorage保留丢失是多标签不同步localStorage保留保留是XSS 窃取HttpOnly Cookie保留保留会话cookie除外否CSRF 与跨域配置我自己更推荐“access token 放内存 refresh token 放 HttpOnly cookie”的组合后面我会讲这套方案怎么落地。如果你的项目暂时没法调整后端那先用 localStorage 存 token把刷新逻辑做对也比一刷新就踢人好得多。1.3 从“状态存哪”到“会话恢复”初始化时要主动做一次校验存好了 token只是第一步。页面刷新后应用启动时必须有一段“会话恢复”逻辑从 localStorage/ cookie 里读出 token放回内存 store然后发一个“获取当前用户信息”的请求验证这个 token 还能不能用。如果 access token 过期了就触发刷新流程拿到新 tokenrefresh token 也过期了才清空本地状态并跳转登录页。很多项目之所以“刷新就丢”就是少了这段初始化校验。用户刷新后前端一加载就直接走路由守卫发现内存里没有 token就当成未登录处理了。正确做法是路由守卫只做基础判断真正决定是否放行的应该是“本次会话是否恢复成功”。我在实际项目里会把 bootstrap 函数放在应用入口处等用户信息请求成功后再渲染页面避免白屏闪烁。2. 双 Token 刷新机制为什么比单 Token 体验好2.1 access token 是门禁卡refresh token 是备用钥匙先别急着写代码想一个问题为什么很多项目从单 token 升级成双 token因为单 token 很难平衡安全和体验。你把一个 token 有效期设为 7 天用户确实不用天天登录但 token 一旦被截获攻击窗口就是 7 天你把它设为 15 分钟安全是安全了用户吃个饭回来就要重新登录体验崩了。双 token 把这两个诉求拆开了。access token 有效期短专门放在每次业务请求的 Authorization 头里就像门禁卡丢了也就丢 15 分钟refresh token 有效期长只用来调用刷新接口换新的 access token平时不参与业务请求。就算 access token 泄露攻击者能用的时间窗口很短refresh token 虽然在网络里传输但只出现在一个接口里更容易做额外防护比如绑定设备、记录会话、支持吊销。2.2 过期时间别拍脑袋几个算明白的参数具体有效期设置我一般按产品场景分三档。内部管理系统比如公司后台、运营平台用户坐在电脑前连续工作access token 可以给 30 到 60 分钟refresh token 给 24 小时到 7 天。对外 SaaS 或移动端项目access token 给 15 分钟refresh token 给 7 天配合“滑动续期”机制只要用户 7 天内活跃过就一直不用重新登录。高安全场景比如支付后台、密钥管理access token 给 5 分钟refresh token 给 24 小时而且默认不接受“记住我”关闭浏览器就失效。我说一下滑动续期怎么算。假设 refresh token 有效期是 7 天用户在第 6 天又用了一下系统此时刷新接口返回了一个全新的 refresh token有效期重新按 7 天计算等于状态往后顺延。这样用户只要不是连续 7 天完全不用就不会被强制踢出。前端在刷新成功时一定要保存返回的新 refresh token很多项目忘了做这一步结果每天首次刷新都能成功但 7 天后突然全军覆没。2.3 刷新时机被动刷新和主动刷新怎么选刷新时机有两种常见策略。被动刷新是等业务请求返回 401 后前端拦截器去调刷新接口拿到新 token 后把原来失败的请求重新发一次。好处是代码路径统一所有过期情况都能兜住坏处是用户这次操作会多一次等待极端情况下并发请求多要处理“同时多个 401 都触发刷新”的问题。主动刷新是在请求发出前检查 access token 剩余时间如果快过期了就先刷新再发请求。好处是用户无感业务请求基本不会遇到 401坏处是你要知道 token 什么时候过期需要解析 JWT 的 exp或者自己在本地记录过期时间还要处理时钟偏移。我的建议是不要二选一而是组合。请求前做一次低成本的剩余时间检查剩余不足 60 秒就主动刷新如果检查没来得及生效或者后端提前吊销了 token请求还是返回 401再走被动刷新兜底。这套组合我在生产环境用了很久用户感知基本为零。3. 落地实操从登录到静默刷新的完整闭环3.1 第一步登录成功后的状态怎么存才不丢假设登录接口返回的数据结构是这样的{ accessToken, refreshToken, expiresIn, userInfo }。其中expiresIn是 access token 的有效秒数后端可以顺手下发前端就不需要解析 JWT 了。登录成功后我建议同时做三件事把 access token 放内存 store方便组件读取把 refresh token 放持久化存储方便刷新后恢复记录 token 的本地过期时间用于主动刷新判断。示例代码用 localStorage 演示// 登录成功的处理函数 function handleLoginSuccess(res) { const { accessToken, refreshToken, expiresIn, userInfo } res.data; // 内存态用于当前页面所有组件 authStore.setSession({ accessToken, userInfo }); // 持久化用于刷新后恢复 localStorage.setItem(access_token, accessToken); localStorage.setItem(refresh_token, refreshToken); // 本地相对过期时间避免依赖服务器时间 localStorage.setItem(token_expires_at, String(Date.now() expiresIn * 1000)); // 跳转首页 router.replace(/dashboard); }需要注意的是localStorage 里不要放用户敏感信息比如手机号、身份证token 本身已经够了。如果项目安全要求高refresh token 应该让后端直接写在 HttpOnly cookie 里前端这段代码里只需要保存 access token。3.2 第二步请求拦截器统一加身份信息请求层必须统一封装不能每个页面手动在请求里加 token。我习惯用 axios在 request 拦截器里读取 access token设置到 Authorization 头。import axios from axios; export const http axios.create({ baseURL: /api, timeout: 10000, }); http.interceptors.request.use((config) { const token authStore.accessToken || localStorage.getItem(access_token); if (token) { config.headers config.headers || {}; config.headers.Authorization Bearer ${token}; } return config; });这里有个小细节Bearer 后面有个空格写错会导致后端解析失败。另外如果 token 存在内存里但刷新页面后内存没恢复拦截器就会拿到空值所以 1.3 里说的初始化逻辑必须跑在业务请求之前。3.3 第三步响应拦截器实现 401 刷新与重放这是整个方案最关键的一段。我直接给出一个能跑的 axios 拦截器模式然后解释每行代码为什么这么写。let refreshPromise null; http.interceptors.response.use( (response) response.data, async (error) { const { response, config } error; // 状态码不是 401或者这个请求已经重试过直接抛出 if (!response || response.status ! 401 || config._retry) { return Promise.reject(error); } // 没有 refreshToken没办法续期直接清状态跳登录 const currentRefreshToken localStorage.getItem(refresh_token); if (!currentRefreshToken) { clearAuthAndRedirect(); return Promise.reject(error); } config._retry true; try { // 如果 refreshPromise 已经存在说明已经有请求在刷新了直接复用 if (!refreshPromise) { refreshPromise refreshAccessToken(currentRefreshToken) .then((res) { const newAccessToken res.accessToken; const newRefreshToken res.refreshToken || currentRefreshToken; localStorage.setItem(access_token, newAccessToken); localStorage.setItem(refresh_token, newRefreshToken); authStore.setAccessToken(newAccessToken); return newAccessToken; }) .finally(() { refreshPromise null; }); } const newToken await refreshPromise; config.headers.Authorization Bearer ${newToken}; return http(config); } catch (refreshError) { // 刷新失败refresh token 过期、无效或服务端拒绝 clearAuthAndRedirect(); return Promise.reject(refreshError); } } );这段代码解决了两个最容易翻车的问题。第一个是死循环config._retry标记保证同一个请求最多重放一次不会出现“401 - 刷新 - 重放 - 又 401 - 再刷新”的死循环。第二个是并发多个接口同时返回 401refreshPromise是同一个 Promise所有请求共享一次刷新结果不会把刷新接口刷爆。finally里把refreshPromise置空是为了让下一次过期事件能重新触发刷新。补充一个细节refreshAccessToken这个函数内部调用的刷新接口不能走同一个响应拦截器。否则刷新接口返回 401 后又会被拦截器再触发一次刷新形成循环。最简单的做法是单独创建一个 axios 实例给刷新接口用或者在后端把刷新接口的过期错误码和普通业务接口区分开。3.4 第四步后端刷新接口的设计与 token 轮换前端流程再漂亮后端刷新接口设计不到位也会前功尽弃。我以一个 Node.js/Koa 的简化接口为例说说核心点。router.post(/auth/refresh, async (ctx) { const oldRefreshToken ctx.cookies.get(refresh_token) || ctx.request.body.refreshToken; const payload verifyRefreshToken(oldRefreshToken); if (!payload) { ctx.status 401; ctx.body { code: REFRESH_TOKEN_INVALID }; return; } const newAccessToken signAccessToken({ userId: payload.userId }); const newRefreshToken signRefreshToken({ userId: payload.userId, sessionId: payload.sessionId, }); // 如果做轮换把旧的 refresh token 标记为已使用或加入吊销名单 await tokenStore.invalidate(payload.sessionId, oldRefreshToken); await tokenStore.save(payload.sessionId, newRefreshToken); ctx.cookies.set(refresh_token, newRefreshToken, { httpOnly: true, sameSite: lax, path: /api/auth, }); ctx.body { accessToken: newAccessToken }; });这里最重要的概念是 refresh token 轮换。每次刷新接口被调用除了返回新的 access token还要返回一个新的 refresh token同时让旧 refresh token 作废。这样做的好处是即使某次刷新请求的响应被截获旧 token 也已经用不了攻击者拿到的是一张作废的票据。refresh token 的载荷里一定要有sessionId不是只放 userId。因为同一个用户可能在手机和电脑同时登录吊销要按会话粒度进行。如果你用随机 token 存储方案而不是 JWT可以把 refresh token 当成一个随机字符串存数据库查到了就给新 token查不到就 401逻辑更简单也更容易控制吊销。3.5 初始化时的会话恢复与静默校验应用启动后需要先恢复会话再决定路由跳哪。我最常用的是写一个bootstrap函数放在入口文件的最前面async function bootstrap() { const token localStorage.getItem(access_token); if (!token) { router.replace(/login); return; } try { // 这个请求会带上 token如果 access token 过期 // 会被拦截器自动刷新并重放最终拿到用户信息 await http.get(/user/me); router.replace(/dashboard); } catch (e) { // 401 处理已经由拦截器完成这里只需要兜底 router.replace(/login); } }这段逻辑看起来简单但解决了刷新后“闪一下登录页又跳回来”的糟糕体验。你可以在应用挂载完成前先执行 bootstrap确认会话有效后再渲染主页面避免用户看到未登录态的 loading 或者空白页。我有一个额外经验如果项目里每个页面都会请求用户信息那没有必要在 bootstrap 里再发一次/user/me可以直接在路由守卫里读内存 store发现没有用户信息再走恢复流程能省一次请求。但如果只有少数页面请求用户信息还是老实初始化一次避免后续组件拿不到用户基础数据。4. 状态刷新常见翻车现场与排查思路4.1 并发请求集体 401刷新接口被调成筛子怎么办这是新手最容易踩的坑。一个页面打开可能有十个接口并行发出它们拿到的是同一个快过期的 access token于是几乎同时返回 401。如果每个请求的拦截器都独立调刷新接口刷新接口会被打十次。更严重的是如果 refresh token 做了轮换第一次刷新成功后旧 token 作废后面九次刷新请求全部失败原本应该成功的业务请求也跟着失败。解决办法就是 3.3 里的单例 Promise。第一个 401 触发刷新后把刷新 Promise 存在一个公共变量里后面所有请求的拦截器都去await这个同一个 Promise拿到新 token 后各自重放自己的业务请求。你可以打开浏览器 Network 面板验证正常情况下一次页面操作里 refresh 请求只会出现一次。如果出现多次说明你的刷新逻辑没有共享。4.2 刷新接口自己也返回 401不跳出循环的唯一办法如果 refresh token 过期、被吊销、或者用户会话被服务器踢掉刷新接口会返回 401。此时如果刷新接口走了同一个响应拦截器就会再次触发“刷新”形成死循环。必须在前端做三件事一是刷新接口使用独立的 axios 实例不走业务拦截器二是用config._retry标记限制业务请求最多重放一次三是刷新失败后无条件清空本地 token并跳转登录页。我还会做一个额外处理刷新接口返回的错误码和普通 401 区分开。比如普通过期是ACCESS_TOKEN_EXPIRED刷新失败是REFRESH_TOKEN_INVALID。这样前端可以精确判断到底是该刷新还是该踢出登录。4.3 多标签页不同步一个标签退出另一个还活得好好的用户经常开着两个标签页。假设他在 A 标签页点了退出登录A 清了 localStorage但 B 标签页内存里还留着 token还能继续访问接口。从安全角度看这是一个漏洞。解决办法是利用storage事件监听。window.addEventListener(storage, (event) { if (event.key access_token || event.key refresh_token) { if (!localStorage.getItem(refresh_token)) { // 其他标签页清除了登录态这里同步退出 authStore.resetSession(); router.replace(/login); } } });浏览器规定storage事件只会在其他标签页修改 localStorage 时触发当前修改自己那个标签页不会收到通知这正好符合我们的需求。如果 refresh token 存在 HttpOnly cookie 里JS 监听不了 cookie 变化那就要靠后端把会话状态做成可查询的或者通过BroadcastChannel让标签页之间通信复杂度会高一些。普通后台管理系统我建议先用 localStorage storage 事件成本最低。4.4 时间解析的坑时钟偏移和相对时间如果你用主动刷新策略就涉及“判断 token 还剩多少时间”。最直接的方式是解析 JWT 的exp字段。但 JWT 的exp是 UTC 时间戳单位是秒很多人会忘记乘以 1000 再和Date.now()比较结果把有效时间算短了十倍token 明明还能用就被提前刷掉虽然不算事故但会平白增加刷新频率。更推荐的方式是前端不解析 JWT而是用后端登录时返回的expiresIn计算相对过期时间。登录成功时记录tokenExpiresAt Date.now() expiresIn * 1000每次请求前比较tokenExpiresAt - Date.now()。这样既不受本地时钟偏差影响也不依赖 JWT 工具库还不用处理 exp 单位问题。唯一要小心的是Date.now()是毫秒expiresIn是秒别把单位搞混。4.5 登录状态刷新排查速查表我把这些年遇到过的问题整理成一张表遇到类似情况可以直接查。症状可能原因排查方式刷新页面立即回登录页token 存在内存或 sessionStorage缺少会话恢复逻辑DevTools Application 面板看 LocalStorage / SessionStorage请求返回 401 但刷新接口没发拦截器没匹配 401或刷新请求也走了业务拦截器Network 面板看请求序列事件拦截器断点刷新接口被调用多次并发 401 没有共享同一个 Promise看 Network 面板同一个刷新接口是否出现多次刷新成功但原请求仍然失败重放时使用了旧 token或请求头被清空打印重放时 config.headers.Authorization所有标签页同时被迫下线refresh token 有效期过短检查后端滑动续期和 session 策略偶发 401刷新后又能用多个请求并发旧的已经在路上被后端作废对关键接口做重试或用更长的 access token 到期时间表格最后一行要多说一句如果并发请求里有一个请求拿的是旧 token在刷新轮换发生后即使它是正常的业务请求也可能被后端判定为 token 失效。这种偶发 401 最烦人常见解法是把 access token 有效期适当放宽或者后端在验证 token 时容忍“同一个 session 下刚刚作废的旧 token”在很短的宽限期比如 30 秒内还能用一次。这种宽限期策略非常实用能显著降低并发重放报错概率。我自己后来在项目里的做法是把 access token 只放内存refresh token 放 HttpOnly cookie业务请求从内存读 access token 手动加头刷新接口靠 cookie 自动携带。这套组合虽然多写一点代码但 XSS 能偷到的只有 15 分钟内有用的 access token安全收益非常明显。如果你现在的项目还在用 localStorage 硬扛也别焦虑先把单例刷新和 401 重放这套核心逻辑做好这是收益最大的部分。等稳定了再慢慢把存储方式往更安全的方向迭代。