引言
这篇文章不打算罗列一堆 "跨域解决办法" 的八股,而是从同源策略的设计动机讲起,把 CORS 每一个响应头在协议层到底干嘛、浏览器在哪个环节拦了你的响应等一次性说清楚。读完你会发现,跨域不是一个 "配一下就好" 的配置项,而是理解浏览器安全模型的一把钥匙。
一、先纠正一个最常见的误解:跨域不是 "请求发不出去"
很多同学第一次碰到跨域报错时,看到控制台那行红色的:
Access to fetch at 'https://api.xxx.com/user' from origin 'https://www.xxx.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.
第一反应是:"完了,请求没发出去,被浏览器挡在门口了。"
这是错的。
真相是:请求已经正常发到服务器了,服务器也正常返回了数据,是浏览器拿到响应之后,发现响应头里没有 Access-Control-Allow-Origin 或者这个源不在白名单里,于是把响应 "扣下" 了,不交给 JS。
你可以打开 Chrome DevTools 的 Network 面板,点开那个红色的请求,会看到:
- Status Code 是 200 / 201 / 500,甚至响应体里都有数据;
- 但 Console 里报 CORS 错误。
为什么浏览器要这么设计?这就是同源策略的核心目的 ------它防的不是 "服务器被请求",而是 "恶意网站拿到你的数据" 。服务器本来就该对任何来者不拒地响应(否则 CDN、公共 API 就没法用了),浏览器的责任是:当一个恶意页面(比如 evil.com)用你登录态去请求 bank.com 时,服务器哪怕返回了你的余额,浏览器也不能让 evil.com 的 JS 读到这个响应。
理解这一点,后面所有机制都顺了。
二、同源策略:跨域问题的根源
2.1 什么是 "源"(Origin)
同源 = 协议 + 域名 + 端口三者完全一致,任何一个不同就算跨域。
以 https://www.example.com:443/a/page.html 为例:
|------------------------------------------|------|--------------------|
| URL | 是否同源 | 原因 |
| https://www.example.com/b/page.html | 同源 | 只有路径不同 |
| http://www.example.com/a/page.html | 跨域 | 协议不同(https → http) |
| https://mail.example.com/a/page.html | 跨域 | 子域名不同 |
| https://www.example.com:8080/a/page.html | 跨域 | 端口不同 |
| https://example.com/a/page.html | 跨域 | 主机名不同(www 算不同源) |
注意几个细节:
- 端口是显式对比的: http://example.com 默认 80,http://example.com:80 是同源,但 http://example.com:8080 就是跨域。
- IE 是个例外:IE 在同源判断上不把端口算进去(历史 bug),所以老系统里经常出现 "IE 不跨域、Chrome 跨域" 的诡异现象。
- about:blank、data: URL 这种 "空源" 在调试时也容易踩坑。
2.2 同源策略到底限制了什么
很多人以为同源策略就是 "不能发 AJAX 请求",其实它限制的范围要广得多:
- DOM 访问:一个窗口 /iframe 里的 JS 不能读写另一个源的 document、cookie、localStorage。
- 网络响应读取:XMLHttpRequest / fetch 拿到跨域响应后,JS 读不到 response body。
- Cookie / LocalStorage / IndexedDB :按源隔离,a.com 的 JS 读不到 b.com 的存储。
- 某些 HTTP 头:比如 Authorization、Cookie 在跨域场景下的自动携带规则。
但是 ------以下这些东西是不受同源策略限制的(或者说浏览器主动放行):
- <script src="..."> 加载脚本(这就是 JSONP 的原理)
- <link rel="stylesheet"> 加载样式
- <img>、<video>、<audio> 加载媒体
- <iframe> 加载页面(能加载,但不能读里面的 DOM)
- <form action="..."> 提交表单(能提交,但拿不到响应)
- 字体 @font-face(这个历史上曾经跨域,需要 CORS 头)
- Web Worker、Service Worker 的脚本加载
这张表非常重要:跨域方案的本质,要么是利用 "哪些标签本来就能跨域加载"(script/img/form),要么是 "服务器主动说我允许这个源读响应"(CORS),要么是 "让浏览器看起来是同源"(Nginx 代理)。

