先把责任划清楚
这个错误很多人都遇到过:用 Postman 或者 curl 调接口,一切正常,返回数据都对。把同样的地址写进页面里的 fetch,控制台立刻红了一片,提示被 CORS 策略阻止。
于是开始怀疑接口挂了、参数错了、网络不通。其实都不是。请求确实发出去了,服务器也确实把数据返回了,只是浏览器在把结果交给你的代码之前,看了一眼响应头,发现没允许跨域,就把它拦下了。
这就是关键:拦截动作发生在浏览器里。Postman、curl、Node 脚本、服务端到服务端的调用,全都没有这层检查,所以它们永远测不出跨域问题。想验证 CORS 只能开浏览器。
什么算跨域
浏览器判断两个地址是否同源,看三样东西必须完全一致:协议、域名、端口。
| 页面地址 | 请求地址 | 结果 |
|---|---|---|
| https://a.com | https://a.com/api | 同源,不受限 |
| https://a.com | http://a.com/api | 协议不同,跨域 |
| https://a.com | https://www.a.com/api | 域名不同,跨域 |
| https://a.com | https://a.com:8080/api | 端口不同,跨域 |
| https://a.com | https://b.com/api | 完全跨域 |
第三行值得多说一句。www 和不带 www 在浏览器眼里就是两个不同的域名,这也是为什么站点通常会把 www 统一跳转到主域,不然同一个页面会有两个地址,缓存和收录都会乱。
还有几个细节容易踩:端口不同就是跨域,80 和 8080 不算同源,即使都写 localhost。子域名之间也算跨域,a.example.com 和 b.example.com 互不相干。
浏览器怎么判断放不放行
不是所有请求都会先问一遍服务器。浏览器把请求分成两类,处理方式完全不同。
简单请求直接发出去,服务器正常响应,浏览器再看响应头里有没有允许跨域。如果没允许,浏览器就把响应内容丢掉,你的代码只能拿到一个错误。注意请求本身已经到达服务器并且执行了,如果是个写操作,数据可能已经改了。所以别指望 CORS 拦住什么危险操作,它只保护读取。
什么算简单请求?方法必须是 GET、HEAD、POST 之一,请求头只能有那几个安全的首部,Content-Type 只能是表单格式或纯文本。只要有一点超出,就要走预检。
预检请求是这样:浏览器先发一个 OPTIONS 请求到同一个地址,问服务器我能不能用 POST、能不能带某个自定义头。服务器回一组允许清单,浏览器检查通过才发真实请求。
# 浏览器发出的预检请求(看不到正文)
OPTIONS /api/user HTTP/1.1
Origin: https://a.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
# 服务端应的放行清单
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://a.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 86400
最后那个 Max-Age 表示这个预检结果能缓存多久,单位秒。设大一点能减少 OPTIONS 请求次数,但改配置后要等缓存过期才生效,调的时候容易以为是没改对。
服务端要配哪几个头
核心就一个,其余是补充。
Access-Control-Allow-Origin 必填,值要么是具体的来源域名,要么是星号表示允许任何来源。浏览器拿请求的 Origin 和这个值比对,一致才放行。
Access-Control-Allow-Methods 只在预检时需要,列出允许的方法。很多人漏写 OPTIONS 自身,导致预检失败。
Access-Control-Allow-Headers 也是预检时用,列出允许的自定义请求头。前端传了 Authorization 或者自定义的 X-Token,服务端不在这儿列出来,预检就不通过。
Access-Control-Allow-Credentials 表示允许携带 Cookie 等凭证。设成 true 的时候,Allow-Origin 绝对不能是星号,必须是明确的域名。这条规则卡住了不少人,明明配了星号还是不行,原因就在这里。
Access-Control-Expose-Headers 用来暴露额外的响应头。默认情况下前端脚本只能读到几个基础响应头,服务端返回的自定义头比如 X-Request-Id 读不到,需要在这儿列出来。
几个高频的坑
- 预检请求返回 404 或 405。说明网关或路由不允许 OPTIONS,真实请求根本没发出去。控制台报的是跨域,根因是方法没放行,去查 Nginx 或框架的路由配置。
- 带了 Cookie 还用星号。上面说过,这种组合浏览器直接拒绝。改成具体域名,并且 Allow-Credentials 设 true。
- 多个地方重复加头。网关加一次、应用加一次,响应里出现两个 Allow-Origin,浏览器会认为冲突然后拒绝。顺着链路从外层往内查一遍。
- 改完不生效。可能是中间层覆盖了响应头,也可能是浏览器缓存了预检结果。先看 Max-Age,再在开发者工具里禁用缓存复测。
- 以为前端能修。加个 mode 或者 headers 选项都不解决问题,跨域只能服务端放行。前端唯一能做的是走本地代理,让请求看起来是同源的。
本地开发怎么绕
开发阶段最省事的办法是本地代理,不要在生产环境的接口上配宽松的跨域规则。
Vite、webpack dev server、Vue CLI 都有 proxy 配置,把 /api 开头的请求转发到后端地址。在浏览器看来请求是同源的,跨域问题根本不会出现。
// vite.config.js
export default {
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true
}
}
}
}
如果后端不在本地,也可以起一个临时反向代理把请求转过去。重点是让浏览器的视角里请求同源,而不是去服务端加星号放行。
生产环境反过来:该配的白名单老老实实配,别图省事写星号。尤其是带登录态的接口,星号在凭证模式下本来就是无效的。
怎么快速确认问题出在哪
打开开发者工具,切到 Network,找到那个失败的请求,看两个地方。
先看请求方法是不是 OPTIONS。如果是,说明卡在预检,问题在服务端对 OPTIONS 的响应,而不是真实接口。点开这个 OPTIONS 请求,看状态码和响应头里有没有 Access-Control-Allow-Methods。
再看响应头里 Access-Control-Allow-Origin 的值,和请求自带 Origin 是否一致。不一致就是白名单没配对,比如前端跑在 localhost:5173,服务端只允许了 localhost:3000。
这两步能定位大部分情况。剩下的是中间层干扰,得从网关开始一层层往下看响应头到底被谁改了。
常见问题 FAQ
为什么 Postman 能调通,浏览器里就报跨域?
因为 CORS 是浏览器实施的限制,不是服务端的限制。Postman、curl、服务端到服务端的调用都不受同源策略约束,它们没有浏览器那层安全模型。所以接口能通不代表配置没问题,只是那个工具不去检查。真正要验证 CORS,必须在浏览器页面里发请求。
Access-Control-Allow-Origin 设成星号是不是最省事?
公开接口可以,涉及登录态的接口不行。一旦请求带了 Cookie(前端设置 credentials 为 include),浏览器要求 Allow-Origin 必须是具体的域名,用星号会被直接拒绝。这种情况要改成明确的白名单域名,同时还得配上 Allow-Credentials 为 true。
预检请求 OPTIONS 返回 404 是怎么回事?
说明服务端或网关没有处理 OPTIONS 方法。浏览器在发真实请求之前会先发一个 OPTIONS 探测,如果这一步就失败,真实请求根本不会发出,控制台看到的是跨域错误,但根因是 OPTIONS 没被正确处理。查一下路由和反向代理有没有放行 OPTIONS,或者有没有把这个方法拦掉。
CORS 配置改了但不生效?
几个常见原因:一是改动没重启服务或没 reload 网关;二是响应头被中间层覆盖或去掉了,比如网关、CDN、反向代理会重写头;三是浏览器缓存了之前的预检结果,可以在开发者工具里勾 Disable cache 或看 Access-Control-Max-Age 设置多长;四是多个地方都配了 CORS 头,重复的 Allow-Origin 会让浏览器判定失败。