前端调接口,浏览器控制台甩出一行红字:Access to XMLHttpRequest at 'http://xxx' from origin 'http://yyy' has been blocked by CORS policy。这个场景几乎每个做 Web 开发的人都遇到过,有人改了两行后端代码就通了,有人折腾一下午还在原地打转。CORS 这三个字母看着简单,背后牵扯的是浏览器同源策略、预检请求、凭证传递、缓存机制一整套东西,任何一个环节理解偏了,都会出现“配置看起来没问题但就是不生效”的尴尬局面。
这篇内容围绕跨域问题与 CORS 解决方案展开,把我在实际项目里反复踩过的坑、调试过的链路、验证过的配置方式整理出来。适合正在被跨域卡住的后端同学、需要理解请求为什么被拦的前端同学,以及想把这件事一次性搞明白而不是每次靠搜索复制粘贴的开发者。下面不堆概念,直接从“请求到底经历了什么”讲起,再落到具体配置和排错。
1. 先把跨域这件事的来龙去脉理清楚
很多人对跨域的理解停留在“域名不一样就会被拦”,这个说法不算错,但太粗。真正决定一个请求会不会被浏览器拦下来的,是同源策略,而“源”的判定由协议、域名、端口三部分组成,三者完全一致才算同源。http://a.com和https://a.com不同源,因为协议不同;http://a.com和http://a.com:8080不同源,因为端口不同;http://a.com和http://b.a.com不同源,因为域名不同。这里没有“差不多”的余地,浏览器是逐字符比对的。
1.1 同源策略到底在防什么
要理解 CORS 为什么长这样,得先明白同源策略的动机。假设你登录了某个银行站点,浏览器里存着它的 Cookie,这时候你打开了一个恶意页面,如果那个页面能随意向银行站点发请求并读取响应内容,你的账户信息就暴露了。同源策略的核心作用就是:限制一个源的脚本读取另一个源的资源。注意关键词是“读取”,请求本身往往能发出去,被拦的是响应的读取。
这个区分特别重要,因为它解释了一个常见困惑:为什么在 Network 面板里能看到请求发出去了、服务端日志也打印了,但前端就是拿不到数据。因为浏览器确实把请求发了,只是在响应回来时发现对方没给“通行证”,于是把响应内容对脚本隐藏了。所以跨域报错很多时候不是“请求没到”,而是“响应被扣下了”。
1.2 简单请求与非简单请求的分水岭
CORS 把请求分成两类,处理方式完全不同。简单请求需要同时满足几个条件:方法是 GET、HEAD 或 POST 之一;头部只包含安全列表内的字段(如 Accept、Accept-Language、Content-Language、Content-Type 的有限取值);Content-Type 只能是application/x-www-form-urlencoded、multipart/form-data、text/plain三种之一。只要有一条不满足,就是非简单请求。
这个分类不是学术游戏,它直接决定了浏览器要不要先发一个 OPTIONS 预检请求。简单请求直接发出,服务端在响应里带上Access-Control-Allow-Origin就行;非简单请求则要先发 OPTIONS 问一句“我能不能这么干”,服务端同意了,浏览器才发真正的请求。实际项目里,只要用了application/json作为 Content-Type,或者加了自定义头部(比如带 token 的Authorization),请求立刻变成非简单请求,预检就绕不开了。这也是为什么很多人明明只改了个 Content-Type,跨域行为就变了。
1.3 预检请求的完整往返过程
预检请求(preflight)是一个 OPTIONS 请求,浏览器会自动带上几个关键头部:Origin表明来源,Access-Control-Request-Method表明真正请求想用的方法,Access-Control-Request-Headers表明想用的自定义头部。服务端收到后,需要在响应里明确回应:允许哪个源(Access-Control-Allow-Origin)、允许哪些方法(Access-Control-Allow-Methods)、允许哪些头部(Access-Control-Allow-Headers)。
这里有个容易被忽略的点:预检请求不带 Cookie 和认证信息,它是匿名的。所以如果你的服务端对 OPTIONS 请求做了鉴权拦截,预检会直接失败,真正的请求根本发不出去。我在一个项目里就遇到过,网关层统一校验 token,结果 OPTIONS 请求没有 token 被拦成 401,前端看到的是跨域错误,实际根因是鉴权把预检挡了。排查这类问题时,先看 OPTIONS 请求的响应状态码,比盯着 CORS 头部看更有效。
2. 服务端配置:不同技术栈的落地写法
理解了机制,配置本身其实不复杂,难的是不同框架的默认行为和中间件顺序。下面按常见技术栈给出可直接参考的写法,重点标注那些“看起来配了其实没生效”的细节。
2.1 通用响应头与它们的真实含义
不管用什么语言,CORS 最终都是靠一组响应头说话。核心的几个必须记牢:
| 响应头 | 作用 | 常见坑 |
|---|---|---|
Access-Control-Allow-Origin | 允许的源 | 配*时不能同时带凭证 |
Access-Control-Allow-Methods | 允许的方法 | 预检响应里必须包含实际方法 |
Access-Control-Allow-Headers | 允许的请求头 | 漏配自定义头会导致预检失败 |
Access-Control-Allow-Credentials | 是否允许带凭证 | 为 true 时源不能是* |
Access-Control-Max-Age | 预检结果缓存秒数 | 太长会导致配置变更后不生效 |
Access-Control-Expose-Headers | 暴露给脚本的响应头 | 不配则脚本读不到自定义响应头 |
Access-Control-Allow-Origin配*是最省事也最容易出问题的做法。一旦前端需要带 Cookie(比如withCredentials = true),浏览器会强制要求这个头必须是具体的源,不能是通配符,否则直接报错。正确做法是读取请求的Origin头,校验它在白名单里之后,把它原样回写。这样既支持多源,又能带凭证。
2.2 Node.js 环境下的中间件顺序陷阱
在 Express 或 Koa 里配 CORS,最常见的坑是中间件顺序。CORS 中间件必须放在路由和鉴权中间件之前,否则预检请求可能在到达 CORS 处理逻辑前就被拦掉了。用cors这个库的话,基础配置大概是这样:
const cors = require('cors'); const app = express(); const whitelist = ['http://localhost:3000', 'https://your-site.com']; app.use(cors({ origin: function (origin, callback) { if (!origin || whitelist.includes(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], maxAge: 600 }));注意origin用函数而不是字符串,是为了支持多源白名单。另外maxAge设成 600 秒是个折中值,太短预检频繁影响性能,太长则改了配置要等缓存过期才生效,调试阶段建议设小一点甚至设 0。
2.3 Java Spring 体系里的两种配法
Spring 项目里配 CORS 有全局和局部两种思路。全局配置用WebMvcConfigurer:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("https://your-site.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }局部配置则在 Controller 上加@CrossOrigin注解。这里有个实际经验:如果项目里同时用了网关或安全框架(比如某些安全拦截器),全局 CORS 配置可能被更外层的过滤器覆盖,导致注解和配置都不生效。判断方法是看响应头里到底有没有Access-Control-Allow-Origin,如果没有,说明请求根本没走到 CORS 处理层,得往更外层找。
2.4 Nginx 反向代理层的统一处理
很多生产环境会在 Nginx 层统一处理 CORS,好处是后端服务不用各自配置。典型写法:
location /api/ { 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' always; add_header 'Access-Control-Allow-Credentials' 'true' always; add_header 'Access-Control-Max-Age' 600 always; return 204; } add_header 'Access-Control-Allow-Origin' '$http_origin' always; add_header 'Access-Control-Allow-Credentials' 'true' always; proxy_pass http://backend; }always参数很关键,它保证即使响应是 4xx 或 5xx 也会带上 CORS 头。不加的话,后端报错时前端看到的是跨域错误而不是真实的错误信息,排查会被带偏。另外用$http_origin动态回写源,比写死一个域名灵活,但要注意这等于放行了所有源,生产环境最好配合白名单校验。
3. 那些配置了却不生效的典型场景
CORS 最折磨人的地方不是不会配,而是配了没反应。下面这几种情况我都实际遇到过,每一种的排查路径都不一样。
3.1 重复设置响应头导致的冲突
当 Nginx 和后端应用都设置了Access-Control-Allow-Origin,浏览器会收到两个同名头,直接判定为非法并报错。报错信息通常是“The 'Access-Control-Allow-Origin' header contains multiple values”。这种问题的根因是两层都配了 CORS,解决方式是只保留一层。我的习惯是:如果用了 Nginx 统一处理,后端就把 CORS 配置关掉;如果后端自己处理,Nginx 就不要加 CORS 头。判断当前是哪层在加头,看响应头里该字段出现了几次即可。
3.2 凭证模式下通配符失效
前端设置了withCredentials: true,后端Access-Control-Allow-Origin却配了*,浏览器会直接拒绝。这个组合是非法的,规范明确禁止。解决办法前面提过,动态回写具体的 Origin。还有一个连带问题:带凭证时,Access-Control-Allow-Headers和Access-Control-Allow-Methods也不能用*,必须逐个列出。很多人只改了 Origin 就以为完事,结果预检还是失败,就是漏了这两个。
3.3 预检缓存让修改“看起来没生效”
调试时改了后端配置,刷新页面发现还是报错,重启服务也没用。这时候很可能是浏览器缓存了之前的预检结果。Access-Control-Max-Age设得越大,缓存越久。排查时打开开发者工具的 Network 面板,勾选“Disable cache”或者手动清一下,再观察 OPTIONS 请求是否重新发出。我一般建议开发环境把maxAge设成 0 或很小的值,避免这种“改了没反应”的假象浪费时间。
3.4 重定向打断 CORS 流程
如果服务端对请求做了 301 或 302 重定向,浏览器在跟随重定向时对 CORS 的处理会变得复杂,尤其是重定向到不同源时,很容易失败。实际项目中常见于 http 跳 https、或者末尾斜杠补全。排查方法是看 Network 面板里请求是不是先返回了 3xx,再跟一个失败的新请求。解决思路是尽量让 API 请求直达最终地址,避免中间重定向,或者在重定向的响应上也带上 CORS 头。
4. 前端侧的配合与调试手段
跨域从来不是后端一个人的事,前端的请求方式、凭证设置、调试工具的使用,都会影响最终结果。这一节说说前端能做什么、该看什么。
4.1 请求库的凭证配置差异
不同请求库设置凭证的方式不一样。原生fetch要显式写credentials: 'include';axios 则是withCredentials: true;jQuery 的$.ajax是xhrFields: { withCredentials: true }。这些配置必须和服务端的Access-Control-Allow-Credentials: true配套,缺一不可。我见过前端设了withCredentials但后端没开凭证支持,结果 Cookie 带不过去,登录态丢失,表现成“接口 401 但明明登录了”,实际是跨域凭证没打通。
4.2 用开发者工具定位问题层级
遇到跨域报错,别急着改代码,先按这个顺序看:第一,Network 面板里有没有 OPTIONS 请求,状态码是多少;第二,OPTIONS 的响应头里有没有Access-Control-Allow-Origin,值对不对;第三,如果 OPTIONS 通过了,真正的请求状态码是什么;第四,Console 里的报错原文是什么。这四步能区分出问题出在预检阶段、真实请求阶段还是响应读取阶段。比如 OPTIONS 返回 401,那是鉴权问题不是 CORS 问题;OPTIONS 返回 200 但没有 CORS 头,那是配置没生效。
4.3 开发环境的代理方案
本地开发时,前端跑在 3000 端口、后端跑在 8080 端口,天然跨域。这时候与其折腾后端 CORS,不如用开发服务器代理。Webpack Dev Server 或 Vite 都可以配 proxy,把/api开头的请求转发到后端,浏览器看来是同源请求,跨域问题直接消失。这种方案只适合开发环境,生产环境还是得靠真正的 CORS 或同域部署。代理配置示例:
// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } }changeOrigin: true会把请求的 Host 头改成目标地址,某些后端会校验 Host,不加这个可能被拒。
5. 几个容易踩的边界情况与经验总结
除了上面这些常规场景,还有几个边界情况值得单独拎出来说,都是我在实际项目里踩过或者帮别人排查过的。
5.1 自定义响应头读不到的问题
前端想读取响应里的自定义头(比如X-Total-Count做分页),发现response.headers.get('X-Total-Count')返回 null。这不是跨域没配好,而是 CORS 默认只暴露少数几个安全响应头。要让脚本读到自定义头,服务端必须加Access-Control-Expose-Headers: X-Total-Count。这个头经常被忘,因为请求本身是通的,只是读不到某个字段,报错也不明显。
5.2 本地文件与特殊协议的限制
用file://协议直接打开 HTML 文件发请求,Origin 是null,很多服务端的白名单校验会把它拒掉。这种场景在本地调试静态页面时偶尔遇到,解决办法是起一个本地静态服务器,用http://localhost访问,而不是双击文件打开。另外某些特殊协议下的请求,CORS 行为也有差异,遇到时优先确认 Origin 的实际值。
5.3 配置白名单时的安全考量
动态回写 Origin 虽然灵活,但如果校验不严,等于对所有源开放。正确做法是维护一个明确的白名单,请求的 Origin 在白名单内才回写,否则不返回 CORS 头。不要用简单的字符串包含判断(比如origin.includes('your-site')),因为your-site.evil.com也能通过。要用精确匹配或者严格的正则锚定。这个细节在安全评审时经常被提,值得提前做好。
5.4 我的排查习惯
最后分享一个我自己的排查习惯:遇到跨域问题,先用 curl 直接请求接口,看服务端返回的原始响应头里到底有没有 CORS 相关字段。curl 不受浏览器同源策略约束,能拿到最真实的响应。如果 curl 里没有 CORS 头,那问题百分百在服务端配置;如果 curl 里有但浏览器还报错,那问题在浏览器侧,比如缓存、凭证、或者多层代理覆盖。这个二分法能快速把问题范围缩小一半,比在浏览器里反复刷新高效得多。
跨域这件事,说到底是浏览器安全模型和实际开发便利性之间的博弈。CORS 是官方给出的标准解法,理解它的每个头部在做什么、每个阶段在发生什么,比记住某段配置代码更有价值。上面这些配置和排查思路,覆盖了大部分日常场景,剩下的边角情况,靠的是遇到问题时按“预检是否通过、响应头是否存在、凭证是否匹配”这条主线去定位,基本都能找到出口。