2.3 为什么浏览器要设计同源策略
假想一下没有同源策略的世界:
- 你登录了 mail.example.com,然后新开一个标签页访问 evil.com;
- evil.com 的 JS 用 fetch('https://mail.example.com/inbox'),浏览器自动带上你的 Cookie;
- 服务器返回了你的邮件列表;
- evil.com 把邮件内容发到自己的服务器。
这就是经典的 CSRF(跨站请求伪造)+ 数据窃取 攻击链。同源策略就是为了斩断最后一步 ------让 evil.com的 JS 读不到 mail.example.com的响应。
所以同源策略的本质是:浏览器作为用户的代理人,替你挡住 "恶意站点读你的数据",而不是 "挡住服务器响应请求"。理解这个动机,才能理解为什么 CORS 是一个 "服务器白名单 + 浏览器放行" 的机制,而不是别的形态。
三、CORS:官方的跨域解法
CORS(Cross-Origin Resource Sharing,跨源资源共享)是 W3C 在 2014 年正式定稿的标准(RFC 6454 / Fetch Living Standard),它的核心思路是:让服务器通过 HTTP 响应头显式告诉浏览器 "我允许哪些源来读我的响应",浏览器看到白名单后就放行。
3.1 CORS 的核心思想
整个 CORS 流程可以浓缩成一句话:
浏览器在发跨域请求时自动带上 Origin 请求头,服务器在响应头里回 Access-Control-Allow-Origin,浏览器对比两者,决定放不放行。
它解决的是 "读响应" 的问题,不是 "发请求" 的问题。
3.2 请求头与响应头全景
浏览器自动加的请求头(你不用手动 set):
|-------------------------------------------------------|----------------------|
| 请求头 | 含义 |
| Origin: https://www.example.com | 请求来自哪个源(仅跨域时加) |
| Access-Control-Request-Method: POST | 预检时告诉服务器实际请求要用什么方法 |
| Access-Control-Request-Headers: Content-Type, X-Token | 预检时告诉服务器实际请求会带哪些自定义头 |
服务器需要回的响应头:
|----------------------------------|-------------------------|--------------------------------------|
| 响应头 | 含义 | 示例 |
| Access-Control-Allow-Origin | 允许的源 | https://www.example.com 或 * |
| Access-Control-Allow-Methods | 允许的方法 | GET, POST, PUT, DELETE |
| Access-Control-Allow-Headers | 允许的自定义请求头 | Content-Type, X-Token, Authorization |
| Access-Control-Allow-Credentials | 是否允许携带凭证 | true |
| Access-Control-Max-Age | 预检结果缓存秒数 | 86400 |
| Access-Control-Expose-Headers | 允许 JS 读取的响应头 | Content-Range, X-Total-Count |
| Vary: Origin | 告诉缓存层 "响应内容随 Origin 变化" | 必加 |
3.3 简单请求(Simple Request)
CORS 把跨域请求分成两类,简单请求不发预检,直接发实际请求。
一个请求同时满足以下所有 条件,才算简单请求 "[https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS "https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS")":
- 方法只能是 GET、HEAD、POST 三者之一;
- 手动设置的请求头只能是以下集合:
- Accept、Accept-Language、Content-Language
- Content-Type(且有严格限制,见下)
- Range(有特定语法限制)
- 以及 UA 自动加的那些(Connection、User-Agent、Host 等)
- Content-Type 只能是以下三者之一:
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
- 请求中没有使用 ReadableStream,没有注册任何事件监听器。
3.4 预检请求(Preflighted Request)
不满足简单请求条件的,都叫 "非简单请求",浏览器会先自动发一个 OPTIONS 方法的预检请求,问服务器:" 我接下来要发一个 POST + Content-Type: application/json + X-Token: xxx 的请求,你允许吗?"
预检请求长这样:
OPTIONS /api/user HTTP/1.1
Host: api.example.com
Origin: https://www.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, X-Token
服务器如果同意,回:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://www.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, X-Token, Authorization
Access-Control-Max-Age: 86400
浏览器收到后,才真正发业务请求:
POST /api/user HTTP/1.1
Host: api.example.com
Origin: https://www.example.com
Content-Type: application/json
X-Token: xxx
{"name": "小明"}
实际请求的响应也必须带 Access-Control-Allow-Origin,否则一样被拦。
为什么要有预检? 因为在 CORS 出现之前,<form> 就能跨域 POST(表单提交不受同源策略限制),服务器早就习惯了 "任何站都能来 POST"。如果浏览器允许 JS 自由发 application/json 的 POST,恶意网站就能借用户登录态悄悄改数据。
预检的本质是:在发 "可能修改服务器数据" 的请求之前,先让服务器明确表态 "我知道这个源要来,我同意"。

