页面里一个 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 头的跨域授权协议
CORS(Cross-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_credentials=True, 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_credentials=True时,allow_origins不能使用["*"]。这是浏览器的硬性规定,因为*表示允许任何源,同时又允许携带凭证,等于把用户的身份信息暴露给了任意站点,任何浏览器都会拒绝这种组合。allow_methods、allow_headers:预检请求时返回的可用方法和请求头,日常开发直接给["*"]就行,但如果你有安全和最小化的洁癖,可以列成具体的。
如果你做的是不需要登录、完全公开的数据接口,图省事可以直接allow_origins=["*"]并且不给 credentials。但注意,一旦接口需要读 Cookie 里的会话信息,那你必须老老实实列源,并且把allow_credentials=True打开。
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 凭证与 JSONP
3.1 开发环境代理:让浏览器以为“没有跨域”
后端修 CORS 是最根治的方式,但在开发环境里,更常见的做法是让前端启动一个本地开发服务器,把所有/api请求转发到真实后端。因为浏览器看到的请求是同源的,所以压根不会触发 CORS 拦截。这个思路叫“代理转发”,原理很简单,开发服务器在中间扮演了一个“传话筒”的角色。
Vue 项目用 Vue CLI 时,在vue.config.js里配置devServer.proxy:
module.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 request's 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?callback=handleData'; 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 就优先 CORS,JSONP 只是兜底方案里的兜底。
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: true |
| OPTIONS 请求返回 404 或 403 | 带自定义头或非简单方法时请求失败 | 在网关、安全框架、Nginx 层放行 OPTIONS 预检请求 |
后端设置了多个Access-Control-Allow-Origin头 | 浏览器不识别多个值,可能忽略 | 只能返回一个值,多域名通过反射或白名单处理 |
| 配置了 Nginx 代理后仍出现跨域 | 前端请求没有走代理,或代理路径写错 | 确认前端请求路径匹配 proxy 规则,且不存在重复的 CORS 头 |
| 浏览器缓存了旧失败的响应 | 改完配置后发现依然报错 | 强刷、清缓存,或确认 CDN 层加了Vary: Origin |
5. 最后再分享几个实操体会
我个人的习惯是:开发环境优先用 Vite 或 Webpack 代理解决,让前后端并行开发互不干扰;生产环境优先用 Nginx 反代做成同源,这样后端代码里基本不需要写 CORS 逻辑;只有当接口要开放给第三方域名调用时,才在代码里配置白名单式的 CORS 规则。后来经手一个需要支持多租户产品时,每个租户的域名都不同,不能写死,才用白名单反射的方案:后端动态校验 Origin 在白名单内就原样返回,并始终带上Vary: Origin,这样既支持了动态域名,又避开了安全漏洞。这只是一种经验性做法,实际还要结合业务对安全性和灵活性的要求来取舍。
还要提醒一个容易被忽略的点:如果你用的是 Nginx 反代,后端已经正确返回了 CORS 头,那 Nginx 就别再做任何跨域头的追加,否则会出现重复头或者覆盖问题。排查时记得先把“散弹枪式”的配置收敛统一,再做定位。跨域这问题不复杂,但链条长、环节多,只要掌握了原理,以后再多奇怪变体都难不住你。