CORS跨域报错全解:preflight与Access-Control-Allow-Origin的排查指南

发布时间:2026/10/12 13:20:45
CORS跨域报错全解:preflight与Access-Control-Allow-Origin的排查指南 浏览器控制台里又跳出一整行红字Response to preflight request doesnt pass access control check: No Access-Control-Allow-Origin header is present on the resource.做前后端联调的人看到这个第一反应多半是“跨域了”。但跨域这个事有时候是后端没配有时候是网关把预检请求拦了还有可能是前端自己的请求头触发了额外检查。这篇文章不打算泛泛讲概念而是以这条具体报错为主线把 preflight 机制、定位方法、后端配置和中间层踩坑一次性讲清楚。适合前端开发、后端开发、运维以及所有被 CORS 折腾过的人。1. 先搞清楚这条报错到底在说什么1.1 从一次前端联调现场说起先说一个我印象比较深的现场。某次联调一个内部管理系统的接口前端页面跑在http://localhost:8080接口地址在https://api.internal.example.com。页面用 POST 提交 JSON 数据刚点保存按钮控制台就出现这条报错。初看以为是后端 CORS 头没配于是后端同事在接口过滤器里加了Access-Control-Allow-Origin: *一刷新报错变成了关于Access-Control-Allow-Headers的提示。再一看前端请求头里放了Authorization和X-Request-Id显然预检请求已经被允许但允许的“请求头列表”没跟上。折腾了半小时最后反而发现最早的问题出在网关没有放行 OPTIONS 请求数据其实已经写进库了前端还傻傻地等着响应。这个场景几乎能代表 80% 的跨域排查问题的共同点表面是浏览器报错背后可能是多层配置叠加失败。1.2 拆解报错preflight 请求和 Access-Control-Allow-Origin这条报错信息其实可以拆成三块。Response to preflight request说明报错针对的是预检请求的响应doesnt pass access control check说明浏览器在做跨域校验时没有通过No Access-Control-Allow-Origin header is present on the resource则直接把原因写明了服务端返回给浏览器的响应里没有携带允许的来源头。这里的 resource 指的就是那个 OPTIONS 预检请求的响应资源。那什么是 preflight当浏览器检测到当前页面和请求地址不同源协议、域名、端口任一不同并且请求属于“非简单请求”时它不会直接发送真实请求而是先发一个OPTIONS请求。这个请求的目的就是代替浏览器问服务端我的来源你是不是允许我要用的请求方法、请求头你是不是支持服务端如果通过 CORS 响应头回答“允许”浏览器才会继续发送真正的业务请求。什么情况下会触发预检一句话总结就是请求不够“简单”。最常见的是下面几种使用了 PUT、DELETE、PATCH 等方法Content-Type为application/json等非简单类型自定义了Authorization、X-Requested-With或其他请求头请求中携带了特殊凭证相关配置。只要命中任意一条浏览器都会先发 OPTIONS。所以你看标题里这个报错本质上是“预检没过关”而不是“业务接口报错”。1.3 同源策略逼出来的 CORS 协商机制浏览器为什么要做这么一出根源还是老生常谈的同源策略。一个页面加载的脚本默认只能读取同源接口返回的数据否则一个恶意站点就能借用用户在另一个站点上的登录态去读取或修改数据。CORS 相当于给这个限制开了一个可控的“白名单机制”而 preflight 又是 CORS 里更保守的第一步类似于小区保安先给访客做登记登记通过才放行。关键点在于浏览器拦截的是“页面拿到响应数据”这一步不是“网络请求发送”这一步。也就是说即便 CORS 检查失败HTTP 请求也已经到达了服务器服务器该执行的业务逻辑可能已经执行了。如果你在排查过程中只盯着浏览器报错很容易漏掉一个更严重的问题接口被重复调用或数据被重复写入。2. 从现象到定位三步锁定跨域报错源头2.1 先在 DevTools 里确认有没有发出 OPTIONS定位跨域问题第一步不是改后端代码而是先在开发者工具里看清楚请求到底发没发、有没有 OPTIONS。打开 Network 面板勾选 Fetch/XHR刷新页面触发接口。如果请求列表里能看到一条OPTIONS请求它的请求类型通常显示为preflight说明这次请求确实触发了预检。如果只有一条正常的 POST 请求说明请求属于简单请求或者浏览器已经缓存了之前的预检结果那么报错就纯粹是实际响应缺少 CORS 头造成的。这里有一个很容易被忽略的细节浏览器对 preflight 结果是有缓存的缓存时间由响应头Access-Control-Max-Age控制。第一次失败后你改了服务端配置再刷新浏览器可能还拿着刚才失败的缓存直接拦截所以你经常需要硬刷新CtrlShiftR或者清一下站点数据才能看到新结果。很多同事在这个点上白白浪费了时间以为配置没生效其实浏览器端缓存的是旧结论。2.2 用 curl 还原预检现场看响应头在开发者工具里只能看到浏览器视角的结果真要判断服务端到底返回了什么建议用命令行工具复现一次。下面这条命令可以模拟浏览器发出 OPTIONS 预检请求curl -i -X OPTIONS https://api.internal.example.com/api/data \ -H Origin: http://localhost:8080 \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: Content-Type, Authorization-i表示输出响应头-X OPTIONS指定请求方法。发送时把Origin改成页面实际地址Access-Control-Request-Headers改成前端真实会带的自定义请求头。注意这里的关键是看响应头如果响应里压根没有Access-Control-Allow-Origin说明服务端或网关没有补 CORS 配置如果响应状态是 404、405 或 401说明 OPTIONS 请求没有被正确放行如果头都在但值不是预期的来源说明白名单或回显逻辑有问题。用这种方法可以把“浏览器行为”排除掉直接在服务端视角确认原始响应责任边界就清晰了。2.3 一张表快速判断责任方实际排查中与其听对方“我觉得是前端/后端的问题”不如直接对着现象判断。下面这张表我总结过很多次基本能覆盖大多数情况观察到的现象最可能的原因主要责任方请求列表里没有 OPTIONS但报错简单请求实际业务响应缺 CORS 头后端/网关OPTIONS 请求状态 404/405/401服务端或网关没有放行 OPTIONS后端/网关OPTIONS 响应没有 Access-Control-Allow-OriginCORS 配置缺失后端/网关OPTIONS 响应头存在但允许的方法/请求头不对Allowed-Methods/Headers 配置不全后端前端带凭证请求Allow-Origin 是*凭证请求不允许通配符后端本地代理正常生产报错开发代理绕过了跨域前端/部署环境这张表背后的逻辑很简单你不需要记住所有报错文案只需要抓住两个核心预检有没有正常返回、响应头内容是否符合前端请求。判断责任方之后再动刀修改效率会高很多。3. 后端修复方案把 CORS 响应头配置正确3.1 最小配置先让 Access-Control-Allow-Origin 出现服务端要做的第一件事是在所有接口响应上尽量统一加上Access-Control-Allow-Origin。如果你用的是常见框架通常有对应的全局过滤器或中间件。拿一个 Node.js 中间件举例app.use((req, res, next) { const whitelist [ http://localhost:8080, https://app.internal.example.com ]; const origin req.headers.origin; if (whitelist.includes(origin)) { res.setHeader(Access-Control-Allow-Origin, origin); res.setHeader(Vary, Origin); } next(); });这里有两个点要强调。第一值是前端页面所在的完整源地址是协议加域名加端口不是单纯域名第二维护白名单而不是直接把req.headers.origin原样回显是为了避免任何来源都能拿到带凭证的接口响应。如果是纯公开接口不涉及 Cookie 和登录态临时用*确实能跑通但我建议生产环境还是把白名单做起来。在 Java 后端里Spring 的CrossOrigin注解或全局 CorsConfigurationSource 也可以达到同样效果。关键不是用什么写法而是让每个接口响应都带上这个头并保持全局行为一致。3.2 单独处理 OPTIONS 预检请求很多框架默认不会自动处理 OPTIONS 请求尤其是你在接口层做了统一的认证过滤器时OPTIONS 可能会因为没有登录态被直接拦成 401。正确做法是在认证拦截之前先判断请求方法如果是 OPTIONS直接返回 204并补上 CORS 头。示例逻辑如下if (req.method OPTIONS) { const origin req.headers.origin; if (!whitelist.includes(origin)) { return res.status(403).end(); } res.setHeader(Access-Control-Allow-Origin, origin); res.setHeader(Access-Control-Allow-Methods, GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS); res.setHeader(Access-Control-Allow-Headers, req.headers[access-control-request-headers] || Content-Type,Authorization); res.setHeader(Access-Control-Allow-Credentials, true); res.status(204).end(); }另一个我经常看到的错误是Access-Control-Allow-Headers没有包含前端实际发送的请求头。比如前端带了X-Request-Id服务端只写了Content-Type预检响应就会报错Request header field x-request-id is not allowed by Access-Control-Allow-Headers in preflight response.。看到这种报错只需要把缺失的请求头加进去即可。有一个取巧的办法是直接回显预检请求里的Access-Control-Request-Headers值但前提是你知道自己在做什么不会因此引入不必要的风险。保守做法是明确列举。3.3 带 Cookie 和 Authorization 的凭证请求配置如果前端需要跨域携带 Cookie或者默认使用 Bearer Token后端必须同时开启凭证支持。具体到响应头就是Access-Control-Allow-Credentials: true。一旦开启这个头浏览器会强制要求Access-Control-Allow-Origin不能是*必须是一个明确的源地址。所以你写了*再加上Allow-Credentials: true浏览器照样会报另一种形式的 CORS 错误。前端侧也有对应配置比如使用 fetch 时要设置credentials: include使用常见请求库时要设置withCredentials: true。如果前端没开就算后端允许带凭证浏览器也不会帮你带上 Cookie。前后端的设置必须对齐。配置示例可以在白名单中间件里判断来源再决定是否附加Allow-Credentials。同时建议给响应头加上Vary: Origin否则浏览器或者 CDN 缓存了某个 Origin 的响应对另一个 Origin 复用会出现很难复现的诡异问题。4. 中间层与前端侧最容易踩的坑4.1 开发代理能临时绕过生产环境绕不过很多前端同学在开发环境遇到过这种情况浏览器控制台明明没有跨域报错但部署到测试或生产环境就报。原因多半是本地开发用了代理。代理的原理是把/api开头的请求转发到目标服务器浏览器看到的是当前域名下的请求不满足跨域条件自然没有 preflight。这确实是开发过程中的有效手段但它只是绕过了跨域并没有解决服务端跨域策略。一旦部署到生产前端域名和后端域名不同preflight 就全出来了。所以我的建议是开发时可以开着代理提升效率但联调阶段至少关掉代理验证一次确认后端真的能正确处理跨域请求。否则代理会成为遮羞布把很多配置问题掩盖到上线前才暴露。4.2 网关把 OPTIONS 请求拦截的典型现场网关层吞掉 OPTIONS 是比后端缺头更隐蔽的问题。我曾经在模拟项目X里遇到过前端请求一条接口浏览器发 OPTIONS网关因为没配置对应路由直接返回 405后端日志里完全看不到这个预检请求。排查到最后发现是统一网关的认证插件把 OPTIONS 也当成普通接口过了一遍没有 token 就返回 401。如果你们有统一网关或反向代理建议在最外层把 OPTIONS 请求统一处理掉。以一个常见的反向代理配置为例if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS always; add_header Access-Control-Allow-Headers Content-Type, Authorization, X-Request-Id always; add_header Access-Control-Max-Age 86400; return 204; }注意这里我加了always因为在某些反向代理的配置下add_header只在特定响应码下生效加上always可以保证 204 也带上响应头。另外$http_origin直接回显虽然方便也要确认你接受“任何来源都能拿到 CORS 允许”的风险建议在网关层再做一层来源白名单或交给后端统一过滤。4.3 跨域拦截不是服务端拒绝请求可能已经落地再说一次最容易被忽视的事实CORS 报错不等于服务端拒绝了这个请求。HTTP 请求已经到达后端业务代码可能已经把数据写进数据库了只是浏览器不让前端读取响应。因此遇到跨域报错尤其是 POST、PUT、DELETE 这类写操作第一步应该去后端看日志确认请求有没有进来、有没有产生副作用。我自己就踩过这个坑。某次联调前端一直说跨域后端同事反复说没收到查到最后发现网关日志显示请求已转发到应用应用也执行了插入只是响应回来时 CORS 头不对浏览器拦截前端才以为失败。这种问题如果不及时发现联调阶段就会多出一堆重复数据生产环境更麻烦。所以排查顺序一定是先确认服务端是否执行再处理 CORS 响应头。5. 排查实录与避坑经验5.1 一套可以直接抄的排障流程下面这套流程是我实际在用的你直接照做也能覆盖大部分情况打开 DevTools 的 Network 面板触发一次接口先看请求列表里是否有 OPTIONS 类型的 preflight 请求。选中 preflight 请求查看Response Headers确认Access-Control-Allow-Origin是否存在值是否等于页面 Origin。用 curl 模拟 OPTIONS确认服务端原始响应是否真的缺头排除浏览器缓存干扰。如果 OPTIONS 状态不是 2xx检查路由、认证插件、网关是否放行 OPTIONS。修复后端或网关配置后硬刷新页面或者等Access-Control-Max-Age过期后再验证。最后用 curl 同时验证 OPTIONS 和真实请求的响应头是否都正确。第 2 步里如果 DevTools 里看不到响应头可以直接用 curl 看。第 5 步是重点浏览器缓存会掩盖你刚做的修改。第 6 步也容易被漏掉因为很多人只给 OPTIONS 分支加了头真实业务接口的响应仍然没有 CORS 头。5.2 preflight 通过后主请求仍失败的排查方向有一种让人抓狂的情况OPTIONS 预检已经返回 204相关 CORS 头也都在但主请求依然被拦。这里按概率排序排查方向主业务请求的响应里没有 CORS 头。很多人只在 OPTIONS 分支里加了头真实接口的响应没加导致第二次校验失败。主响应里的Access-Control-Allow-Origin与实际 Origin 不一致比如后端写的是http://localhost:8080页面实际是http://127.0.0.1:8080。请求头列表不匹配且 preflight 缓存过期问题。预检首次成功可能只因为回显了请求头但实际主请求带了另一个头真实请求又验证一次。多级代理丢头。前端到网关再到应用如果只有网关加了头应用又没加最终响应可能缺头。每次遇到“配置看起来都对”的情况就用 curl 分别请求 OPTIONS 和 POST/GET把两个响应头贴出来对比通常一眼就能看到差异。5.3 生产环境里容易忽略的边界情况生产环境里跨域问题会比你本地更复杂因为中间层更多。列几个我遇过或同事踩过的边界情况协议不一致本地http上线强制httpsOrigin 从http://app.example.com变成https://app.example.com配置文件里写死的旧域名就匹配不上。端口不一致登录系统在 8080前端页面在 8081即使同一个域名也算跨域。SSO 登录跳转后 Origin 变化如果前端不是纯静态页面而是由某个认证中心重定向回来Origin 可能变成认证中心的域名白名单漏掉就会出问题。CDN 或浏览器缓存旧响应没有Vary: Origin时缓存可能会把 A 源的响应返回给 B 源造成“偶现”跨域。安全策略扫描或防火墙拦截 OPTIONS部分安全设备默认只放行 GET/POST也会让 preflight 失败。遇到这些边界情况建议在排查清单里加一句把实际页面地址和浏览器里的 Origin 值完整复制出来和后端白名单逐字对比。很多时候不是配置错了是差了一个斜杠、一个端口或一个 s。我现在的习惯是在接口联调 checklist 里直接加一条“用 curl 验证 OPTIONS 响应”再写操作报错时先看服务端日志。做到这两点绝大多数 CORS 崩溃现场都能在十分钟内收敛。最后再分享一个小技巧改完 CORS 配置后尽量把Access-Control-Max-Age调小一点比如 60 秒等稳定了再调回 86400 或更长。这样能避免浏览器一直拿旧缓存做判断省掉来回硬刷新和清缓存的麻烦。这算是我反复被坑之后总结出来的最实用的一个细节。