几个实操要点:
- 预检请求本身不带 Cookie(规范明确规定),所以你后端不要在 OPTIONS 接口上做鉴权,否则一定 401。
- 预检响应建议直接返回 204 No Content,不要返回业务数据。
- 服务器必须正确响应 OPTIONS 方法,不能让框架把它当 404 处理。
3.5 携带凭证的请求(Credentials)
默认情况下,跨域 fetch / XHR 不会带 Cookie,也不会带客户端证书、Authorization 头。如果业务需要带 Cookie(比如登录态),必须前端显式声明:
// fetch
fetch('https://api.example.com/user', {
credentials: 'include' // 关键
})
// axios
axios.get('https://api.example.com/user', {
withCredentials: true // 关键
})
// XHR
xhr.withCredentials = true
同时,服务器必须回:
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://www.example.com // 注意:不能是 *
这里有一个 HTTP 规范层面的硬约束 :当请求带凭证时,Access-Control-Allow-Origin 绝对不能是 *,必须是具体的源(通常做法是服务器读请求里的 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'.
为什么要这么规定? 因为如果允许 * + Cookie,那任何恶意网站都能借你的登录态读你的数据,同源策略就形同虚设了。
3.6 暴露响应头:Access-Control-Expose-Headers
默认情况下,JS 只能读到这几个 "安全列表" 里的响应头:
- Cache-Control
- Content-Language
- Content-Length
- Content-Type
- Expires
- Last-Modified
- Pragma
如果你前端想读 X-Total-Count(分页)、Content-Range(断点续传)、X-Request-Id(链路追踪)等自定义头,必须在响应里加:
Access-Control-Expose-Headers: X-Total-Count, Content-Range, X-Request-Id
这是一个极其高频的坑:很多同学说 "我跨域通了,但是 response.headers 里拿不到我的自定义头",99% 是漏了这个。
3.7 预检缓存:Access-Control-Max-Age
每次非简单请求都发一次 OPTIONS,开销不小(多一个 RTT)。服务器可以在预检响应里加:
Access-Control-Max-Age: 86400
表示这个预检结果浏览器缓存 86400 秒(1 天),期间对同样的方法 + 头的跨域请求不再发 OPTIONS。
注意:
- Chrome 硬性把这个值上限钳制在 600 秒 (Firefox 是 86400),你写 86400 在 Chrome 里也只会按 600 算 "[Cross-Origin Resource Sharing (CORS) - HTTP \| MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS "Cross-Origin Resource Sharing (CORS) - HTTP | MDN")"。
- 缓存是按 "源 + 方法 + 实际请求头" 作为 key 的,改一个自定义头就会重新预检。
四、六种跨域方案横向对比
4.1 JSONP
原理:利用 <script> 标签不受同源策略限制的特性。
// 前端
function handleData(data) { console.log(data) }
const script = document.createElement('script')
script.src = 'https://api.example.com/user?callback=handleData'
document.body.appendChild(script)
// 后端返回(注意是 JS,不是 JSON)
handleData({ name: '小明', age: 22 })
优点:
- 兼容性极好,连 IE6 都能用;
- 实现简单。
缺点:
- 只支持 GET(因为 script 标签只能 GET);
- 有安全风险:如果服务器返回的内容被劫持,等于在你的域里执行任意 JS(XSS 级);
- 错误处理困难:script 加载失败只能监听 onerror,拿不到 HTTP 状态码;
- 已经被 CORS 全面取代,新项目不要再用。
4.2 CORS(后端方案)
就是上面讲的那一套,现代浏览器标准做法。
- 优点:标准、功能全、支持所有方法、有预检机制保护数据;
- 缺点:需要后端配合,老 IE(IE9 以下)支持不全。
4.3 Nginx 反向代理
原理:浏览器只跟 Nginx 通信,Nginx 再把请求转发给后端服务。因为浏览器看到的是 " 我请求的是同源的 /api",所以根本不存在跨域。
server {
listen 80;
server_name www.example.com;
location / {
root /usr/share/nginx/html;
}
# 关键:/api 开头的请求转发给后端
location /api/ {
proxy_pass http://backend.internal:8080;
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;
}
}
前端代码里请求 /api/user 就行了,浏览器认为这是同源的。
优点:
- 前端零改动;
- 隐藏后端真实地址,安全;
- 顺带做负载均衡、限流、HTTPS 终止。
缺点:
- 部署上需要有一层 Nginx(或网关);
- WebSocket、SSE 这种长连接需要额外配置 Upgrade 头。
注意:很多同学把 "Nginx 配 CORS 头" 和 "Nginx 反向代理" 混为一谈。前者是 Nginx 帮后端加 Access-Control-Allow-Origin(浏览器仍然跨域),后者是 Nginx 把请求转发到同源路径(浏览器根本不跨域)。两者原理完全不同。
4.4 postMessage(跨文档通信)
当你需要在两个窗口 /iframe 之间传数据时(比如父页面 a.com 嵌入 b.com 的 iframe),用 postMessage:
// 父窗口 a.com 向 iframe b.com 发消息
iframe.contentWindow.postMessage({ type: 'GREET' }, 'https://b.com')
// b.com 接收
window.addEventListener('message', (e) => {
if (e.origin !== 'https://a.com') return // 必须校验 origin!
console.log(e.data)
e.source.postMessage('hello', e.origin)
})
优点:W3C 标准、安全(必须校验 origin)、支持任何窗口间通信;
缺点:只解决 "文档间通信",不能解决 "fetch 调 API"。
安全提醒 :接收方必须校验 e.origin,否则等于在你的域里接收任意网站的消息,又是一个 XSS 入口。
4.5 document.domain(同主域子域)
a.example.com 和 b.example.com 之间,如果都把 document.domain 设成 example.com,就可以互相访问 DOM。
// 两个页面都执行
document.domain = 'example.com'
适用场景极窄 :只适用于同主域不同子域 、且通过 iframe 嵌入的老系统。现代浏览器已经在逐步废弃这种方式(ES 规范里 document.domain 的可写性在收紧)。新项目别用。
4.6 WebSocket
WebSocket 的握手是 HTTP,但一旦升级成功,就是一个独立的 TCP 连接,不受同源策略限制。
const ws = new WebSocket('wss://api.example.com/chat')
服务器需要在握手时校验 Origin 头(因为浏览器握手会带 Origin),拒绝不信任的源。
优点:全双工、长连接;
缺点:不是用来替代 REST API 的,只适合推送 / 实时场景。
4.7 选型对照表
|-----------------|----------------|----------|---------------|-------|
| 方案 | 适用场景 | 是否需要后端配合 | 安全性 | 现代推荐度 |
| JSONP | 老系统兼容 | 是 | 低 | ★ |
| CORS | 前后端分离 API 调用 | 是 | 高 | ★★★★★ |
| Nginx 代理 | 部署层面统一解决 | 否(前端无感) | 高 | ★★★★★ |
| postMessage | iframe / 新窗口通信 | 是 | 高(需校验 origin) | ★★★★ |
| document.domain | 同主域子域 iframe | 否 | 中(正在废弃) | ★★ |
| WebSocket | 实时通信 | 是 | 中(需校验 Origin) | ★★★★ |
五、底层思考:为什么 CORS 是 "浏览器拦截响应" 而不是 "拦截请求"
这是理解 CORS 本质的终极问题。
如果浏览器真的拦截了跨域请求,会发生什么?
- <form> 提交跨域表单会失效(但这是 90 年代就有的合法功能);
- <script src="//cdn.jquery.com/jquery.js"> 会失效(CDN 生态直接垮掉);
- <img src="...">、<video> 全部失效;
- 服务器根本不知道自己被谁请求了,CSRF 攻击反而更隐蔽 ------ 因为恶意请求已经发出去了,服务器照样执行了转账、改密码,只是浏览器不告诉 JS 结果而已。
所以 CORS 的设计哲学是:
- 不破坏 Web 既有的资源加载模型(script/img/form 该怎么用还怎么用);
- 把 "是否允许读响应" 的决定权交给服务器(服务器通过 ACAO 头显式授权);
- 浏览器只做最后一道守门人------ 服务器没说允许,JS 就别想读。
这也解释了为什么 CORS 防不住 CSRF:CSRF 是 "请求被发出去了、数据被改了",只是 JS 读不到响应而已。要防 CSRF,还得靠 Token、SameSite Cookie、Referer 校验这些手段。
CORS 和 CSRF 是两个层面的问题,不要混淆。