从第一次接手一个需要登录态的后端接口开始我就意识到“把 Token 放到 HTTP 请求头里”这件事远没有想象中那么简单。做过几年 Web 开发的朋友应该都有体会前端拿到登录凭证后最大的问题不是“存哪”而是“下一次请求怎么把它带过去”。放 Cookie 里有 CSRF 风险放在查询参数里会污染日志、泄露敏感信息于是大家不约而同选择标准化做法——放进 HTTP 请求头具体来说就是Authorization头。这个动作本身只有一行代码但围绕这行代码的工程化封装、过期刷新、跨域预检、并发拦截踩过的坑能写满一屏。这篇文章我就把自己在真实项目里使用 Token 携带方案的全过程、代码实现和排查经验一次性整理出来希望能让刚入门的同学少走弯路也让做前端封装的老手回来后能对比一下自己的方案有没有遗漏。1. 先想清楚为什么偏偏要把 Token 放在请求头里1.1 Token 是什么它和 Cookie 差在哪Token 本质上是一段由服务端签发、包含用户身份信息和过期时间的加密字符串。客户端每次请求时把它原样带回去服务端验签通过就认为是合法用户。这话说起来很轻松但实际工程里“怎么带回去”才是核心问题。我记得最早做项目时有人直接提议“把 Token 塞进 Cookie 不就行了浏览器会自动带”。这话对了一半Cookie 确实会自动附带在请求里不用写任何代码。但代价是服务端必须维护会话状态或 CSRF Token 双重校验否则攻击者可以伪造跨站请求。Token 方案的价值恰恰是无状态服务端只要验签不查会话表扩容时不用同步 Session。但代价是前端必须显式地把 Token 加到每次请求的 Header 里漏一次就 401。所以“放 Header”不是一种随便的选择而是无状态认证模式下唯一的正道。1.2 为什么固定用 Authorization 这个头部名称HTTP 协议里专门预留了一个标准字段用于身份凭证传递就是Authorization。它的格式通常是这样的Authorization: 类型 凭证内容最常见的类型是Bearer表示“持有者凭证”后面直接跟 Token 字符串Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...也有人会用Basic但那是账号密码 base64 拼接属于基础认证不太适合放 Token。用Authorization而不用自定义头比如X-Token的好处有三点第一网关、日志系统、监控工具对它的语义有统一认知能自动做脱敏第二服务端框架普遍内置了对该头的解析支持比如 Spring Security、Express 中间件都会自动读取第三跨域场景下预检请求的处理规则更明确自定义头往往被浏览器列为“非简单请求”字段会触发额外的 OPTIONS 请求。这里顺便说一下我见过不少团队用了X-Token这种自定义头也没出问题但如果你的项目要接第三方网关或统一鉴权平台Authorization的兼容性会好得多。技术上没有强制规定工程习惯上推荐选标准头。1.3 前后端必须提前约定哪几件事把 Token 加到请求头最怕的不是不会写代码而是前后端各写各的最后联调时对不上。我在项目启动阶段就会拉着后端确认三件事Token 类型标识是Bearer还是裸 Token如果是Bearer必须连前缀一起传请求头的键名是Authorization还是自定义名混用会导致网关层无法统一识别过期时返回的 HTTP 状态码是 401 还是 200 业务码这决定了前端拦截器的触发条件。这些问题看似琐碎但任何一个不一致都会让你排查到抓狂。后面我会专门写一节排查记录里面有真实踩坑案例。2. 最朴素的实现用原生 fetch 和 XHR 手动加请求头2.1 fetch 里加 Token一段代码看清本质如果你不需要任何封装库只想在这一次请求里带上 Token原生fetch是最直接的方式。它的第二个参数是配置对象里面有个headers字段你可以直接传一个普通对象const token localStorage.getItem(app_token); fetch(https://api.example.com/user/profile, { method: GET, headers: { Authorization: Bearer ${token}, Content-Type: application/json } }) .then(response { if (response.status 401) { // 处理 Token 过期 } return response.json(); }) .then(data console.log(data)) .catch(error console.error(请求失败, error));这段代码看起来简单但有三个容易被忽略的细节一是模板字符串里的空格。Bearer ${token}这个写法中Bearer和 Token 之间必须有一个空格这是 RFC 6750 的要求后端解析时如果按split( )分割少这个空格会把整个串当成 Token 去验签结果必然是 401。二是内容类型头在 GET 请求里其实可以不写但如果你要发 JSON 到后端Content-Type必须带上不然后端可能把 body 当纯文本解析。三是localStorage的读取时机如果 Token 是在登录后异步写入的而你在这之前已经发出请求token就可能是null这时候请求头里会出现字符串Bearer null后端解析直接失败。2.2 老项目里用 XMLHttpRequest 的写法如果项目还在用原生XMLHttpRequest没有引入任何请求库加头的逻辑在open之后、send之前const xhr new XMLHttpRequest(); xhr.open(GET, https://api.example.com/user/profile, true); const token sessionStorage.getItem(app_token); if (token) { xhr.setRequestHeader(Authorization, Bearer token); } xhr.onreadystatechange function () { if (xhr.readyState 4 xhr.status 200) { console.log(JSON.parse(xhr.responseText)); } }; xhr.send();这里的关键点是setRequestHeader必须在open之后调用如果你在open之前调用会直接抛错。另外setRequestHeader如果重复调用同一个头名值是会追加的不是覆盖。如果你不小心设置了两次Authorization最后发出去的可能是Bearer token1, Bearer token2后端拆出来验签必挂。为了避免这个问题建议在设置前先判断一下或者封装进工具函数里统一处理。2.3 请求头大小写敏感吗真相是什么HTTP 规范里头部字段名是不区分大小写的理论上authorization、Authorization、AUTHORIZATION效果一样。但实际工程里有个隐蔽的坑某些代理服务器、网关或旧版框架内部会把头名转成小写再做匹配如果后端代码里写死了小写authorization去取值而你在浏览器端发的确是Authorization部分网关层会做兼容但有些自行实现的服务器可能取不到值。所以我的建议是写的时候统一用规范驼峰Authorization后端读取的时候尽量用不区分大小写的方式比如 Node.js 里用headers[authorization]Node 内部会把所有头转为小写这样两端都不会踩坑。3. 工程落地用 Axios 拦截器统一携带 Token3.1 为什么实际项目一定要做统一封装上面的原生写法适合演示和技术验证但如果整个项目里每个请求都手动写一遍 Token 拼接等到接口数量上百个时维护成本会直接爆炸。你不想在 30 个业务页面里各写一段“Token 不存在就不发请求”的逻辑更不想在 Token 格式改变后全局搜索替换几十处拼接代码。所以正规项目里一定会有一个统一的请求模块。以 Axios 为例它提供了请求拦截器机制可以在请求发出去之前统一修改配置。这是最优雅的挂载点你只需要写一次所有用 Axios 发出去的请求都会自动带上 Token。3.2 拦截器加 Token 的完整实现import axios from axios; // 创建一个独立的实例避免污染全局 axios 配置 const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000 }); // 请求拦截器每次请求发出前尝试从存储中取出 Token service.interceptors.request.use( (config) { const token localStorage.getItem(app_token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }, (error) { return Promise.reject(error); } ); // 响应拦截器统一处理 401 service.interceptors.response.use( (response) { return response.data; }, (error) { const status error.response?.status; if (status 401) { // Token 过期或非法跳转登录页 localStorage.removeItem(app_token); window.location.href /login; } return Promise.reject(error); } ); export default service;这段代码有几个设计点值得说明我特意创建了一个独立的 Axios 实例而不是直接用全局默认实例。原因是项目里总有那么几个接口不做鉴权比如登录接口本身、验证码获取、公开的产品列表。如果一个全局实例统一加了拦截器想在单个接口里跳过拦截器就比较别扭。用独立实例并导出没有鉴权需求的接口可以单独用新建一个基础实例。拦截器里使用config.headers.Authorization直接赋值这样比config.headers.set更直观兼容性也更好。需要注意的是在不同 Axios 版本里config.headers可能是AxiosHeaders对象而不是普通对象直接赋值通常没问题但如果你在后续逻辑里要读取它建议用config.headers.get(Authorization)的写法来兼容。3.3 从 localStorage 还是内存里取 Token这里有个易忽略的坑很多项目的 Token 存储方式是“登录成功写入 localStorage读取请求头时从 localStorage 拿”。这个方案对大多数场景是合适的因为刷新页面后 Token 还在。但有一个体验问题localStorage 是同步读写的在极端情况下每次请求都经历一次序列化和反序列化性能损耗很小但也不是零成本。更关键的坑是“多标签页同步”问题。localStorage 的修改不会自动广播到其他标签页的内存变量但如果你的代码每次都从 localStorage 实时读取那不存在这个坑。真正容易出问题的是有些团队为了减少 IO登录时把 Token 暂存在模块级内存变量里之后请求都优先读内存。这样一旦用户在其他标签页退出登录或刷新 Token当前标签页内存里的值还是旧的请求带的还是旧 Token就会一直 401。我个人的做法是内存里维护一个变量当缓存来源是 localStorage每次请求前先判断内存有没有值没有再读 localStorage。同时监听storage事件同步多标签页的状态变化这样兼顾性能和一致性。let tokenCache localStorage.getItem(app_token); window.addEventListener(storage, (event) { if (event.key app_token) { tokenCache event.newValue; } });3.4 白名单与动态 Token 头不是所有请求都要带统一拦截器有一个容易被忽略的边界并不是所有请求都应该带 Token。比如获取图形验证码、刷新验证码、第三方登录回调这些场景带了 Token 反而可能被网关判断成越权请求或导致缓存冲突。我的方案是在请求配置里扩展一个自定义标记拦截器里判断是否放行service.interceptors.request.use((config) { if (config.skipAuth) { return config; } const token getToken(); if (token) { config.headers.Authorization Bearer ${token}; } return config; });调用时这样写service.get(/api/captcha, { skipAuth: true });另外如果你的接口有“临时 Token 覆盖全局 Token”的需求比如文件上传或某些第三方服务要单独鉴权可以约定一个customToken字段优先级高于全局 Token。这种写法比在业务代码里临时改 header 要清晰得多也方便日后统一排查。4. Token 生命周期从获取、刷新到过期处理4.1 过期时发生了什么事前端必须做什么Token 都有过期时间JWT 通常设置 2 小时或 24 小时。过期后客户端带着旧 Token 发请求服务端验证签名通过但发现exp字段已过会返回 401。这时候前端如果只是简单跳登录页用户体验很糟糕用户正在填写一个长表单突然被踢出去表单内容全没了。所以工程上一般会做“无感刷新”前端提前或在收到 401 后调用刷新接口换取新 Token然后再重放失败的原请求。响应拦截器里就要处理这个状态let isRefreshing false; let pendingQueue []; service.interceptors.response.use( (response) response.data, async (error) { const { response, config } error; if (response?.status ! 401 || config.url.includes(/auth/login) || config._retry) { return Promise.reject(error); } config._retry true; if (!isRefreshing) { isRefreshing true; try { const refreshToken localStorage.getItem(app_refresh_token); const res await axios.post(/auth/refresh, { refreshToken }); localStorage.setItem(app_token, res.data.token); pendingQueue.forEach((cb) cb(res.data.token)); pendingQueue []; return service(config); } catch (refreshError) { pendingQueue []; window.location.href /login; return Promise.reject(refreshError); } finally { isRefreshing false; } } else { // 同一时间多个请求同时 401把后续请求排队等待刷新完成 return new Promise((resolve, reject) { pendingQueue.push((newToken) { config.headers.Authorization Bearer ${newToken}; resolve(service(config)); }); }); } } );这段代码解决了一个经典的并发问题假设页面同时发出 5 个请求Token 恰好过期5 个请求都返回 401。如果每个请求都去调刷新接口会发出 5 次刷新请求浪费资源不说还可能因为刷新接口的并发校验导致部分失败。通过设置isRefreshing标志位只有第一个 401 触发刷新后续请求进入待命队列刷新成功后再带着新 Token 统一重发。这是我在真实项目里验证过的最稳写法之一。4.2 刷新 Token 时的队列饥饿问题上面代码里有一个容易出错的点pendingQueue里存的是回调函数刷新成功后遍历执行。但如果刷新接口本身失败了我们要把队列清空否则这些 Promise 永远处于 pending 状态页面会出现“请求卡死”的假象。另外一个极端情况是刷新请求太慢接口超时阈值设置得太短导致排队请求等待时间超过浏览器请求限制这时候需要合理设置刷新接口的超时时间我一般会给刷新接口单独延长超时到 30 秒。还有一点刷新接口本身不能也走同一个拦截器否则它 401 会再次触发刷新逻辑形成死循环。所以刷新接口通常用独立的 Axios 实例或配置skipAuth白名单。4.3 多端登录与 Token 失效的兜底策略还有一类情况不是 Token 过期而是服务端主动把 Token 作废比如用户在另一个设备改了密码、管理员封禁账号。服务端可能返回的不是 401而是 403 或业务码 40001 这种。遇到这种“业务性失效”前端只靠 HTTP 状态码判断是不够的必须在响应拦截器里同时判断业务码if (response.code 40001) { // 强制退出登录 clearAuthInfo(); router.replace(/login?reasonforced); }这里要注意的是业务码 40001 不能和 HTTP 200 混淆。有些后端习惯统一返回 200 业务码你的响应拦截器就不能只看response.status必须深入业务体判断。否则即使服务端已经判定 Token 失效你的前端还会拿着旧 Token 继续重试浪费资源。5. 加了 Token 头之后那些不得不说的坑5.1 跨域请求里一个必踩的坑预检请求不见了 Authorization浏览器对于跨域请求有个“预检”机制。当你的请求头包含自定义字段包括Authorization时浏览器会先发出一个OPTIONS请求询问服务器允许哪些方法和哪些头。问题来了很多后端同学只在接口里写了POST、GET的处理逻辑并没有专门响应OPTIONS请求导致跨域环境下请求根本到不了业务接口。解决方案是后端要正确配置 CORS明确允许Authorization头Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Allow-Methods: GET, POST, OPTIONS如果你们用的是 Spring还要注意预检请求必须直接返回成功而不是走业务处理流程。这个坑让我和一位后端同事调试了一整个下午才定位到前端代码写得再对后端不放行也没用。5.2 转发数据时Authorization 被网关吞掉了另一个隐蔽的坑发生在微服务架构里。网关做了统一的身份认证和 Token 解析然后转发请求给下游服务。如果网关内部转发时没有把Authorization头透传下游服务即使需要验签也拿不到信息。这种情况常见于自定义网关过滤器的removeHeader操作或者某些负载均衡器默认不转发敏感头。排查方法很简单在下游服务里打一行日志把收到的所有 headers 打印出来看看。日志里如果完全没有authorization字段问题八成出在网关过滤链上而不是业务代码。5.3 大文件上传场景Token 放到哪里都不合适上传文件的接口有时候不适合把 Token 放请求头因为某些网关对超大请求体的头部处理有缺陷或前端上传组件不支持自定义头。这时有些团队会把 Token 放在查询参数里。我不建议这么做因为 URL 会进日志系统、浏览器历史、运维监控Token 泄露风险很高。折中方案是用form-data里加一个隐藏字段或者使用专门的短时签名 URL。如果你遇到的是第三方对象存储上传最稳妥的是请求后端换取一个短时uploadToken然后用这个 Token 走直传接口而不是把用户登录 Token 暴露出去。5.4 SSR、静态页面和 H5 内嵌的另类视角如果你在做服务端渲染SSR或静态页托管页面首屏的数据请求发生在服务端或构建阶段此时浏览器环境不存在localStorage根本拿不到。这种场景下 Token 通常从 Cookie 读取或者由服务端注入到初始状态里再传给前端。所以你的请求模块最好做一个环境判断const token typeof window ! undefined ? localStorage.getItem(app_token) : global.__TOKEN__;另一个容易忽略的场景是 App 内嵌 H5App 端可能已经在原生层登录需要把原生 Token 注入到 H5 的 WebView 里。这时你在 H5 的请求模块里从某个全局变量取 Token而不是从 localStorage 取因为 localXSTORAGE 在 WebView 的域名隔离环境下可能是空的。设计请求模块时把取 Token 的逻辑抽象成独立函数后续要适配这个场景会非常舒服。6. 安全与规范加完头不代表可以裸奔6.1 请求头里不要放不该放的东西Authorization头传的是鉴权凭证它应该尽量短小、无关业务。有些人为了方便把用户 ID、手机号、昵称全塞在里面服务端解码后直接用。这么做第一会让日志系统里积累大量明文敏感信息第二 Token 一旦泄露黑客连枚举都不需要就拿到了你的身份细节。正确做法是 Token 里只包含用户唯一标识和过期时间业务信息通过后续接口获取。同时注意任何情况下不要把 Token 放在 GET 请求的 URL 里、不要打日志时把 headers 整个输出到控制台。我们生产环境就出过一次事故运维平台把Authorization完整打印到了日志文件然后日志文件又同步到了第三方分析系统导致批量密码重置。排查成本极高教训极其惨痛。6.2 是否需要再套一层签名取决于接口的重要程度如果你做的只是一个普通的中后台管理系统Token 放 Header 加 HTTPS 传输基本够用。但如果涉及支付、订单状态变更、用户信息修改等高敏操作建议在业务层面再增加签名参数。常见做法是把关键字段和时间戳拼接后用密钥签名放在另一个自定义头里。后端先验签名再验 Token双重保险。这样做能有效防重放攻击——即使抓到了请求报文也无法篡改业务字段再重放。6.3 我的最小落地配置清单每次接手带有鉴权需求的项目我都会按清单做一遍自检确保不会漏项请求模块必须统一封装所有请求自动携带 TokenToken 存储统一收敛到一个工具模块里业务代码不直接操作 localStorage响应拦截器统一处理 401 和刷新逻辑刷新失败才跳登录页约定白名单登录接口、验证码等跳过拦截器请求日志里不得包含Authorization明文或完整 Token跨域配置必须显式允许Authorization头服务端接口读取 Token 时使用大小写不敏感方式Token 过期时间和服务端exp要留一定冗余以应对时钟偏差。7. 写在最后的一些个人体会把 Token 加到 HTTP 请求头表面上是前端一行代码的事情实际牵扯到协议规范、安全边界、工程复用、网关转发、跨域策略这么多环节。我自己踩过最大的坑是并发刷新问题之前没做队列管理高峰期同时 10 个 401 触发 10 次刷新请求偶尔刷新接口出现超时或限流页面直接白屏。后来加上isRefreshing和pendingQueue这个故障再也没出现过。另一个经验是“取 Token 的逻辑一定要收敛到一处”。早期项目每个页面私自从 localStorage 读后来改存储 key 时全局搜索替换到崩溃。现在不管项目大小我都会先写一个auth.js专门负责 Token 的读取、写入、清除和监听。这样既方便调试也为将来切换到不同存储方案留了余地。如果你正在搭建新项目的请求层不妨花一晚上时间把本文的拦截器和刷新队列完整实现一遍再对照自己的项目看看有没有遗漏。这不是一蹴而就的事情但做完了后面几年的维护会非常省心。