
页面里一个 fetch 请求突然红了一片控制台打印出has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource。如果你做前后端分离的 Web 开发这条报错基本是迟早要碰上的老朋友。我最早被它卡住的时候前后端代码翻了个遍也没发现问题后来才明白这根本不是业务逻辑出错而是浏览器的安全机制在“拦截”服务器压根没把响应的跨域权限打开。这篇文章就把这件事一次讲透。我从同源策略的底层原理讲起再到 FastAPI、Spring Boot、Node.js、PHP 这些常见后端的修复方式以及前端代理、Nginx 转发、JSONP 等替代方案最后附上我这些年排查 CORS 问题时踩过的坑和验证方法。无论你是刚入门的前端新人还是偶尔被拉去救火的后端同学照着这篇文章基本能把跨域问题处理干净。1. 这个报错到底在说什么——CORS 跨域机制拆解1.1 同源策略浏览器为什么“多管闲事”想搞懂 CORS必须先理解浏览器的“同源策略”Same-Origin Policy。简单说浏览器规定一个页面里的脚本只能读取“同源”的服务器资源。所谓同源指协议、域名、端口三者完全一致。举例来说https://a.example.com:443页面里的脚本去请求http://a.example.com:8080/api这时候协议从 https 变成了 http端口从 443 变成了 8080哪怕域名一样也属于跨域。浏览器为什么要设这么一条规矩因为如果没有这个限制恶意站点就能在你看网页的同时偷偷向你的银行、邮箱、内部系统发起请求。想象一下你打开了钓鱼网站页面里的恶意脚本向你的网银后台发一个转账请求浏览器会带上你登录网银后存的 Cookie服务器一看 Cookie 是合法的转账就执行了。这类攻击就是经典的 CSRF跨站请求伪造。有了同源策略浏览器会阻止页面读取跨域响应等于从源头切断了这种盗用身份的可能性。顺着这个逻辑你就明白CORS 报错的本质是浏览器帮你挡住了跨域响应而服务器没有明确说“我可以让这个源来访问”。所以报错里的关键信息始终是那几行响应头缺失而不是请求本身没到服务器。1.2 CORS 机制一套基于 HTTP 头的跨域授权协议CORSCross-Origin Resource Sharing跨域资源共享是 W3C 推出的一套标准核心思想是服务器通过响应头告诉浏览器“允许哪些源访问我”而浏览器会根据这些响应头决定是否把响应数据暴露给页面里的脚本。当浏览器发现请求是跨域时会自动在请求头里带上一个Origin字段比如Origin: https://a.example.com服务器收到请求后如果同意放行会在响应里返回类似这样的头Access-Control-Allow-Origin: https://a.example.com浏览器收到响应后做比对如果Access-Control-Allow-Origin的值等于当前页面的源或者是一个*就把响应交给页面脚本如果没有这个头或者值不匹配就会在控制台抛出你看到的那条 CORS 报错并且把响应体拦下来不让脚本读取。这里要区分两种情况简单请求和预检请求。简单请求指的是请求方法是 GET、HEAD、POST 之一且自定义头只有Accept、Accept-Language、Content-Language、Content-Type且Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain之一。这类请求浏览器会直接发出靠响应里的Access-Control-Allow-Origin判断放不放行。只要条件不满足比如用 PUT、DELETE或者Content-Type: application/json或者带了自定义头Authorization、X-Custom-Header浏览器就会先发一个OPTIONS 请求也就是预检请求去问服务器“我准备这么跨域请求你允许吗”服务器需要用Access-Control-Allow-Methods、Access-Control-Allow-Headers来回应“允许哪些方法和哪些头”用Access-Control-Max-Age告诉浏览器多久内不用重复预检。预检通过了浏览器才会发真正的业务请求。很多人排查 CORS 问题只盯着业务接口有没有返回Access-Control-Allow-Origin却忽略了 OPTIONS 预检请求这一步结果就是实际请求根本没发出去或者被中间层拦截了后面我会专门讲这个坑。2. 后端修复从根源上给响应加上 CORS 头2.1 一切的基础在响应中手动添加跨域响应头网上很多“急用版”教程教你直接在请求入口加三个响应头这确实是底层原理几乎所有框架的 CORS 配置最终都是在做同一件事。拿 PHP 举例如果你用的是原生代码最粗暴的写法是header(Access-Control-Allow-Origin: *); header(Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS); header(Access-Control-Allow-Headers: Content-Type, Authorization);对简单请求来说一行Access-Control-Allow-Origin就够了剩下的方法头和请求头是给预检请求用的。但实际开发中我不建议长期依赖这种裸写方式因为你要处理的问题会越来越多多域名白名单、带 Cookie 的凭证请求、对动态源的反射等等手写 header 很容易漏也容易写错。2.2 FastAPI 场景CORSMiddleware 的正确打开方式如果你用的是 FastAPI这是目前 Python 后端里做接口服务非常常见的框架它的解决方案很成熟直接用官方提供的 CORSMiddleware 就行。基本配置长这样from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app FastAPI() app.add_middleware( CORSMiddleware, allow_origins[ http://localhost:5173, https://admin.example.com, ], allow_credentialsTrue, allow_methods[*], allow_headers[*], )这里的 import 路径要注意在较新的 fastapi 版本里CORSMiddleware已经从starlette.middleware.cors调整到了fastapi.middleware.cors。我见过不少人在老教程里复制了starlette.middleware.cors的写法结果项目里找不到包而报错。参数理解起来很直白allow_origins允许访问的源列表必须是完整的协议 域名 端口。千万别写http://localhost:5173/这种带尾部斜杠的浏览器比对 Origin 时很严格一个斜杠就会导致放行失败。allow_credentials是否允许携带 Cookie。这里有个最重要的限制当allow_credentialsTrue时allow_origins不能使用[*]。这是浏览器的硬性规定因为*表示允许任何源同时又允许携带凭证等于把用户的身份信息暴露给了任意站点任何浏览器都会拒绝这种组合。allow_methods、allow_headers预检请求时返回的可用方法和请求头日常开发直接给[*]就行但如果你有安全和最小化的洁癖可以列成具体的。如果你做的是不需要登录、完全公开的数据接口图省事可以直接allow_origins[*]并且不给 credentials。但注意一旦接口需要读 Cookie 里的会话信息那你必须老老实实列源并且把allow_credentialsTrue打开。2.3 其他后端框架的写法速查不是所有人都用 FastAPI这里把另外几种常见后端框架的 CORS 配置也一并列出来方便你按图索骥。Spring Boot 项目里最省事的办法是给单个接口加CrossOrigin注解CrossOrigin(origins http://localhost:5173) GetMapping(/api/user) public User getUser() { return userService.getUser(); }但项目接口一多我建议用全局配置类统一管理Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:5173, https://admin.example.com) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }如果你是 Spring Security 和 Spring MVC 同时存在的项目要留意安全过滤器链也可能拦截 OPTIONS 请求。常见做法是在 Security 配置里放行预检请求http.cors().and().csrf().disable() .authorizeRequests() .antMatchers(HttpMethod.OPTIONS, /**).permitAll() ...Node.js 的 Express 项目用cors中间件是最快的这也是社区标准做法const cors require(cors); app.use(cors({ origin: [http://localhost:5173, https://admin.example.com], credentials: true, methods: [GET, POST, PUT, DELETE, OPTIONS], allowedHeaders: [Content-Type, Authorization] }));如果你不希望用中间件自己写也不难核心就是设置响应头。但注意cors 中间件在预检请求时会直接返回 204业务请求则继续走路由这个逻辑自己实现容易漏。PHP 方面除了前面手动加 header 的方式如果用了 Laravel官方包里有一个fruitcake/laravel-cors旧版或 Laravel 9 内置的HandleCors中间件配置写在config/cors.php里大同小异。3. 前端配合代理转发、Cookie 凭证与 JSONP3.1 开发环境代理让浏览器以为“没有跨域”后端修 CORS 是最根治的方式但在开发环境里更常见的做法是让前端启动一个本地开发服务器把所有/api请求转发到真实后端。因为浏览器看到的请求是同源的所以压根不会触发 CORS 拦截。这个思路叫“代理转发”原理很简单开发服务器在中间扮演了一个“传话筒”的角色。Vue 项目用 Vue CLI 时在vue.config.js里配置devServer.proxymodule.exports { devServer: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true, pathRewrite: { ^/api: /api } } } } };Vite 项目则在vite.config.js里配置export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, /api) } } } });配置完之后前端代码里请求地址直接写/api/user浏览器看到的就是http://localhost:5173/api/user同源请求不会出现 CORS 报错。开发服务器收到请求后再转发给http://localhost:8000然后把响应原样返回给前端。这个方案的好处是你开发时完全不用关心后端的 CORS 配置是什么前端和后端的代码可以并行开发互不阻塞。我见过有人问“配了代理之后后端那边获取到的用户真实地址变成 localhost 了怎么办”其实代理转发后后端收到的连接确实来自前端开发服务器需要获取原始请求的客户端 IP、真实 Host、协议等信息时就靠代理设置标准转发头。上面配置里的changeOrigin: true只影响请求头里的Host字段而客户端的真实 IP 通常由X-Forwarded-For、X-Real-IP这类头传递。你可以在代理配置里手动加headers: { X-Real-IP: }但更标准的做法是让代理自动附加这些头Nginx 下则是用proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;。如果你是做日志分析或者需要真实来源 IP 的功能这一点务必跟运维同学确认清楚。3.2 生产环境方案Nginx 反向代理开发环境用了代理生产环境一样可以用代理。前后端分离部署时通常会用一个 Nginx 同时服务前端静态文件和 API 反向代理这样从用户浏览器的角度看前端页面和接口都在同一个域名下根本没有跨域问题。一个典型的配置片段server { listen 80; server_name www.example.com; location / { root /var/www/html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }这样配置后用户在浏览器打开https://www.example.com页面里的接口请求地址是https://www.example.com/api/user同源浏览器不会做任何跨域检查。Nginx 会把/api/路径的请求原样转发给后端的 8000 端口。后端根本不需要开启 CORS因为所有请求都来自 Nginx 这个“同源”入口。用 Nginx 方案时有一个小细节如果后端在响应里生成了重定向地址或绝对链接要注意proxy_redirect配置否则返回的 Location 头可能是内网地址导致浏览器跳转失败。还有如果某个接口已经设置了 CORS 头Nginx 转发时不要重复添加否则响应里出现两个相同头也可能引发解析异常虽然通常是最后一个生效。3.3 带 Cookie 的跨域请求credentials 三件套如果接口需要携带 Cookie比如保存登录态单纯的Access-Control-Allow-Origin: *是不够的你还需要前端和前端后端互相配合。这一步配置不全请求往往表现为“接口返回 200 但页面代码读不到数据”因为响应虽然到了浏览器却被拦了下来。前端要用 fetch 时需要显式指定fetch(https://api.example.com/user, { method: GET, credentials: include });axios 里要设axios.get(https://api.example.com/user, { withCredentials: true });同时后端必须返回Access-Control-Allow-Origin: https://www.example.com Access-Control-Allow-Credentials: true三个条件缺一不可前端允许带凭证、后端允许源是具体源而非*、后端允许凭证。如果少了Access-Control-Allow-Credentials浏览器会报告The value of the Access-Control-Allow-Credentials header in the response is which must be true。如果Access-Control-Allow-Origin是*报错则是The value of the Access-Control-Allow-Origin header in the response must not be the wildcard * when the requests credentials mode is include。另外当你有多个前端域名时不能配置多个Access-Control-Allow-Origin规范里这个头只允许一个值。最常用的做法是后端读请求头里的Origin在白名单里校验通过后就把它原样反射回Access-Control-Allow-Origin同时加上Vary: Origin。因为响应内容取决于请求头 Origin加了Vary才能让 CDN 和浏览器正确缓存。3.4 JSONP老方法也能解决一部分问题JSONP 可能是你听过但没怎么用过的方案它在 CORS 规范出现之前就存在了。核心思想是script标签不受同源策略限制。所以前端动态创建script标签把请求地址作为src服务端返回的不是标准 JSON而是一段调用回调函数的 JS 代码。大概长这样script function handleData(data) { console.log(data); } var script document.createElement(script); script.src https://api.example.com/user?callbackhandleData; document.body.appendChild(script); /script后端以 PHP 为例需要把结果包在 callback 参数指定的函数名里输出?php $callback $_GET[callback]; $data [name 张三, age 18]; echo $callback . ( . json_encode($data) . );JSONP 的实际应用场景现在很窄了它只能发 GET 请求无法发 POST、PUT、DELETE也没办法设置自定义请求头错误处理也很别扭网络异常时浏览器不会触发script的 onerror 之外的标准化回调。唯一还能派上用场的情况是你无法修改对方服务器的 CORS 配置而对方又愿意提供 JSONP 接口。比如一些老牌第三方统计服务、部分公共数据接口还在用 JSONP。能上 CORS 就优先 CORSJSONP 只是兜底方案里的兜底。4. 错误配置与排查实录我踩过的坑和验证方法4.1 经典配置错误反射所有 Origin 并把 credentials 也设为 true这是我在实际项目里见过最危险的错误配置网上不少教程为了避免多域名白名单的麻烦会教你直接从请求头里读Origin然后反射回去比如 FastAPI 里自定义中间件app.middleware(http) async def cors_middleware(request: Request, call_next): response await call_next(request) response.headers[Access-Control-Allow-Origin] request.headers.get(origin, *) response.headers[Access-Control-Allow-Credentials] true return response表面看前端任何域名都能正常访问接口Cookie 也能带上好像很完美。但实际上这个配置等价于任何恶意网站发起跨域请求时你的接口都会完全放行包括携带用户 Cookie 的请求。攻击者可以在这基础上构造恶意页面诱导用户访问然后向你的接口发起跨域 POST、PUT 请求执行敏感操作因为响应头允许任意 Origin 允许携带凭证浏览器不会拦截响应攻击脚本就能读到接口返回的数据。这实际上把 CORS 的安全防护功能完全废掉了。正确的做法是保存一个明确允许的源列表校验通过后再反射from fastapi.responses import JSONResponse ALLOWED_ORIGINS {https://admin.example.com, https://www.example.com} app.middleware(http) async def cors_middleware(request: Request, call_next): origin request.headers.get(origin) response await call_next(request) if origin in ALLOWED_ORIGINS: response.headers[Access-Control-Allow-Origin] origin response.headers[Access-Control-Allow-Credentials] true response.headers[Vary] Origin return response中间件方式我一般只在调兼容问题时用正式项目还是推荐直接用框架自带的 CORS 中间件把白名单写死在配置里维护成本和安全边界都更清晰。4.2 预检请求被拦截OPTIONS 请求为什么 404这个坑很隐蔽。有一次我给项目加一个自定义请求头前端一调用发现请求直接失败浏览器报的还是 CORS 错误但接口明明能通。我在浏览器 Network 面板里仔细一找才看到先发出的是一个 OPTIONS 请求返回 404。业务接口是好的但预检请求根本没送达后端的业务路由。原因是因为项目在网关层或 Web 服务器层对请求做了权限校验只放行了 GET、POST 等常规方法OPTIONS请求被识别为非法方法直接拒了。排查思路是先用普通简单请求测一下看是不是只有带自定义头或使用 PUT/DELETE 时才挂挂了就重点查网关、Nginx、Spring Security、Shiro 这些前置层有没有对 OPTIONS 放行。在 Nginx 层通常可以做这样的处理if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin $http_origin; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS; add_header Access-Control-Allow-Headers Content-Type, Authorization; add_header Access-Control-Max-Age 3600; return 204; }但最稳的做法还是框架本身负责处理 CORS 响应网关层不要乱加头避免重复操作。4.3 排查工具三板斧curl 模拟、Network 面板和代理工具遇到 CORS 报错不要慌先按顺序做三件事。第一步用 curl 模拟请求直接看后端返回了什么头。用 curl 加-i能看到响应头加-X OPTIONS能模拟预检curl -i -X OPTIONS http://localhost:8000/api/user \ -H Origin: http://localhost:5173 \ -H Access-Control-Request-Method: GET \ -H Access-Control-Request-Headers: Authorization, Content-Type如果 curl 请求后能看到Access-Control-Allow-Origin说明后端配置没毛病那问题大概率出在浏览器侧或者中间层。如果 curl 没看到对应响应头那问题就在后端直接去调后端配置。第二步打开浏览器开发者工具的 Network 面板勾选 Fetch/XHR 筛选看请求的具体情况。重点看两个地方一是有没有 OPTIONS 预检请求状态码是多少二是实际请求的响应头里有没有Access-Control-Allow-Origin。如果看到请求标成红色但具体响应头是正常的可能是浏览器缓存了旧的错误响应强制刷新或者清一下缓存再试。第三步如果前后端之间有 Nginx、网关等多层可以逐层验证。在 Nginx 上临时给某个 location 加 headers 模块返回固定响应头或者用curl -k -v直接访问上游地址和后端地址做对比快速定位是哪一层丢掉了响应头。我排查过一次很头疼的问题最后发现是 CDN 缓存了不带 CORS 头的旧响应加了Vary: Origin才从根上解决。4.4 常见 CORS 错误配置速查表错误配置或现象具体表现正确做法Access-Control-Allow-Origin: *且接口需要 Cookie带凭证请求时浏览器直接拒绝响应换成具体源列表配合Access-Control-Allow-Credentials: true配置里写了https://a.com/尾部带斜杠Origin 匹配不上请求仍被拦截去掉末尾斜杠确保协议、域名、端口精确一致前端用withCredentials后接口没有返回Access-Control-Allow-Credentials返回 200 但前端读不到数据后端加Access-Control-Allow-Credentials: trueOPTIONS 请求返回 404 或 403带自定义头或非简单方法时请求失败在网关、安全框架、Nginx 层放行 OPTIONS 预检请求后端设置了多个Access-Control-Allow-Origin头浏览器不识别多个值可能忽略只能返回一个值多域名通过反射或白名单处理配置了 Nginx 代理后仍出现跨域前端请求没有走代理或代理路径写错确认前端请求路径匹配 proxy 规则且不存在重复的 CORS 头浏览器缓存了旧失败的响应改完配置后发现依然报错强刷、清缓存或确认 CDN 层加了Vary: Origin5. 最后再分享几个实操体会我个人的习惯是开发环境优先用 Vite 或 Webpack 代理解决让前后端并行开发互不干扰生产环境优先用 Nginx 反代做成同源这样后端代码里基本不需要写 CORS 逻辑只有当接口要开放给第三方域名调用时才在代码里配置白名单式的 CORS 规则。后来经手一个需要支持多租户产品时每个租户的域名都不同不能写死才用白名单反射的方案后端动态校验 Origin 在白名单内就原样返回并始终带上Vary: Origin这样既支持了动态域名又避开了安全漏洞。这只是一种经验性做法实际还要结合业务对安全性和灵活性的要求来取舍。还要提醒一个容易被忽略的点如果你用的是 Nginx 反代后端已经正确返回了 CORS 头那 Nginx 就别再做任何跨域头的追加否则会出现重复头或者覆盖问题。排查时记得先把“散弹枪式”的配置收敛统一再做定位。跨域这问题不复杂但链条长、环节多只要掌握了原理以后再多奇怪变体都难不住你。