1. 跨域问题的本质与CORS的定位
前后端分离开发做了这么多年,跨域这个问题几乎是每个前端工程师入行后绕不开的第一道坎。我记得早年带新人的时候,十个人里有八个会在联调阶段卡在浏览器控制台那行红色的报错上——Access to fetch at 'http://xxx' from origin 'http://yyy' has been blocked by CORS policy。这个报错看起来吓人,但只要你理解了浏览器为什么要做这件事,解决起来其实思路非常清晰。
CORS,全称 Cross-Origin Resource Sharing,中文叫跨域资源共享,它是浏览器提供的一种安全机制,允许服务端通过特定的响应头来声明"哪些外部源可以访问我的资源"。注意这里的关键词是"服务端声明"——CORS 的控制权在服务端,不在前端。很多新手第一反应是在前端加各种配置、装各种插件,方向就错了。浏览器同源策略是浏览器自身的安全防线,前端代码再怎么改也绕不过去,真正能"放行"的只有服务端返回的正确响应头。
那什么是"跨域"?简单说,当你的页面来源(origin)和目标请求的地址在协议、域名、端口这三项中有任何一项不一致,浏览器就判定为跨域。比如页面在http://localhost:3000,接口在http://localhost:8080,端口不同,跨域;页面用https,接口用http,协议不同,跨域;页面在a.example.com,接口在b.example.com,域名不同,也跨域。这个判定规则很严格,没有"差不多"的余地。
为什么浏览器要这么设计?核心目的是保护用户。设想一下,你登录了某个银行网站,cookie 还在有效期内,然后你手滑点开了一个恶意页面,如果浏览器不限制跨域请求,那个恶意页面的脚本就能拿着你的 cookie 去请求银行接口,把你的账户信息读走。同源策略就是在这个场景下保护你的——它让不同源的脚本无法随意读取其他源的响应内容。而 CORS 则是在这个安全基础上,提供了一套"白名单"式的协商机制:服务端明确告诉浏览器"我信任这个源,你可以把响应交给它"。
所以 CORS 的本质是一次浏览器、服务端、客户端三方参与的协商。浏览器负责发起预检(对非简单请求)、校验响应头;服务端负责返回正确的Access-Control-Allow-*系列头;客户端(你的前端代码)在需要携带凭证时设置credentials。三者缺一不可,任何一环配置不对,浏览器都会拦截。
这篇文章我打算把 CORS 从原理到落地完整拆一遍:先讲清楚浏览器到底在什么条件下拦截、什么条件下放行,再讲服务端各种技术栈下怎么配,然后重点讲那些"配了但还是报错"的疑难场景——比如携带 cookie、预检缓存、网关层拦截、多级代理这些真实项目里最容易踩的坑。适合已经遇到跨域问题但还没彻底搞明白的前端、后端、全栈同学,也适合做运维和网关配置的同学参考。
2. 同源策略与CORS的协商机制拆解
2.1 浏览器到底在拦截什么:简单请求与预检请求的分水岭
很多人以为"跨域请求发不出去",这个理解是错的。浏览器对跨域请求的处理分两种情况,理解这个分水岭是理解 CORS 的第一步。
简单请求是浏览器认为"风险较低"的请求,满足以下全部条件:方法是GET、HEAD或POST之一;请求头只包含 CORS 安全列表内的头(如Accept、Accept-Language、Content-Language、Content-Type且值限于text/plain、multipart/form-data、application/x-www-form-urlencoded);没有使用ReadableStream等特殊对象。简单请求浏览器会直接发出,但在响应回来时检查服务端是否返回了Access-Control-Allow-Origin,如果没返回或者不匹配,浏览器就把响应内容"扣下"不交给你的 JS 代码——注意,请求其实已经到达服务端并可能产生了副作用,只是响应被浏览器拦截了。
预检请求(preflight)则是针对"非简单请求"的。只要你的请求用了PUT、DELETE、PATCH,或者Content-Type是application/json(这是最常见的触发条件,因为 JSON 不在安全列表里),或者带了自定义请求头(比如Authorization、X-Token),浏览器就会先自动发一个OPTIONS请求到目标地址,问服务端:"我接下来想用 PUT 方法、带 Content-Type: application/json 和 Authorization 头来请求,你允许吗?"服务端必须在 OPTIONS 响应里明确回复允许的方法和头,浏览器才会发真正的请求。
这个机制的设计意图很明确:对于那些可能修改数据、或者携带敏感头的"重"请求,先探一次路,避免真正的请求已经打到服务端造成副作用后才发现不被允许。我见过不少同学在调试时发现"接口被调了两次",一次 OPTIONS 一次真实请求,就是这个原因。
提示:预检请求是浏览器自动发起的,你的前端代码里看不到也拦截不了。如果发现 OPTIONS 请求返回 404 或 405,说明服务端没有正确处理 OPTIONS 方法,这是最常见的预检失败原因。
2.2 关键响应头逐个拆解:每个头管什么、配错会怎样
CORS 的响应头不多,但每一个都有明确的语义,配错一个就可能导致整个链路失败。我把最核心的几个整理成表格,方便对照。
| 响应头 | 作用 | 常见配错后果 |
|---|---|---|
Access-Control-Allow-Origin | 声明允许的源 | 配成*又带凭证会直接失败;配具体源但漏了协议或端口会不匹配 |
Access-Control-Allow-Methods | 预检时声明允许的方法 | 漏配PUT/DELETE导致预检失败 |
Access-Control-Allow-Headers | 预检时声明允许的请求头 | 漏配自定义头(如Authorization)导致预检失败 |
Access-Control-Allow-Credentials | 是否允许携带凭证 | 设为true时Allow-Origin不能是* |
Access-Control-Max-Age | 预检结果缓存时长(秒) | 不配则每次请求都预检,性能差;配太大改了配置不生效 |
Access-Control-Expose-Headers | 允许前端读取的响应头 | 不配则前端读不到自定义响应头(如X-Total-Count) |
这里重点说两个最容易出问题的。第一个是Access-Control-Allow-Origin的值。它要么是*(允许所有源),要么是一个具体的源(如http://localhost:3000),不能是多个源用逗号拼接——这是很多人想当然的写法,浏览器不认。如果你的项目需要允许多个源,必须在服务端做逻辑判断:读取请求的Origin头,判断它在不在白名单里,在的话就把这个Origin原样写回Access-Control-Allow-Origin。这个"动态回写"是标准做法。
第二个是Access-Control-Allow-Credentials与Allow-Origin: *的冲突。当你的请求需要携带 cookie(前端fetch设了credentials: 'include',或axios设了withCredentials: true),服务端必须返回Access-Control-Allow-Credentials: true,而此时Allow-Origin绝对不能是*,必须是具体的源。这是规范硬性规定,因为带凭证的请求如果允许任意源,等于把用户凭证暴露给所有网站,安全上不可接受。我踩过这个坑:本地开发时图省事配了*,结果一上线带 cookie 的请求全挂,排查了半天才发现是这个组合非法。
2.3 为什么说CORS的控制权在服务端:一次完整的协商时序
把上面的机制串起来,一次带预检的跨域请求完整时序是这样的:浏览器先发OPTIONS,带上Origin、Access-Control-Request-Method、Access-Control-Request-Headers三个请求头,告诉服务端"我想干什么";服务端校验后返回Access-Control-Allow-Origin、Allow-Methods、Allow-Headers、Max-Age;浏览器比对通过后,才发真正的业务请求;业务响应回来时,浏览器再校验一次Allow-Origin(以及带凭证时的Allow-Credentials),通过才把响应交给 JS。
这个时序说明了一个关键点:服务端是决策者,浏览器是执行者,前端代码只是发起者。所以当你遇到跨域报错,第一件事不是改前端,而是打开浏览器 Network 面板,看 OPTIONS 请求的响应头到底返回了什么、缺了什么。这个排查习惯能帮你省掉大量瞎猜的时间。
3. 主流技术栈下的CORS落地配置
3.1 Node.js生态:Express与Koa的配置差异
Node 生态里最常用的就是cors这个中间件,Express 和 Koa 都有对应封装,但用法略有差异。Express 下直接app.use(cors(options))即可全局生效,也可以针对特定路由单独挂载。核心配置项就那几个:origin、methods、allowedHeaders、credentials、maxAge、exposedHeaders。
const cors = require('cors'); const corsOptions = { origin: function (origin, callback) { const whitelist = ['http://localhost:3000', 'https://app.example.com']; // 允许无 origin 的请求(如服务端间调用、curl) if (!origin || whitelist.includes(origin)) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'], credentials: true, maxAge: 86400, exposedHeaders: ['X-Total-Count'] }; app.use(cors(corsOptions));这里origin用函数形式是推荐做法,因为它能实现动态白名单。注意函数里对!origin的处理——有些请求(比如同源请求、服务端发起的请求、某些工具的直接调用)不带Origin头,这时候应该放行而不是拒绝,否则会误伤。这个细节很多教程没讲,但实际项目里很关键。
Koa 下用@koa/cors,配置项基本一致,但要注意 Koa 的中间件顺序——cors 中间件必须放在路由中间件之前,否则 OPTIONS 请求可能被路由先处理掉返回 404。这个顺序问题在 Koa 里比 Express 更容易踩,因为 Koa 的洋葱模型对顺序更敏感。
注意:
maxAge设太大(比如一周)会导致你改了 CORS 配置后,客户端因为预检结果还在缓存期内而继续用旧配置,表现为"改了没生效"。调试阶段建议设小一点(比如 60 秒),上线稳定后再调大。
3.2 Java生态:Spring Boot的三种配置姿势
Spring Boot 项目里配 CORS 至少有三种方式,各有适用场景。第一种是全局配置,实现WebMvcConfigurer重写addCorsMappings,适合整个应用统一策略:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:3000", "https://app.example.com") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }第二种是@CrossOrigin注解,可以打在 Controller 类或方法上,适合细粒度控制。第三种是用CorsFilter过滤器,适合需要更底层控制的场景。三种方式如果同时存在,优先级是:注解 > 全局配置,过滤器则取决于注册顺序。
Spring 里有个经典坑:如果你用了 Spring Security,CORS 配置可能被 Security 的过滤器链拦截,导致 OPTIONS 请求返回 401。这时候需要在 Security 配置里显式开启 CORS:http.cors(),并确保CorsConfigurationSource被正确注入。我见过太多项目卡在这里,明明WebMvcConfigurer配了,但请求就是过不去,最后发现是 Security 把预检请求当成了未认证请求给拦了。
3.3 Nginx反向代理层:把跨域消灭在网关
生产环境更常见的做法是在 Nginx 层统一处理 CORS,这样后端服务不用关心跨域,职责更清晰。核心就是给响应加上那几个头,同时对 OPTIONS 请求直接返回 204:
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' 86400 always; add_header 'Content-Length' 0; return 204; } add_header 'Access-Control-Allow-Origin' '$http_origin' always; add_header 'Access-Control-Allow-Credentials' 'true' always; add_header 'Access-Control-Expose-Headers' 'X-Total-Count' always; proxy_pass http://backend; }这里用$http_origin动态回写 Origin,配合always参数确保即使响应是错误码(4xx/5xx)也带上 CORS 头——这个always很关键,不加的话后端返回 500 时浏览器会因为缺 CORS 头而报跨域错误,把真正的 500 错误掩盖掉,排查时非常误导人。
另一个 Nginx 的坑是add_header的继承规则:如果 location 块里用了add_header,它会覆盖掉上层 http/server 块里的add_header,而不是叠加。所以如果你在 server 层配了一些安全头,在 location 层又配 CORS 头,安全头可能就丢了。这个行为需要特别注意。
3.4 开发环境代理:绕开跨域的另一种思路
开发阶段其实还有一条更省事的路——用构建工具的代理功能,让请求看起来是同源的。Vite 里配server.proxy,Webpack DevServer 里配devServer.proxy,原理都是把/api开头的请求转发到后端地址,浏览器看到的是同源请求,自然没有跨域问题。
// vite.config.js export default { server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } }changeOrigin: true的作用是把请求头里的Host改成目标地址的 host,有些后端会校验 Host,不开这个可能被拒。代理方案的好处是开发时完全不用碰后端配置,坏处是它只解决开发环境,生产环境还是得靠服务端 CORS 或网关。所以我的建议是:开发用代理提效,但生产配置一定要提前在测试环境验证,别等到上线才发现 CORS 没配好。
4. 高频疑难场景与排查实录
4.1 携带Cookie的跨域:三个必须同时满足的条件
带凭证的跨域是最容易翻车的场景,因为它要求三个条件同时满足,缺一个就失败,而且报错信息往往很模糊。这三个条件是:前端请求设置credentials: 'include'(fetch)或withCredentials: true(axios);服务端返回Access-Control-Allow-Credentials: true;服务端Access-Control-Allow-Origin是具体源而非*。
我遇到过一个案例:前端 axios 配了withCredentials: true,后端也配了allowCredentials(true),但 cookie 就是带不上。排查发现后端用的是allowedOrigins("*")加allowCredentials(true)的组合,Spring 在这种情况下会抛异常或者静默失效。改成具体源列表后解决。还有一种情况是 cookie 的SameSite属性——现代浏览器默认SameSite=Lax,跨站请求不会带 cookie,需要服务端把 cookie 设成SameSite=None; Secure(必须配合 HTTPS)。这个点经常被忽略,因为它是 cookie 层面的限制,不是 CORS 层面的,但表现出的现象就是"cookie 带不上"。
4.2 预检缓存导致的"改了不生效"
这个坑我在多个项目里都遇到过:明明后端 CORS 配置改好了,前端刷新还是报跨域。原因就是之前失败的预检结果或者旧的Max-Age缓存还在。浏览器的预检缓存是按"源+目标+方法+头"的组合缓存的,缓存期内不会重新预检。解决办法很简单:调试时把Max-Age设成 0 或很小的值,或者用无痕窗口测试(无痕窗口不共享预检缓存)。生产环境则要权衡,Max-Age设大能减少 OPTIONS 请求数量提升性能,但配置变更的生效会有延迟。
4.3 多级代理下的Origin丢失与头覆盖
微服务架构下,请求往往要经过 CDN、网关、Nginx、服务多层,每一层都可能对 CORS 头做手脚。常见问题有两个:一是某一层把Origin头丢了,导致最内层服务拿不到源信息无法动态回写;二是多层都配了 CORS 头,后一层的add_header覆盖了前一层的,导致最终响应头不完整或者出现重复头(浏览器对重复的Allow-Origin会直接拒绝)。
排查这类问题的思路是逐层验证:用curl -I -X OPTIONS -H "Origin: http://localhost:3000" -H "Access-Control-Request-Method: PUT" <url>直接打最外层,看返回头;再打内层,对比差异,定位是哪一层出的问题。这个命令我几乎每次排查跨域都会用,比在浏览器里看直观得多,因为能看到原始的响应头。
4.4 常见问题速查表
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
| OPTIONS 返回 404/405 | 服务端未处理 OPTIONS 方法 | 检查路由/网关是否放行 OPTIONS |
报错Allow-Origin不匹配 | 源判断逻辑有误或漏了端口/协议 | 打印请求 Origin 与配置对比 |
| 带 cookie 失败 | 三条件未同时满足或 SameSite 限制 | 逐项核对 credentials、Origin、cookie 属性 |
| 改了配置不生效 | 预检缓存未过期 | 清缓存或用无痕窗口重测 |
| 500 错误被报成跨域 | 错误响应缺 CORS 头 | Nginx 加always,后端异常处理器补头 |
| 前端读不到自定义响应头 | 未配Expose-Headers | 在服务端补Access-Control-Expose-Headers |
5. 我在实际项目中的几条经验
配置 CORS 这件事,技术本身不复杂,难的是排查。我个人的习惯是:任何跨域问题,先看 Network 里的 OPTIONS 请求和它的响应头,90% 的问题在这一步就能定位。如果 OPTIONS 都没发出来或者返回异常,那问题在网关或路由层;如果 OPTIONS 正常但真实请求失败,那多半是Allow-Origin或凭证配置的问题。
另外强烈建议把 CORS 配置做成可观测的——在服务端记录每次跨域请求的Origin和被拒绝的原因,这样线上出问题时不用靠猜。很多团队把 CORS 当成一次性配置,配完就不管了,结果新增了域名或者换了部署环境就出问题。把它当成一个需要维护的配置项,定期 review 白名单,能省掉很多半夜被叫起来排查的麻烦。
最后一个实用技巧:本地开发时如果后端不方便改,除了用构建工具代理,还可以临时用浏览器插件或者启动一个带 CORS 头的本地代理服务来过渡,但一定要清楚这只是开发期的权宜之计,生产环境必须走正规的服务端配置。我见过有团队把开发期的临时方案带上了生产,结果安全审计时被揪出来,返工成本比一开始就配好高得多。