
联调接口的时候浏览器控制台突然冒出一行红字Access to XMLHttpRequest at http://api.example.com/v1/users from origin http://localhost:5173 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource后端同事回了句“我接口正常啊”前端数据也确实是返回了的可页面就是拿不到。这个场景但凡做过前后端分离开发的人都熟本质就是跨域CORS请求被浏览器拦截。今天我把这类问题从头到尾拆一遍包括同源策略到底拦的是什么、一条 CORS 请求从发出到失败的完整链路、几种主流的后端配置方案、前端开发期的临时绕法再单独聊聊 FastAPI 项目里配置 CORS 的实战细节和最终排查清单争取你看完不用再复制报错去搜索引擎找答案。1. 浏览器里的一记闷棍No Access-Control-Allow-Origin 究竟在怪谁1.1 前端工位上的经典一幕接口通了数据却“看不见”先把这个报错最让人困惑的地方讲透请求其实发出去了后端也正常处理了响应也回到了浏览器但浏览器在把响应交给页面代码之前检查发现——响应头里没有Access-Control-Allow-Origin于是直接拦截并在控制台抛错。我说的“请求发出去了”不是猜测。打开 DevTools 的 Network 面板你会看到那条请求的状态码是 200Response 里也有正常的 JSON 数据。很多前端第一次遇到时会被误导以为是后端没返回数据或者是接口写错了实际上后端压根不知道浏览器做了这层拦截。跨域拦截是浏览器的一种自我保护机制不是服务器端的行为也不是网络层面的失败。1.2 同源策略浏览器对“非本家请求”的默认警惕要理解为什么浏览器要做这种事得先把“同源”这个概念弄清楚。同源指的是两个 URL 的协议、域名、端口三者完全一致只要有一个对不上就是跨域。举例来说页面地址请求地址是否同源原因http://localhost:5173http://localhost:5173/api/users同源协议、域名、端口全一致http://localhost:5173http://localhost:8000/api/users跨域端口不同http://localhost:5173https://localhost:5173/api跨域协议不同http://localhost:5173http://127.0.0.1:5173/api跨域域名不同localhost 和 127.0.0.1 不算同一个同源策略最初就是为了防止恶意网页去读取用户在另一个网站上的数据。如果没有这层限制你在浏览 A 网站时页面里的脚本就可以任意请求 B 网站比如你邮箱所在的网站拿到响应后把数据偷偷传走那整个 Web 的信任体系就崩了。这么设计的代价就是合法的、需要跨域调用的业务也被连坐了。尤其是现在前后端分离成为主流前端跑http://localhost:5173后端跑http://localhost:8000端口不同必然跨域。前端要调第三方开放 API,更是天生跨域。这个矛盾一直存在所以 W3C 才设计了 CORS(Cross-Origin Resource Sharing)这套机制让服务器显式地声明“我允许哪些来源访问我”浏览器检查到声明后才会放行。1.3 简单请求与预检请求为什么有的接口一次请求有的却要两次CORS 机制下浏览器把跨域请求分成两类简单请求和非简单请求也叫预检请求。这个区分直接决定你会看到几次网络请求也决定了后端要配置哪些响应头。满足以下条件的算简单请求请求方法为GET、HEAD、POST三者之一请求头只包含浏览器默认的安全字段如Accept、Accept-Language、Content-Language、Content-Type值为application/x-www-form-urlencoded、multipart/form-data或text/plain不使用XMLHttpRequest的withCredentials或fetch的credentials之外的自定义请求头。简单请求不需要预检浏览器直接发出请求然后在响应里检查Access-Control-Allow-Origin。只要有一个条件不满足比如Content-Type: application/json或者加了自定义头Authorization或者用了PUT、DELETE方法浏览器就会先发一个OPTIONS请求去“问”服务器你允许我这么做吗服务器通过响应头表明允许的方法、头字段、来源之后浏览器才会把真正的业务请求发出去。很多新手配置 CORS 时只处理了业务接口没有处理OPTIONS请求结果 DevTools 里前一个OPTIONS直接返回 404 或 403后续真实请求压根没发出去。这个在第 3 章会详细讲。2. 从报错到链路一个 CORS 请求出发之后发生了什么2.1 请求其实已经成功只是一道“门禁”没开我习惯把整个 CORS 流程看成一个小区门禁系统你的代码是访客浏览器是门卫浏览器本身其实很想放你进去毕竟它对你的页面代码执行很在意但门卫有一套自己的安全工作流必须看到访客证才放行否则就把你挡在门外。回到技术细节当页面里执行fetch(http://localhost:8000/api/users)时浏览器会自动帮你在请求里加一个Origin头比如Origin: http://localhost:5173。这个头表明“我是从这个页面发起的请求”。服务端收到请求后如果配置了 CORS 支持会在响应头里加上Access-Control-Allow-Origin。浏览器拿到响应后会比较请求里的Origin和响应里的Access-Control-Allow-Origin两者匹配才把响应交给页面代码不匹配或缺失就抛出你看到的那个红字报错。这个流程里有个特别反直觉的地方即使 CORS 失败对于简单请求服务端业务代码是执行了的。换句话说如果你这个接口有副作用比如写数据库、扣款就算浏览器拦截了响应服务端的逻辑已经跑过了。这就是为什么 CORS 配置不能只想着“前端能用就行”还要考虑安全与幂等等问题。后端必须把跨域访问当成一种外部访问来对待不能因为是“自己页面发起的”就放松校验。2.2 浏览器拦截的三道关卡用 Chrome DevTools 排查跨域问题时我习惯把整个检查点拆成三段第一关看请求有没有发出去。如果请求在控制台直接给出Failed to fetch或者OPTIONS请求直接 404多半是请求根本没到达正确的后端地址或者预检环节挂了这属于网络和路由层面的问题。第二关看响应是否到达浏览器。如果 Network 面板里能看到后端返回的 200说明请求和响应都正常问题出在响应头不满足浏览器的要求。这一关需要检查的响应头包括Access-Control-Allow-Origin允许访问的来源可以写死某个域名也可以写*Access-Control-Allow-Methods允许的方法列表Access-Control-Allow-Headers非简单请求里允许携带的自定义头Access-Control-Allow-Credentials是否允许携带 Cookie 和 HTTP 认证信息Access-Control-Max-Age预检结果可以缓存多久单位秒。第三关看业务代码是否正确处理了数据。如果响应头都符合要求但页面里依然报错那就要看是不是状态码、数据格式、首发字符等问题这层就跟 CORS 无关了。2.3 用 DevTools 快速定位是哪一道关卡挂了拿到 CORS 报错后我的固定操作顺序是这样的打开 Network 面板刷新页面看发起了哪些请求。如果同时看到OPTIONS和GET先点开OPTIONS看它的 Response Headers 里有没有Access-Control-Allow-*相关的头。没有基本就是后端没处理预检请求。如果没有OPTIONS只有业务请求那就看这个请求的 Response Headers。缺少Access-Control-Allow-Origin就是后端没配 CORS有这个头但值不对就是配置的来源和页面来源不匹配有两个相同的头可能是网关和应用层都加了浏览器会提示The Access-Control-Allow-Origin header contains multiple values。点击请求名左侧切换到 Headers往下翻到 Response Headers 区块配合右侧的“原始头信息”对比看能发现很多格式化视图里被掩盖的细节比如头字段大小写、多值、尾随空格等。这套顺序基本能覆盖绝大多数现场。我发现很多问题其实就卡在“根本没人去看响应头到底长什么样”只是一味地给后端同事转发报错截图。自己动手打开一看答案往往很明显。3. 后端治本把响应头写成浏览器认账的样子3.1 Access-Control-Allow-Origin 的写法和取舍既然报错这么直接最直观的解法就是在后端接口的响应里加上Access-Control-Allow-Origin。最常见的三种写法第一种写死单个来源Access-Control-Allow-Origin: http://localhost:5173这种方式最安全只允许指定来源访问。生产环境我就是这么干的白名单列表里写清楚前端正式域名。第二种用动态来源回显Access-Control-Allow-Origin: http://localhost:5173严格说这里“动态”是指后端从请求的Origin头里取出调用方来源然后把这个来源原样回填到Access-Control-Allow-Origin上同时要校验它是否在白名单里。很多后端配置就是这么做的比如 FastAPI 的allow_origins其实就是这个逻辑不是简单地把*返回而是从允许列表里找匹配项。第三种用通配符Access-Control-Allow-Origin: **表示允许任意来源访问任何页面都能拿到你的接口数据。这是最简单也最危险的做法。我一般只在纯公开数据、没有任何登录态和敏感信息的接口上用它而且它不能和Access-Control-Allow-Credentials: true一起使用。一旦响应里带了Access-Control-Allow-Credentials: trueAccess-Control-Allow-Origin就必须写具体的来源不能用*。这是浏览器规范规定的通配符无法和携带凭证的请求共存配置了也会被浏览器直接拒绝。3.2 预检请求OPTIONS 的处理比你想的更关键后端如果只是给业务接口加了 CORS 响应头遇到非简单请求还是会失败因为浏览器先发的那发OPTIONS预检请求如果没拿到允许信息真正的请求根本不会发。预检请求的处理方式有两种。一种是在后端框架里显式处理OPTIONS路由返回 200 并附上 CORS 头。另一种是用框架自带的 CORS 中间件由中间件统一拦截所有OPTIONS请求并自动响应。后面这种更省心FastAPI 的CORSMiddleware、Spring Boot 的CorsFilter基本都是这个思路。用中间件方案时允许的方法和允许的请求头也要配全。常见的坑是只配了GET、POST结果业务里用了自定义头Authorization预检响应里没有Access-Control-Allow-Headers: Authorization浏览器同样会拦截。报错信息通常长这样Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response。我见过有人反复检查Allow-Origin半天其实问题是Allow-Headers没跟上。Access-Control-Max-Age这个头值得单独说。它的作用是让浏览器把预检结果缓存一段时间单位秒。比如设置Access-Control-Max-Age: 86400一天之内同源同请求模式的预检不会再发能明显减少OPTIONS请求数量降低网络开销。不过要注意这个缓存只对同一种请求方式有效而且不同浏览器对它的上限有自己的限制Chrome 是 2 小时。我之前调一个高频接口每次请求都带自定义头没配Max-Age之前一个业务请求前面永远挂着一个OPTIONS白白多了几十毫秒延迟配上之后只有第一次有预检。3.3 携带 Cookie 时的 Credentials 与通配符冲突涉及登录态时前端fetch或axios会设置credentials: include/withCredentials: true这时后端必须在响应里加上Access-Control-Allow-Credentials: true而且如前所述Access-Control-Allow-Origin不能写成*。因为浏览器在携带凭证的模式下对通配符是拒绝放行的必须明确返回允许的来源并且该来源还要和请求页面的Origin完全一致。如果你用 FastAPI 的CORSMiddleware配置allow_origins[*]同时又把allow_credentialsTrue运行期可能不报错但在浏览器端请求会直接被拦截而且控制台提示往往还是“No Access-Control-Allow-Origin header”特别容易让人一头雾水。我建议在使用中间件时只要业务涉及 Cookie就明确把allow_origins写成具体的来源列表不要偷懒写*。后端配置看起来就是加几个响应头但真正的难点在于不同来源、不同请求方法、不同凭证模式下响应头的组合要求不一样。最好的做法是后端写个统一的 CORS 中间件集中管理白名单而不是在每个接口手动拼。这个统一层的存在既是为了方便更是为了少踩坑毕竟 CORS 配置错一个字段报错信息都是一副样子排查成本很高。4. 前端自救开发期用本地转发绕开跨域拦截4.1 开发服务器自带的转发能力Vite 与 webpack-dev-server如果你是纯前端联调后端一时半会儿改不了 CORS 配置或者只是本地开发不想动生产代码最省事的方案是用前端开发服务器的转发能力。以 Vite 为例在vite.config.js里配置// vite.config.js import { defineConfig } from vite import react from vitejs/plugin-react export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } })配置之后前端代码里请求/api/usersVite 开发服务会把这个请求转发到http://localhost:8000/api/users。浏览器里看到的请求地址是同源的http://localhost:5173/api/users自然不触发跨域检查而 Vite 服务端到后端是普通 HTTP 请求不归浏览器同源策略管。这里有几个关键点第一changeOrigin要设成true这样转发时后端看到的Origin是http://localhost:8000避免某些后端对Host和Origin做校验时报错。第二前端代码里所有跨域接口尽量都用相对路径/api/...不要写死http://localhost:8000。这样切到生产环境时只需要调整proxy配置或者让网关层处理代码不用大改。我见过不少项目把http://localhost:8000写死在 axios baseURL 里结果本地能用、一部署就跨域最后还得返工。webpack-dev-server 也类似在devServer.proxy里配置即可。原理一样都是让请求在开发服务器这一层被转发浏览器和业务代码察觉不到跨域的存在。4.2 浏览器扩展与“关闭跨域校验”模式为什么只适合本地调试网上还有一种方案是装浏览器扩展来移除 CORS 限制或者用带--disable-web-security参数的浏览器启动模式。这个方案确实能快速干掉报错但我建议只在本地调试时用而且要清楚它的副作用一旦关闭跨域校验你访问的任何一个网页都可以自由发起跨域请求读取数据浏览器的安全模型在你本机等于失效了如果此时还在登录着各种网站风险是不小的。这类方案只影响你自己的浏览器换台电脑、换台浏览器、或者让用户访问报错照旧它不是解决问题的根本办法。很多扩展只是给响应头里插入了Access-Control-Allow-Origin: *会掩盖真实的配置问题。等关闭扩展再测问题又浮现出来反而浪费排查时间。所以我的定位很清楚它是个临时调试辅助工具不是工程方案。团队协作时如果靠这个才能跑起联调说明前后端的接口约定或环境配置一定有问题应该回到转发或后端 CORS 配置这条路线上来。4.3 网关与反向代理层面追加 CORS 头除了前端转发还有一种思路是让网关或反向代理层统一处理 CORS。很多项目的架构是浏览器 - Nginx - 后端服务。此时可以选择在 Nginx 层面加 CORS 头而不是改后端代码。这种做法的好处是如果后端是多个服务可以在网关层集中配置不用每个服务各自处理跨域。一个常见的 Nginx 配置片段server { listen 80; server_name api.example.com; # 允许的来源生产环境建议写具体域名 add_header Access-Control-Allow-Origin https://admin.example.com always; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS always; add_header Access-Control-Allow-Headers Authorization, Content-Type always; if ($request_method OPTIONS) { return 204; } location / { proxy_pass http://backend_server; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }Nginx 配置里有一个经典坑add_header指令具有继承性如果在某个location里写了add_header它会整体覆盖父级里定义的所有add_header。换句话说父级加的 CORS 头在子级可能突然消失。遇到“根路径能跨域子路径不能”这种诡异问题时先检查是不是这个原因。另外if ($request_method OPTIONS) { return 204; }是很多博客里流传的写法它能在 Nginx 层面直接终结预检请求但会跳过后续的认证、鉴权等逻辑。如果接口本身还需要校验登录态这种一刀切可能有隐患。更好的做法是让预检请求也进入后端逻辑由后端的 CORS 中间件统一响应。不过在比较简单的静态托管和纯前端路由场景下Nginx 直接挡掉OPTIONS是很常见且有效的提速手段。5. FastAPI 项目的 CORS 配置实战从中间件到常见误用5.1 CORSMiddleware 的核心参数与最小配置因为在 FastAPI 项目里踩过几次 CORS 的坑这里单独开一章把 FastAPI 相关的配置讲细。FastAPI 官方推荐用CORSMiddleware来处理跨域。最基本的配置长这样from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app FastAPI() origins [ http://localhost:5173, http://127.0.0.1:5173, https://admin.example.com, ] app.add_middleware( CORSMiddleware, allow_originsorigins, allow_credentialsTrue, allow_methods[GET, POST, PUT, DELETE, OPTIONS], allow_headers[Authorization, Content-Type], )注意几点origins列表里的域名不要漏了本地 IP 形式。很多人只写了http://localhost:5173结果前端页面用http://127.0.0.1:5173打开跨域还是失败。当初我就是被这个坑了大半小时后来才意识到浏览器里这两个地址被认为是不同的来源。allow_methods不一定非要列全。FastAPI 也支持传[*]表示允许所有方法但如果allow_credentialsTrue同样不允许*与凭证共存。我习惯显式把业务用到的方法写上一个是明确一个是避免把不必要的 HTTP 方法暴露出去。allow_headers容易漏Authorization。只要你的接口要鉴权请求头里就会有Authorization预检请求会校验这个头是否被允许。漏配之后报错信息不会直接说少了哪个头而是笼统说 preflight 失败很容易让人误会成跨域源头配置问题。5.2 本地联调时“一改就生效”不中间件顺序很重要FastAPI 的app.add_middleware是在应用启动时生效的改完 CORS 配置需要重启服务。但如果你的项目使用了其他中间件比如认证中间件、TrustedHostMiddleware、GZipMiddleware它们的执行顺序是有讲究的。CORSMiddleware应该尽量加在外层也就是后 add 的中间件会先执行。如果 CORS 中间件被其他中间件挡住可能出现一种情况普通请求能被处理但预检请求被鉴权中间件提前拒绝返回 401 或 403导致浏览器看不到 CORS 响应头。实践里我遇到过一次项目里写了一个自定义的AuthMiddleware用于校验 JWT它在所有请求里读请求头Authorization。我忘记让它放行OPTIONS请求结果前端每次预检都被 401 挡回去。排查到后来发现根本不是 CORS 配置问题而是中间件把OPTIONS请求的认证流程也走了一遍。修复很简单让鉴权中间件对OPTIONS请求直接放行或者调整中间件注册顺序保证 CORS 中间件先处理跨域逻辑。所以如果你在 FastAPI 项目里配了 CORS 仍报错第一条检查命令就是在服务端日志里看OPTIONS /api/xxx这个请求到底有没有到达你的路由如果压根没到路由就被 401/403 拦截了那问题就出在其他中间件或路由守卫上。5.3 响应头重复和“都被吃掉了”的诡异现象FastAPI 应用中如果同时启用了CORSMiddleware又在某个路由装饰器里手动通过Response加了Access-Control-Allow-Origin头就可能导致响应里出现两个同名 CORS 头。浏览器看到重复且值完全一致一般还能容忍如果值不一致比如一个*一个具体域名浏览器会直接报 “multiple values” 并拒绝放行。正确做法是全站只保留一处 CORS 配置。用框架中间件就不要再在业务路由里手动加 CORS 头。这个原则也适用于其他语言框架。还有一种情况是响应头“被吃掉了”。如果 FastAPI 后面还挂了个网关层Nginx、Cloudflare 等网关可能因为自己配置了 CORS 或者有安全策略把后端的Access-Control-Allow-Origin头覆盖掉。排查时用 curl 直测后端端口和一个请求通过域名入口对比两者的响应头就能快速定位是哪一层出了问题。我习惯把所有后端服务都先 curl 一遍看头再走完整链路看头基本能排除 80% 的“诡异消失”问题。5.4 FastAPI 部署到服务器后仍然跨域怎么办本地明明好的一部署到服务器上就跨域这种问题在 FastAPI 项目里也不少见。常见原因有几个一是前端访问地址换成了https://admin.example.com而后端origins里还留着本地地址且没有加入https://admin.example.com。二是后端跑了 HTTPS但origins里的值写成了http://admin.example.com协议不一致浏览器同样认为不同源。三是反向代理配置问题。用 Nginx / Caddy 转发到 FastAPI 时如果代理层没有保留Origin头后端可能在处理时拿不到正确的来源。但这个我在第 4 章已经讲过重点是先直连后端测试再确认域名链路一步一对比就能定位。6. 查漏补缺配置生效后仍报错的高频现场清单6.1 Allow-Origin 写死了域名端口一换就翻车无论是origins列表还是 Nginx 的add_header如果写死的是完整 URL一定要把端口也考虑进去。开发和测试环境最常见的情况是前端在5173端口跑某天不知道为什么在5174起了一个新实例或者临时换成3000端口跨域立刻失效。排查技巧是去看浏览器请求头的Origin到底长什么样再和后端允许来源列表做字符串比对。注意http://localhost:5173和http://localhost:5173/严格来说也不是完全一致的说法大部分框架解析时会忽略末尾斜杠但为了保险还是前后端对齐最好。6.2 多个 Allow-Origin 头同时存在浏览器只认“唯一值”前面提到过如果应用的 CORS 头由多个中间件或手动设置叠加响应里可能出现两个Access-Control-Allow-Origin头。浏览器会报错The Access-Control-Allow-Origin header contains multiple values http://localhost:5173, http://localhost:5173, but only one is allowed.这时候去 DevTools 的 Response Headers 区域可能会看到被折叠成两行。解决方案就是清理重复配置源确保整个请求链路上只有一个地方输出这个头。FastAPI 项目只管好CORSMiddlewareNginx 层就别再加Access-Control-Allow-Origin反之亦然。一定要分清边界。6.3 自定义头未被 Allow-Headers 放行这个坑在第 3 章提过但因为太常见了再单独列一次。前端请求头里加了X-Requested-With、X-Client-Version、Authorization等自定义字段时预检请求会校验Access-Control-Allow-Headers。如果没包含这些自定义头浏览器直接拦截。我给的通用配置是allow_headers[Authorization, Content-Type, X-Requested-With]但在生产环境更推荐按需最小化只允许真正会用到的头。反正后端能拿到请求头业务需要哪个就允许哪个不需要把所有未知自定义头都放行。6.4 预检请求被登录态校验拦截很多安全框架会在所有非公开接口上强制要求登录态OPTIONS请求也可能被拦。后端要单独放行OPTIONSapp.options(/{full_path:path}) async def preflight(full_path: str): return Response(status_code200)或者更优雅的做法是在鉴权中间件里跳过OPTIONS请求。判断逻辑不复杂如果请求方法是OPTIONS直接放行让 CORS 中间件处理。6.5 一张排查清单把 CORS 报错压到最小口子我把最终排查顺序整理成表格按这个顺序检查能少走很多弯路检查顺序检查点操作方式1请求是否真的发到了后端DevTools Network 看状态码和耗时2是否存在 OPTIONS 预检请求有则先看 OPTIONS 的响应头3响应头里是否有 Allow-Origin没有后端未配置有比对值是否匹配4是否有多个 Allow-Origin有则找重复配置源5是否带凭证Cookie是则 Allow-Origin 不能是*必须有 Allow-Credentials6是否有自定义请求头有则确认 Allow-Headers 已放行7中间件 / 网关是否拦截 OPTIONS服务端日志确认 OPTIONS 到达情况8代理层是否覆盖了 CORS 头curl 直连后端与走域名对比响应头我个人实际做项目的时候CORS 报错 90% 以上都能在前四步里解决。剩下那 10%基本绕不开凭证和自定义头的组合问题。最后补一个经验本地开发时不要急着在后端写死一堆来源先用 Vite 或 webpack 的转发方案把联调跑起来等要部署测试环境、预发环境之前再认真把后端或网关层的白名单收敛好。配置 CORS 的本质不是“让当前请求能通”而是“明确愿意向哪些来源开放哪些能力”这件事想清楚了很多选择和报错背后的逻辑也就不难理解了。