"跨域被拦了"这句话经常是误判:请求其实已经到达服务器、数据库也写了,只是浏览器拒绝把响应交给 JS。把这条搞反,就会去后端加一堆头,而问题出在架构层——前端和后端本该同源。
这篇按"限制什么 → 预检怎么判 → 头怎么写 → 三种解法怎么选 → 坑"走,配置以 Spring Boot 3.3.x 与 nginx 1.25 为准。
1. 同源策略限制的是"读",不是"发"
同源 = 协议、主机、端口三者完全相同。任一不同即跨源:
| 页面地址 | 请求地址 | 是否同源 | 说明 |
|---|---|---|---|
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:8443/api |
否 | 端口不同 |
http://localhost:5173 |
http://localhost:8080/api |
否 | 开发期最常见的一种 |
关键点:跨源请求通常会正常发出,服务端也会正常处理,浏览器只是在把响应交给 JS 之前做了拦截。所以"跨域导致订单没创建"这种说法几乎总是错的——先看服务端日志,再判断是不是仅前端读不到响应。
唯一的例外是预检失败:预检没通过时,真正的请求根本不会发出。
2. 简单请求与预检
浏览器把跨源请求分成两类,判定规则是固定的:
| 条件 | 简单请求 | 需要预检(OPTIONS) |
|---|---|---|
| 方法 | GET、HEAD、POST |
PUT、DELETE、PATCH 等 |
| 请求头 | 仅 CORS 安全列表(Accept、Accept-Language、Content-Language) |
含 Authorization、X-Requested-With、自定义头 |
Content-Type |
text/plain、multipart/form-data、application/x-www-form-urlencoded |
application/json |
| 其他 | 无 ReadableStream、无上传进度监听 |
任一不满足 |
这就是"为什么 POST 也会触发预检"的答案:方法在安全列表里,但 Content-Type: application/json 不在。JSON 接口几乎必然走预检,PUT/PATCH 同理。
$ curl -i -X OPTIONS http://localhost:8080/api/users \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type,authorization"
典型响应:
HTTP/1.1 200
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: 1800
预检请求不带 cookie、不带认证头,也不带请求体。所以任何"必须登录才能通过"的过滤器都会把它拦掉——这是后面坑表里的第一条。
3. 响应头逐条
| 响应头 | 作用 | 常见错误 |
|---|---|---|
Access-Control-Allow-Origin |
允许的来源,或 * |
带 cookie 时写 *,浏览器直接拒 |
Access-Control-Allow-Methods |
预检时声明允许的方法 | 只写 GET,POST,PUT 被拒 |
Access-Control-Allow-Headers |
预检时声明允许的请求头 | 漏 authorization,登录后所有请求失败 |
Access-Control-Allow-Credentials |
是否允许携带 cookie | 与 * 组合,规范禁止 |
Access-Control-Max-Age |
预检结果缓存秒数 | 写 0 或太小,每个请求都多一次 OPTIONS |
Access-Control-Expose-Headers |
允许 JS 读取的非安全响应头 | 前端读不到 X-Total-Count 分页信息 |
Vary: Origin |
缓存键要区分来源 | 缺它导致 CDN 把 A 站的响应发给 B 站 |
带 cookie 时 Allow-Origin 不能是 *,原因是 * 表示"任何来源都可读",而 Credentials: include 意味着"带着用户身份读"——两者相乘等于任意站点都能以用户身份读取数据。所以规范要求此时必须回显具体来源,并且 Allow-Credentials: true,同时响应必须带 Vary: Origin 以保证缓存正确。
减少预检的三种做法:
- 把
Max-Age调大(浏览器有上限,Chrome 约 2 小时封顶,按版本核对); - 让接口落在同源路径下(反代),从根上不存在跨源;
- 把自定义头收敛,能放 query/body 的不要塞 header。
4. 三种解法对照
| 方案 | 做法 | 成本 | 适用 |
|---|---|---|---|
| 后端 CORS | 后端返回 CORS 头,允许前端域 | 每套环境都要配、要维护白名单 | 前后端域名长期分离、开放 API |
| 开发期代理 | Vite/webpack devServer 转发 /api |
只在开发机生效 | 本地联调,避免改后端 |
| 生产同域反代 | nginx 同域下挂前端与 /api |
一次配置,长期零成本 | 绝大多数自有站点 |
// vite.config.js:开发期代理,浏览器视角始终是同源
export default {
server: {
proxy: {
'/api': { target: 'http://localhost:8080', changeOrigin: true }
}
}
}
代理生效后,前端代码里所有请求都写相对路径 /api/...。这也是三条方案里唯一"改前端代码最少"的一条:只要不写死后端域名,切到生产同域反代时一行都不用改。
# 生产:同域,前端静态文件与 API 同一个 origin
server {
listen 443 ssl;
server_name app.example.com;
root /var/www/app/dist;
index index.html;
location /api/ {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html; # 前端路由回退
}
}
同域之后,CORS 头一条都不用加,预检也不存在。这是"消除跨域"而不是"绕过跨域"——差别在于后者永远要维护白名单。
5. Spring 侧的两处配置与优先级
@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "https://app.example.com", maxAge = 1800)
public class UserController { /* ... */ }
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(1800);
}
}
| 配置位置 | 作用范围 | 与另一处的关系 |
|---|---|---|
@CrossOrigin(类/方法) |
单个 handler | 与全局配置合并,局部值优先 |
addCorsMappings |
按路径匹配 | 作为全局基线 |
CorsFilter(FilterRegistrationBean) |
全链路,含静态资源 | 与上面两者叠加会产生重复头 |
nginx add_header |
反代层 | 与后端头叠加同样重复 |
@CrossOrigin 与全局 addCorsMappings 不会产生"两份头",Spring 在 AbstractHandlerMapping 里把它们合并成一个 CorsConfiguration(局部优先),只写一次响应头。真正会重复的是 CorsFilter + MVC 配置、或 nginx + 后端各写一次。
# 验证是否重复:响应头出现两行就是叠加了
$ curl -sI https://app.example.com/api/users -H "Origin: https://app.example.com" | grep -i access-control
6. 常见坑
| 坑 | 现象 | 原因 | 处理 |
|---|---|---|---|
Allow-Credentials: true + Allow-Origin: * |
控制台报 "credentials mode is 'include'" | 规范禁止组合 | 回显具体来源,并加 Vary: Origin |
| 预检 403 | 真实请求从未发出 | 鉴权 Filter 拦了不带 cookie 的 OPTIONS |
放行 OPTIONS,或在 Filter 里先处理 CORS |
| 只配了 GET/POST | PUT 请求预检失败 |
Allow-Methods 没列全 |
显式列全,或允许 *(不带 credentials 时) |
| 前端读不到自定义头 | X-Total-Count 为 undefined |
未设 Expose-Headers |
加上对应头名 |
| 前后端都配 CORS | 报 "Multiple values for header" | 两份头被浏览器合并成非法值 | 只保留一处 |
| 预检每次都要发 | 每个请求都多一个 OPTIONS | Max-Age 为 0 或未设 |
设 600–7200,注意浏览器上限 |
| 反向代理后变成跨域 | 明明同域却报跨域 | proxy_pass 改了 Host/协议,或前端写死了后端域名 |
统一用相对路径 + 保留原始 Host |
7. 边界:哪些情况根本不该用 CORS
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 自有前后端同一域名 | nginx 同域反代 | CORS 头只是补偿,反代是消除 |
| 移动端/桌面客户端 | 不需要 CORS | 不是浏览器,没有同源策略 |
| 对外开放 API | 令牌 + 明确白名单 | * 只适合完全公开的只读接口 |
| 本地联调 | devServer 代理 | 不动后端、不污染生产配置 |
| 需要跨站带 cookie | SameSite=None; Secure + 精确来源白名单 |
单靠 CORS 头不够 |
一句话:CORS 是浏览器强加的一道读权限检查,能在架构层把它消掉(同域反代),就不要在应用层天天维护白名单。
8. 自测
- 跨域请求被拦时,服务端有没有收到并处理这次请求?预检失败时呢?
- 为什么
POST+application/json也会触发预检? - 带 cookie 的场景下,
Allow-Origin: *为什么被规范禁止? @CrossOrigin与全局addCorsMappings同时存在时,会产生两份响应头吗?- 预检请求为什么会被鉴权过滤器拦掉?正确的处理位置在哪?
- 前端要读
X-Total-Count,缺哪个响应头?