ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

前端Token处理全攻略:从登录到续签的安全实践

前端Token处理全攻略:从登录到续签的安全实践 1. 先搞清楚 Token 到底在解决什么问题一次线上事故的复盘先说个真事。去年我们有个内部管理系统上线前端登录后拿到的 Session 存在服务端内存里用户量一上来动不动就掉线后来运维查了半天发现是服务端 Session 存储撑爆了导致部分用户被随机踢出。那次之后我们才痛下决心把整个认证体系迁到 Token 方案上。很多前端同学对 Token 的印象就是登录后返回一段字符串请求时放在 Header 里这个理解没错但太浅了。Token 的核心价值在于无状态认证——服务端不需要保存你的会话信息只需要验证 Token 本身合法、没过期就认定你是你。这对横向扩容、多端登录、微服务拆分都极其友好也正是现在前后端分离架构里 Token 成为标配的根本原因。另一个让 Token 流行的原因是跨域友好。传统 Session Cookie 方案在跨域场景下要处理 SameSite、CSRF、Cookie 共享等一系列问题而 Token 放在请求头里天然不受跨域限制对接第三方平台时尤其好用。你调用 GitHub API、OpenAI API都是靠 Token或者叫 API Key来认证的。这篇文章我会完整走一遍前端操作 Token 的流程从怎么拿、存哪里、怎么带到失效了怎么处理、续签怎么做再到那些热搜词里反复出现的token exchange failed到底是什么鬼。内容都是我在实际项目里验证过的方案尽量少讲虚的多给能直接抄走的东西。提示全文以常见的 JWTJSON Web Token为主线因为它是目前应用最广的标准格式。其他魔改 Token 的原理大同小异区别在载荷格式和校验方式上。2. 前端拿到 Token 的几条路径从登录框到第三方回调2.1 账号密码登录与授权码模式最常见的场景就是用户在登录页输账号密码前端把凭证 POST 给认证接口服务端校验成功后返回 Token。这里有一个经常被忽略的细节登录接口返回的 Token 分不分 access_token 和 refresh_token小项目早期往往只返回一个 Token配上较长的过期时间比如 7 天简单粗暴够用。但一旦用户量起来或者安全要求提高就得分出短期 Tokenaccess_token通常 15 分钟到 2 小时和长期 Tokenrefresh_token通常 7 到 30 天。前端拿到的是两个不是一坨。// 登录成功后的典型响应 { access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c, refresh_token: dGhpcy1pcy1hLXJlZnJlc2gtdG9rZW4tZXhhbXBsZQ, token_type: Bearer, expires_in: 7200 }第三方登录OAuth 2.0则走了另一条链路前端先跳到授权页用户同意后服务端用 authorization_code 换 Token。整个过程前端能参与的环节不多但有一个点要注意回调 URL 里可能带着 code前端要用它调换 Token 接口换完立刻清掉 URL 里的 code防止历史记录和 Referer 泄露。2.2 从 URL 参数、iframe 消息、剪贴板接收 Token实际开发中还有一些野路子在跑。比如老系统集成A 系统登录后要跳转到 B 系统B 系统从 URL 上拿 token 参数直接开工。这种做法不是不行但我强烈建议拿到后立刻做两件事// 从 URL 获取 token 并清理 function extractTokenFromUrl() { const params new URLSearchParams(window.location.search); const token params.get(token); if (token) { // 存入你选择的存储方案下一节细讲 storage.set(access_token, token); // 用 history.replaceState 把 URL 里的 token 抹掉 const cleanUrl window.location.origin window.location.pathname; window.history.replaceState({}, , cleanUrl); } return token; }如果 B 系统嵌在 iframe 里A 系统通过 postMessage 把 Token 传过来那还需要额外校验 event.origin不能用*。否则随便一个恶意页面都能往你的 iframe 里塞假 Token这属于安全红线具体后面说。还有一个场景是用户从外部工具比如 Git 私服、云平台复制 Token 粘贴到系统里使用。这类 Token 通常是个人访问令牌权限范围各不相同前端要做的就是传给后端校验不要让 Token 以明文形式出现在任何前端日志里。2.3 第三方服务报错里的 Token 谜团热搜词里有一串很显眼的报错sign-in could not be completed token exchange failed: token endpoint returned...、token exchange failed: token endpoint returned status 403 forbidden: country, region, or territory not supported。这类报错经常让前端同学一脸懵——明明我代码没写错为什么登录就是失败拆开看就清楚了。token exchange failed说的是Token 交换失败也就是你用授权码或 refresh_token 去换 access_token 时服务端的 Token 端点返回了错误。401 通常是 refresh_token 失效或已被吊销403 forbidden 后面跟的country, region, or territory not supported则是服务商在 Token 端点做了地区限制——这不是前端代码问题是你的网络出口或账号所属区域不在服务商的支持列表里。遇到这类报错正规处理方式是确认账号所在区域是否受支持更换服务商官方支持的区域账号或者改用该服务商合法可用的其他接入方式。在国内开发时如果某个海外服务的 Token 端点 403老老实实走合规渠道不要想着绕过限制那不是前端代码能解决的问题也不是应该解决的问题。3. Token 存哪里localStorage、Cookie 还是内存我最终选了哪种3.1 三个候选方案的真实对比前端存 Token 的位置翻来覆去就三个localStorage、Cookie、内存变量。很多面试题问Token 存在 localStorage 安全吗标准答案是不安全因为 XSS 能读到但实际工程里全是存 localStorage 的为什么因为省事、刷新不丢、代码简单。我的态度是分场景方案刷新页面后是否保留XSS 风险面跨标签页适用场景localStorage保留高脚本能直接读同步方便内部系统、非核心业务sessionStorage保留至标签关闭高各标签独立单标签应用、临时授权CookieHttpOnly保留低JS 读不到自动携带老系统、同域部署内存变量刷新丢最小不共享高安全场景、SPA 壳Cookie 方案能把 Token 变成 HttpOnlyJS 拿不到XSS 确实读不到但你要撞上的坑是 CSRF 防护。Cookie 会随请求自动带上攻击者可以诱导你浏览器发请求。要防这个就得再加 CSRF Token又要处理不同域下的 Cookie 写入SameSite 属性够你调半天。现在新项目我基本不推荐纯 Cookie 方案除非是同域 SSR 老项目。3.2 我的推荐组合localStorage 存 refresh内存存 access我最终落地的方案是组合拳access_token 放内存变量refresh_token 放 localStorage同时在 localStorage 里存一个是否已登录的标记。刷新页面时先用内存里的 access_token 发请求如果没有再从 localStorage 拿 refresh_token 去静默换新 access_token。这样做的逻辑很直白access_token 使用频率最高、泄露危害最大放内存意味着任何脚本在页面空闲时都读不到它只有运行时才有refresh_token 生命周期长但使用频率低只在换 access_token 时发一次放 localStorage 是为了刷新页面后还能找回登录态。// 极简版 Token 存储封装 const tokenStore { _access: null, setAccess(token) { this._access token; }, getAccess() { return this._access; }, setRefresh(token) { localStorage.setItem(refresh_token, token); localStorage.setItem(is_logged_in, 1); }, getRefresh() { return localStorage.getItem(refresh_token); }, clear() { this._access null; localStorage.removeItem(refresh_token); localStorage.removeItem(is_logged_in); } };3.3 刷新页面后白屏或跳登录页问题往往出在这里组合方案最大的坑在刷新时机。如果用户在页面加载瞬间就发了请求而 refresh_token 换 access_token 的异步请求还没回来就会出现先发一个没有 Token 的请求 - 401 - 被踢到登录页的尴尬。解决办法是做一个统一的等待 Token 就绪的 Promise在所有请求发出之前先 await 它let refreshPromise null; function ensureAccessToken() { if (tokenStore.getAccess()) { return Promise.resolve(tokenStore.getAccess()); } if (!refreshPromise) { refreshPromise refreshAccessToken() .then((newToken) { tokenStore.setAccess(newToken); return newToken; }) .finally(() { refreshPromise null; }); } return refreshPromise; } async function apiRequest(config) { const token await ensureAccessToken(); // 带上 token 发请求... }这样并发 10 个请求时只有第一个会触发 refresh其他 9 个都排队等同一个 Promise不会重复换 Token也不会 401。4. 每次请求怎么带上 Token拦截器封装与特殊场景处理4.1 Axios 拦截器与 Fetch 封装的标准写法用 Axios 的话拦截器是唯一正确的挂载点。常见错误是每个 API 函数里手动加 header十来个接口还行上百个接口你根本维护不过来。// axios 请求拦截器 axios.interceptors.request.use( async (config) { const token await ensureAccessToken(); if (token) { config.headers.Authorization Bearer ${token}; } return config; }, (error) Promise.reject(error) ); // axios 响应拦截器统一处理 401 axios.interceptors.response.use( (response) response.data, async (error) { const { response } error; // 判断是不是 Token 过期导致的 401 if (response response.status 401) { // 避免死循环标记请求是否已经重试过 if (!error.config._retried) { error.config._retried true; try { const newToken await refreshAccessToken(); tokenStore.setAccess(newToken); error.config.headers.Authorization Bearer ${newToken}; return axios(error.config); } catch (refreshError) { // refresh 失败跳登录页 redirectToLogin(); } } else { redirectToLogin(); } } return Promise.reject(error); } );如果用原生 Fetch就得自己包一层。思路一样先 ensureAccessToken再带 Authorization 头状态码 401 时尝试续签后重放请求。注意 Fetch 的Authorization头写法是Authorization: Bearer token注意空格少了这个空格服务端解析出来是空 Token。4.2 不只是普通请求WebSocket、SSE、图片标签里的 Token大项目里 Token 要覆盖的网络请求不止 XMLHttpRequest 一种。WebSocket握手阶段要把 Token 放进查询参数或子协议头里。常见做法是new WebSocket(wss://api.example.com/ws?token encodeURIComponent(token))。查询参数会被记到服务端日志里所以敏感场景建议用Sec-WebSocket-Protocol传但这样需要服务端配合看你们后端的支持情况。SSEServer-Sent Events用 EventSource 时没法自定义 Header只能走withCredentials或查询参数。如果你用的是 fetch 驱动的 SSE比如某些 AI 流式接口就轻松了直接往下游 fetch 里塞 Header 就行。图片/文件加载img src...这种没法加 Header如果你的资源下载接口需要鉴权要么改成 fetch 拿 blob 再生成 objectURL要么把 Token 放查询参数不推荐会进日志。我处理附件下载都是走 fetch blob 方案一劳永逸还能顺带处理大文件断点续传的进度展示。4.3 并发请求竞态一个失效 Token 引发的一连串 401这个问题在热搜里没直接出现但实际项目里太常见了。页面加载时同时发出 5 个请求这 5 个请求都带着同一个快过期的 Token 飞出去服务端咔咔全返回 401你的拦截器一看 401 就去 refreshrefresh 过程还没完被重放的请求又带旧 Token 重试又 401死循环。上面 4.1 的代码用_retried标记解决了重试死循环但更优雅的方案是在 request 层就拦截发请求前检查 Token 是否即将过期比如 exp - 当前时间 30 秒如果是先静默刷新再继续发请求。这样大部分 401 在源头就被避免了。// 判断 JWT 是否快过期 function isTokenExpiring(token, thresholdSeconds 30) { try { const payload JSON.parse(atob(token.split(.)[1])); return payload.exp - Math.floor(Date.now() / 1000) thresholdSeconds; } catch { return true; } }5. 401 只是开始Token 失效、续签与用户体感5.1 Token 失效的四种典型形态失效这个话题面试时能聊到第四层的候选人不多。第一层自然过期JWT 里的 exp 字段到了服务端校验不通过返回 401。第二层服务端主动吊销比如用户改密码、管理员踢人、账号被禁用服务端把该 Token 加入黑名单或刷新 Redis 里的版本号旧 Token 即刻失效。第三层客户端时钟漂移如果用户手机时间不对JWT 的 iat 和 exp 校验就会出问题jwt.verify可能因为NotBeforeError或TokenExpiredError直接挂掉。第四层刷新令牌也过期refresh_token 本身有生命周期超过 7 天或 30 天静默刷新也失败了用户必须重新登录。每一层前端处理的策略都不一样。自然过期走静默续签主动吊销时静默续签也会失败得跳登录页刷新令牌过期要提示登录已过期请重新登录而且不要自动跳转刷新登录页再弹回来很多项目这里处理粗糙用户体验很差。5.2 续签方案Refresh Token、自动续期与双 Token 对比续签方案的选型直接影响架构复杂度。单 Token 长过期最简单7 天有效过期重新登录。适合内部工具、个人项目用户容忍度高。双 Token 常规续签access_token 2 小时refresh_token 7 天access 过期后拿 refresh 换新的。覆盖绝大多数业务场景。滑动续期用户每次操作都刷新 access_token 的过期时间只要 7 天内活跃过就一直不用重新登录。适合天天打开的 C 端产品注意服务端要在 refresh 时重新签发并让旧 Token 失效。Refresh Token 轮换每次 refresh 不仅换 access_token也换 refresh_token旧 refresh_token 立即作废。安全级别最高能有效防止 Refresh Token 泄露后长期有效代价是服务端要维护一个已使用过的 refresh_token列表。前端代码层面轮换方案只需要在响应里多存一个新 refresh_token 覆盖旧值其他逻辑不变。async function refreshAccessToken() { const refreshToken tokenStore.getRefresh(); if (!refreshToken) { throw new Error(No refresh token); } const response await fetch(/auth/refresh, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ refresh_token: refreshToken }) }); const data await response.json(); if (!response.ok) { throw new Error(data.message || Refresh failed); } // 轮换模式下同时更新 refresh_token if (data.refresh_token) { tokenStore.setRefresh(data.refresh_token); } return data.access_token; }5.3 正确处理登录已过期别让用户一头雾水很多前端一收到 401 就跳登录页用户正在填表单呢突然被踢走回来表单清空了能不骂人吗我的策略是三级降级静默续签Token 快过期时提前刷新用户无感知。弹层拦截续签失败但用户有登录标记时弹一个 Modal 登录状态已过期是否重新登录原地拉起一个小型登录框登录成功后续执行刚才的操作。强制跳转点击重新登录且失败或者登录标记也没有再跳全局登录页。第 2 级的体验价值极高特别是在表单长、流程长的页面里原地续登录比全页跳转好一百倍。实现上就是拦截器里检测到 refresh 失败后显示 Modal 组件Modal 里登录框的提交逻辑复用正常登录接口成功后关闭 Modal 重放队列中的请求。6. 我踩过的 Token 坑多标签同步、第三方服务和日志泄露6.1 多标签页下 Token 不同步一个标签登出另一个还是登录态这是 localStorage 方案最经典的坑。用户开了两个标签页A 标签操作导致 Token 失效或用户主动登出B 标签里的内存变量还留着旧 Token继续请求就 401然后刷新逻辑用 localStorage 里的 refresh_token又偷偷登回来了——服务端已经吊销刷新令牌了才对。解决方向是利用storage事件做跨标签同步window.addEventListener(storage, (event) { if (event.key refresh_token) { if (event.newValue null) { // 别的标签页登出了当前标签也要清理 tokenStore.clear(); redirectToLogin(); } else { // 别的标签页更新了 refresh_token比如轮换同步当前内存 access_token tokenStore.setAccess(null); } } });storage事件只在其他标签页修改 localStorage 时触发当前页自己改不会触发刚好适合做同步。另一个思路是用 BroadcastChannel可以传递消息让其他标签页主动刷新 access_token更精细但要多写代码。6.2 第三方服务接入时的 Token 链前端不该做中转热搜里反复出现的token exchange failed、codex auth token is unavailable这类问题暴露出很多前端同学在做第三方 AI / 云服务集成时踩同一个坑把第三方服务的 Key 直接放前端代码里然后让浏览器直接调第三方 API。这是大忌。浏览器里任何字符串都是透明的用户一个 F12 就能看到你的OPENAI_API_KEY或AWS_SECRET_ACCESS_KEY。正确架构是前端只和后端通信后端持有第三方服务的高权限凭证前端要调用第三方能力是向后端请求由后端去换临时凭证或中继请求。你前端拿到的应该是你们自己服务端签发的 Token后端用这个 Token 判断这个用户有没有权限调用 AI 功能然后后端再去调第三方的 API。所谓token exchange的过程应该发生在服务端之间而不是浏览器请求第三方 Token 端点。很多sign-in could not be completed token exchange failed报错本质是前端把本来该在后端做的事揽到了自己身上才会撞上对方 Token 端点的各种限制。6.3 日志泄露你随手 console.log 的 Token 可能躺在监控后台里这个坑我见过太多次了。前端同学排查问题习惯了console.log(response.data)如果登录接口返回的数据里有 access_token这行日志打到生产环境被 Sentry 或阿里云日志服务收集等于把你的 Token 明文存到了第三方服务器上。攻击者拿到日志权限直接登录你的系统。排查问题要做日志脱敏// 安全的登录日志 function safeLogLoginResponse(response) { const { access_token: at, refresh_token: rt, ...safeData } response; console.log(登录响应, safeData, { access_token_exists: Boolean(at), refresh_token_exists: Boolean(rt), access_token_tail: at ? at.slice(-6) : null }); }同样的规则适用于 URL、请求头、错误堆栈。任何可能带 Token 的地方要么不记要么只记后四位。6.4 关于前端 uiue、大屏、WordPress 这些热词里藏着什么热搜里混着一些看起来跟 Token 无关的词比如前端ue是什么前端页面大屏布局探针hzero前端开发前端组件库。我猜是搜索 Token 的人同时也搜了其他前端主题或者想了解前端怎么接收 Token 并渲染到页面上。稍微点一下大屏项目里 Token 一般也是常规的 Authorization Header 流程只不过大屏通常不需要频繁交互Token 过期时长可以拉长刷新策略用静默续期即可。前端 ue 一般指的是用户体验User Experience跟 Token 没什么直接关系但Token 失效时怎么不打断大屏展示倒是个正经的 UE 议题处理方式就是 5.3 里的弹层拦截方案大屏场景可以直接选择不强制跳转仅提示失效并在后台静默续签。7. 安全底线前端 Token 操作的几条原则7.1 最小可见范围谁应该能拿到 Token这里说的谁包括人、脚本、第三方服务三个层面。人对 Token 的可见范围普通用户不需要看到自己的 access_token 原文所以前端 UI 上不要展示。调试模式下可以在开发者工具里看但生产环境不要打印。管理员可能需要用到 API Key 类 Token也只显示后四位完整密钥只在创建时展示一次。脚本对 Token 的可见范围第三方统计脚本、错误监控脚本、AB 测试脚本都不该拿到业务 Token。存 localStorage 的 Token 理论上任何注入的脚本都能读到所以要控制页面加载的外部脚本白名单能用 npm 包编译进来的就别从 CDN 引。第三方服务对 Token 的可见范围不要在 URL 里传 Token不要在埋点事件属性里带 Token更不要把它塞进公共 Redux/Pinia state 里供所有组件读。全局状态管理器只存是否已登录的标志位具体 Token 只在封装好的请求库内部流转。7.2 双因子校验与敏感操作防护Token 能证明登录过但证明不了当前操作确实是你本人。改密码、绑定手机、转账这类敏感操作即使带了合法 Token也要二次验证短信验证码、扫码确认、或重新输入密码。这是产品逻辑问题但前端要做的是别把所有接口都当成普通请求处理敏感接口要单独设标记不走通用拦截器的盲放行。另一个细节是操作来源校验Token 里最好带上签发设备信息比如浏览器指纹服务端比对不一致时要求重新验证。前端能配合的是提供设备指纹参数或者在登录时把指纹一起提交。7.3 登出时别只清前端 Token服务端也要同步失效很多前端登出就是localStorage.clear()然后跳登录页。但 refresh_token 在服务端还活着泄漏出去依然能换新 access_token。正确的登出流程是async function logout() { try { const refreshToken tokenStore.getRefresh(); // 调用服务端登出接口吊销当前 refresh_token await fetch(/auth/logout, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ refresh_token: refreshToken }) }); } catch { // 网络异常也继续执行本地清理不阻塞用户登出 } finally { tokenStore.clear(); window.location.href /login; } }如果服务端不支持吊销接口至少也要把 refresh_token 换掉或标记失效。短时间内拿 refresh_token 去服务端续签失败就会暴露 Token 已失效安全性比前端单方面清理高一个数量级。我个人的实际体会是Token 这东西看着简单但每个环节都能玩出花来。前端能做的就是把这些流程封装成一套可靠的模块别在业务代码里到处散落 Token 操作同时心里时刻绷着安全这根弦——存的地方要谨慎、带的方式要规范、失效了要优雅、登出了要清干净。把这套东西理顺不管面试还是实战你都比大多数前端走得深一块。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进