本文会把 CORS 的原理、SpringBoot 里的几种跨域配置方案,以及实际踩过的坑都完整梳理一遍,适合刚接触前后端分离的新手参考,也适合已经配过跨域但总被奇怪问题折腾的人
目录
- 1. 先从根上理解:浏览器为什么不允许跨域
- 1.1 同源策略是浏览器的“门卫”
- 1.2 CORS 是怎么打破限制的
- 1.3 一个生活化类比看懂全流程
- 2. 简单请求与预检请求:CORS 的两种握手模式
- 2.1 什么样的请求不需要预检
- 2.2 预检请求的完整交互流程
- 2.3 理解两种模式有什么用
- 3. SpringBoot 里的四种跨域配置姿势
- 3.1 方案总览:从注解到网关怎么选
- 3.2 全局配置:一个配置类搞定所有接口
- 3.3 注解式配置:精准控制单个接口
- 3.4 过滤器方式:配合安全框架更稳
- 4. 配置里最坑的几个点:参数选错就会翻车
- 4.1 allowedOrigins 的通配符陷阱
- 4.2 携带 Cookie 与凭证的协同设置
- 4.3 前端拿不到自定义响应头的原因
- 4.4 拦截器和过滤器把 OPTIONS 吞了
- 5. 排错手册:跨域报错定位与实战清单
- 5.1 配置生效还报错?按这五步排查
- 5.2 SpringBoot 版本差异带来的隐藏坑
- 5.3 网关场景:双重配置容易互相打架
- 5.4 本地开发的“捷径”:前端代理
- 5.5 最后一道调试手段:手动模拟请求
- 跨域配置这件事,最后说点实在话
你有没有遇到过这种情况:前端同事急吼吼地跑过来,说“接口明明能通,怎么浏览器里全是一片红?”点开控制台一看,清一色的 CORS error。跨域(CORS)这个问题,几乎每个做前后端分离项目的开发者都躲不过去。后端接口用调试工具测得好好的,一到浏览器里就报跨域,不是你代码写错了,而是浏览器的同源策略在把关。SpringBoot 作为目前最主流的后端框架之一,处理跨域的方式远比想象的要多——有全局配置、有注解、有独立过滤器,也有不少人把跨域问题直接丢给网关或者代理去解。但配置方式各有各的坑,尤其是涉及携带 Cookie、暴露自定义请求头、预检请求缓存这些细节时,一个配置项弄错,线上就会出现“时好时坏”的诡异现象。这篇文章我把 CORS 的原理、SpringBoot 里的几种跨域配置方案,以及我实际踩过的坑都完整梳理一遍,适合刚接触前后端分离的新手参考,也适合已经配过跨域但总被奇怪问题折腾的人。
1. 先从根上理解:浏览器为什么不允许跨域
1.1 同源策略是浏览器的“门卫”
浏览器在 Web 安全模型里有一条底线,叫同源策略。所谓同源,指三个条件同时满足:协议相同、域名相同、端口相同。举个例子, http://localhost:8080 去请求 http://localhost:9090/api ,端口不同,这就是跨域; https://a.com 去请求 http://a.com ,协议不同,同样是跨域。只要有一个条件不满足,浏览器就认为这是两个不同的源。
那浏览器到底在拦什么?它拦的不是“发出请求”这个动作,而是“读取响应”。也就是说,请求照样能发到服务端,服务端也照样会处理,甚至数据库里的数据可能都被查询了,但浏览器会把返回的响应拦下来,不让前端 JavaScript 拿到。这一点非常关键,很多人都误以为跨域请求根本没到后端,实际上后端可能已经执行了操作,只是前端拿不到结果而已。
同源策略保护的核心场景是 XMLHttpRequest 和 fetch 。至于 、 、
这些标签,浏览器天然允许跨域加载,否则 CDN、地图服务、统计脚本全都用不了。但这个“允许加载”只限于加载资源,并不代表页面里的 JS 脚本可以随便读取这些跨域资源的内部数据。理解了这个边界,再去看 CORS 就很顺了。
1.2 CORS 是怎么打破限制的
CORS 全称是 Cross-Origin Resource Sharing,也就是跨域资源共享。它不是一个框架功能,而是一个基于 HTTP 头的协议标准。核心思路是:服务端在响应里显式声明“我允许哪些来源的页面来读取我的数据”,浏览器看到这个声明后,才会把响应交给前端代码。
整个判断过程可以这样理解:浏览器先看请求是不是跨域,如果是,就去响应头里找 Access-Control-Allow-Origin 。这个头存在且值匹配当前页面来源,浏览器就放行;不存在或者不匹配,就直接报错。所以跨域能否成功,最终决定权在服务端手里,浏览器只是执行检查的“门卫”。
这里顺带纠正一个常见的认知偏差:CORS 不是 SpringBoot 独有的概念,也不只是后端要处理的事。任何 Web 服务、任何编程语言,只要正确处理了这些响应头,都能实现跨域资源共享。SpringBoot 只是把这些响应头的生成方式封装好了,让开发者少写很多手工代码。搞懂这一点,你在排查问题的时候就不会只盯着 Spring 配置,而是会先看响应头到底有没有。
1.3 一个生活化类比看懂全流程
把浏览器想象成小区保安,前端页面是访客,后端服务是业主。访客想进小区找业主,保安不会直接放行,而是先问业主:“有个来自某某地址的人要进来,你同意吗?”业主在门口贴了一张纸条,写着“我同意这个地址的人进来”,保安核对纸条后才会放行。
CORS 里的 Access-Control-Allow-Origin 就是那张纸条。不同的是,这张纸条不是手工贴的,而是后端服务在每次响应时动态生成的。如果纸条上写的是 * ,意思是谁都可以进;写的是具体地址,意思是只有这个地址能进。生活里保安见到陌生人会问业主,对应到技术上,就是浏览器遇到“非简单请求”时,会先发一个 OPTIONS 预检请求去问服务端允不允许——这就是下一章要展开的核心机制。
2. 简单请求与预检请求:CORS 的两种握手模式
2.1 什么样的请求不需要预检
很多人第一次看日志,发现后端收到一堆 OPTIONS 请求,还以为是有人恶意攻击。其实那大概率是浏览器发的预检请求。但并不是所有跨域请求都会触发预检,浏览器有一套“简单请求”的判定标准,满足以下所有条件才不需要预检:
- 请求方法是
GET、HEAD、POST之一。 - 如果是
POST,Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain之一。 - 没有使用自定义请求头,比如
Authorization、X-Token这类都不行。 - 不能使用
ReadableStream之类的特殊对象作为请求体。
最常见的反例就是 Content-Type: application/json 。现在前后端交互基本都用 JSON,所以大多数 POST 请求都不是简单请求,都会触发预检。另外,加自定义头也是触发预检的常客,比如很多项目在拦截器里统一校验 Authorization ,前端一加这个头,预检就跑不掉了。
2.2 预检请求的完整交互流程
预检请求的完整流程值得仔细走一遍。假如前端要跨域发一个 POST /api/user ,请求头带着 Content-Type: application/json 和 Authorization: Bearer xxx ,浏览器会先发出一个 OPTIONS 请求,这个请求本身不含业务数据,但会带上三个关键头:
Origin: http://localhost:5173 Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type,authorization
后端收到这个 OPTIONS 请求后,需要进行响应,返回类似这样的头:
Access-Control-Allow-Origin: http://localhost:5173 Access-Control-Allow-Methods: POST, GET, PUT, DELETE, OPTIONS Access-Control-Allow-Headers: content-type, authorization Access-Control-Max-Age: 3600
浏览器看到这些头之后,会核对三件事:请求来源是否在 Allow-Origin 里、请求方法是否在 Allow-Methods 里、请求头是否在 Allow-Headers 里。三项全部通过,浏览器才继续发真实的 POST 请求。任何一个不匹配,预检就失败,真实请求根本不会发出去。
有个容易忽略的细节:预检请求里的 Access-Control-Request-Headers 的大小写不敏感,但 SpringBoot 默认的 allowedHeaders 如果配置了 * ,基本都能匹配上。真正麻烦的是你自定义了允许的请求头列表,但又漏掉了某个头,前端一加这个头就报错。
2.3 理解两种模式有什么用
搞清楚简单请求和预检请求的区别,排查效率会高很多。比如你发现后端日志里只看到一条 POST 请求,但浏览器还是报 CORS 错误,那说明这可能是简单请求,响应头里缺了 Access-Control-Allow-Origin ;如果日志里 根本没有收到 OPTIONS ,那问题可能出在更早的环节,比如请求被拦截器、网关直接挡掉了。
还有一个常见现象是“预检通过、真实请求失败”。这种情况往往会看到 OPTIONS 请求返回 200,但真实请求依然被 CORS 拦下。原因通常是服务端在真实请求的响应里没有带上 CORS 头——比如某些过滤器只给 OPTIONS 请求添加了 CORS 响应头,真实请求走了另一条处理链路。排查时可以对着网络面板仔细看真实请求的响应头,而不是只盯着预检请求。
3. SpringBoot 里的四种跨域配置姿势
3.1 方案总览:从注解到网关怎么选
SpringBoot 里配置跨域至少有四种常见姿势,没有绝对的好坏,只有场景合不合适。我先把几种方案放在一起对比,后面再逐个展开。
| 方案 | 使用场景 | 优点 | 缺点 |
|---|---|---|---|
@CrossOrigin 注解 |
单个接口或单个 Controller 快速放行 | 配置直观、粒度细 | 接口多时维护成本高 |
WebMvcConfigurer 全局配置 |
单体后端服务 | 一处配置全局生效,最常用 | 无法覆盖非 MVC 链路 |
CorsFilter 过滤器 |
配合安全框架或自定义过滤器使用 | 层面最低、覆盖范围广 | 与 Spring Security 配置容易冲突 |
| 网关统一配置 | 微服务架构 | 边界清晰,避免每服务重复配 | 网关和后端都配时会互相干扰 |
选型逻辑其实很简单:单体项目无脑用 WebMvcConfigurer ;如果项目里集成了 Spring Security,建议直接看安全框架的 CORS 配置能力,或者使用 CorsFilter ;如果已经上了微服务网关,跨域最好统一收敛到网关,后端服务不再单独配。有人喜欢每个服务各配各的,一旦出现“服务 A 能访问、服务 B 不行”的情况,排查起来极其痛苦。
3.2 全局配置:一个配置类搞定所有接口
最通用也最省心的办法是实现 WebMvcConfigurer 接口,重写 addCorsMappings 方法。直接放代码:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
这段配置的含义很直白: addMapping("/**") 表示对所有路径生效; allowedOriginPatterns("*") 表示允许所有来源; allowedMethods 列出允许的 HTTP 方法; allowedHeaders("*") 放行所有请求头; allowCredentials(true) 允许携带凭证; maxAge(3600) 表示预检请求的结果可以缓存一小时。
这里有个特别需要注意的取舍: allowCredentials(true) 和 allowedOriginPatterns("*") 是安全的组合,但如果你写成 allowedOrigins("*") 再配上 allowCredentials(true) ,浏览器会直接拒绝。具体原因后面单独讲。单就这段配置来说,它是目前兼容性最好的全局写法,也被我到处粘贴过无数次。
3.3 注解式配置:精准控制单个接口
如果只是某个管理端接口需要跨域调用,用 @CrossOrigin 更轻量。它可以直接放在类上,也可以放在方法上:
@RestController
@RequestMapping("/api/user")
public class UserController {
@GetMapping("/info")
@CrossOrigin(origins = "http://localhost:5173", maxAge = 3600)
public Result getInfo() {
// ...
}
}
放在类上表示这个 Controller 的所有接口都生效,放在方法上就只对那一个接口生效。方法上的注解优先级高于类上的注解。实际项目里我见过有人把 @CrossOrigin 加在每一条接口上,这种做法在接口超过二十个之后维护起来就很头疼,改一个来源域名得全局搜索替换。所以注解方式更适合临时调试、或者确实只有两三个接口需要跨域的场景。
还有一个细节值得提: @CrossOrigin 默认的 allowCredentials 是 false 。如果你需要携带 Cookie,必须显式写 allowCredentials = "true" ,否则前端就算设置了 withCredentials: true ,浏览器照样会把请求拦下来。Spring 的注解属性里 allowCredentials 是字符串类型而不是布尔,写的时候容易看走眼。
3.4 过滤器方式:配合安全框架更稳
CorsFilter 是底层一点的方案,它直接注册为一个 Filter Bean,作用范围不局限于 Spring MVC 的处理器链路。项目里如果用了 Spring Security,或者有其他自定义过滤器,且它们和 MVC 拦截器之间存在顺序问题,用 CorsFilter 通常更可控。
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
这段代码和全局配置的效果几乎一样,但它的执行发生在过滤器链层面。需要强调的是,Spring Security 从 5.x 开始自己也有 CORS 处理逻辑,如果你同时又注册了 CorsFilter ,两者可能重复添加响应头,虽然大多数时候不影响结果,但响应头出现重复项时,某些浏览器会有兼容性意外。建议二选一:要么用框架提供的过滤器,要么在 Security 配置里用 http.cors() 打开自带的 CORS 支持。
4. 配置里最坑的几个点:参数选错就会翻车
4.1 allowedOrigins 的通配符陷阱
allowedOrigins("*") 和 allowedOriginPatterns("*") 看起来差不多,实际行为差别很大。 allowedOrigins 里写 * ,表示响应头直接返回 Access-Control-Allow-Origin: * 。一旦同时开启 allowCredentials(true) ,按照浏览器规范,带凭证的请求不允许通配符来源,于是浏览器直接拒绝,弹出一段“The value of the 'Access-Control-Allow-Origin' header ... must not be the wildcard '*'”的报错。
而 allowedOriginPatterns("*") 的工作方式不同:它会把请求的 Origin 原样匹配后,回写成具体的来源地址。浏览器看到的是 Access-Control-Allow-Origin: http://localhost:5173 这种精确值,而不是 * ,这样就能和 allowCredentials(true) 共存。
所以结论是:需要携带 Cookie 时,使用 allowedOriginPatterns ;不需要携带 Cookie,并且确实想让任何第三方来源都来访问,才可以用 allowedOrigins("*") 。但生产环境里“谁都能访问”通常不是什么好事,哪怕技术上支持,我也建议把来源收敛成明确列表。
4.2 携带 Cookie 与凭证的协同设置
跨域携带 Cookie 是前后端分离项目里最容易“互相甩锅”的环节。后端配置了 allowCredentials(true) 只是必要条件,前端必须同步配合。 fetch 要加 credentials: "include" , axios 要设 withCredentials: true 。前端没开的话,浏览器压根不会把 Cookie 带上,即使后端响应头一切正常,登录状态依然保不住。
还有一个容易被忽视的点:如果前端和后端不在同一个域名下,Cookie 的域属性也需要处理。跨域情况下,服务端要确保 Set-Cookie 里不设置过死的 Domain ,否则浏览器不会存储。同时如果后端接口跑在 HTTPS 下,Cookie 的 Secure 属性也要一起考虑,否则同一份 Cookie 在 HTTP 调试环境里能存,切到 HTTPS 就存不进去。
另外, withCredentials 一旦打开, Access-Control-Allow-Origin 就不能再返回 * 。这一点在本地用 http://localhost:5173 调试时还不明显,部署到线上如果前端域名变化,而后端代码里写死了来源,就会直接导致登录失效。我的习惯是把允许的来源列表放到配置项里,避免每次换环境都动代码。
4.3 前端拿不到自定义响应头的原因
很多接口会在响应头里返回分页相关字段,比如 X-Total-Count ,或者刷新令牌 X-Token 。前端代码想通过 response.headers.get("X-Total-Count") 去读取,结果拿到的全是 null 。这不是后端没返回,而是浏览器默认只允许前端读取几个标准的响应头,比如 Cache-Control 、 Content-Type 、 Content-Language 。其他自定义头必须由后端显式声明。
在 SpringBoot 里通过 exposedHeaders 声明即可:
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.exposedHeaders("X-Total-Count", "X-Token");
}
有很多人只注意了 allowedHeaders ,也就是允许前端“带过来哪些头”,却完全忽略 exposedHeaders ,也就是允许前端“读走哪些头”。这俩名字长得相似,作用方向刚好相反。 allowedHeaders 管的是请求头, exposedHeaders 管的是响应头,排查问题的时候先把这两个分清。
4.4 拦截器和过滤器把 OPTIONS 吞了
这是我自己踩过最深的一个坑。项目里有一个登录拦截器,校验用户有没有登录,没登录就直接返回 401。预检请求里面不会携带业务凭证,于是拦截器一看到 OPTIONS 请求,判断未登录,直接返回 401,浏览器根本拿不到任何 CORS 响应头,真实请求自然发不出去。
解决办法是让拦截器对 OPTIONS 请求直接放行。放行的判断依据是 HttpMethod.OPTIONS 或者请求头里是否带 Access-Control-Request-Method 。注意要在拦截器的最前面放行,否则后续逻辑可能先把预检给拦截了。类似的,自定义过滤器如果写了对请求的统一处理逻辑,也要确认不会把 OPTIONS 请求误伤。
这里要特别说一下执行顺序:Spring MVC 的 HandlerInterceptor 是在 CORS 处理之前还是之后执行,不同版本行为不太一致。所以不要假设拦截器一定在 CORS 后面,最简单可靠的思路就是在拦截器里明确放行 OPTIONS 。日志里如果能看到预检请求背着 401 或者 403 状态码返回,基本就是这个环节的问题。
5. 排错手册:跨域报错定位与实战清单
5.1 配置生效还报错?按这五步排查
跨域问题看着千奇百怪,实际排查路径只要按顺序走,绝大多数都能定位。我总结了一个五步检查法:
- 打开浏览器开发者工具,找到报错的请求,看响应头里有没有
Access-Control-Allow-Origin。没有,说明后端这个接口根本就没进 CORS 配置链路。 - 看网络面板里有没有 OPTIONS 请求。有,说明这不是简单请求;没有,说明是简单请求。分别检查两种情况下的响应头。
- 看 OPTIONS 请求的状态码。如果 401、403、404,那就找到元凶了——通常是拦截器、过滤器或路由没放行 OPTIONS。
- 看接口网关上有没有重复的 CORS 配置。网关和后端都返回了 CORS 头时,浏览器可能因为头重复或值不一致而拒绝。
- 确认前端凭证设置。如果接口需要登录态,而请求里没有 Cookie,那就看
withCredentials或credentials是否打开。
每次排查完,把“现象、原因、改动点”写下来,攒多了你会发现跨域翻来覆去就是这几个原因。硬件级的稳定可靠问题我见过很多,像这种 HTTP 层面的问题,只要按链路一步步看,基本没有定位不到的。
5.2 SpringBoot 版本差异带来的隐藏坑
SpringBoot 2.x 和 3.x 之间最明显的差异是底层包从 javax.servlet 换成了 jakarta.servlet 。如果你从 2.x 升级到 3.x, WebMvcConfigurer 的包路径会变,已有的代码如果不改 import,编译直接报错。这不是 CORS 本身的问题,但很多人升级后第一反应是跨域配置失效了,其实只是包路径没跟着切。
另一个不太容易被察觉的差异在预检请求的处理顺序上。SpringBoot 2.7 之后对 CORS 的预检处理逻辑有过调整,整体更规范,但如果你用了老版本自定义的 HandlerInterceptor ,升级后可能会发现原本能跨域的接口突然不行了。建议升级版本后把和跨域相关的集成测试跑一遍,尤其是那些带着 Authorization 头和 OPTIONS 方法的用例。
5.3 网关场景:双重配置容易互相打架
在微服务架构里,浏览器请求会先打到网关,再由网关转发到具体服务。如果网关配了一份 CORS,后端服务又配了一份,就会出现两个问题:一是 OPTIONS 请求可能直接被网关拦截,后端根本没机会响应;二是响应头可能出现两份,或者值不一致。浏览器对重复响应头的容忍度不一致,结果就是“有的浏览器行,有的浏览器不行”。
推荐的方案是“只在边界配一次”。如果统一在网关处理,后端服务就不需要再配置跨域;如果后端服务各自配置,那网关层面就不要重复处理跨域。我个人更倾向在网关层统一做,因为网关能清楚看到所有来源,也方便后续在配置文件或配置中心里动态调整允许的来源列表,而不用改每个服务再各自发版。
5.4 本地开发的“捷径”:前端代理
如果你用前端开发服务器,本地调试跨域还有一个更省心的玩法:代理转发。原理是把前端页面里的 /api 请求转发到后端地址,浏览器看到的请求路径是同源的,自然就没有跨域问题了。典型配置长这样:
// vite.config.js
server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
这个方案只适合开发阶段,生产环境不能用。但它对日常联调很有价值:前端不用等后端配置好跨域,后端也不用为了配合调试去改来源白名单。需要注意一点,代理只是把请求转发到 localhost:8080 ,如果后端运行时还依赖服务间的鉴权、限流等逻辑,代理模式下可能测不全,正式环境的联调还是要把代理关掉,走真正的网络链路测一轮。
5.5 最后一道调试手段:手动模拟请求
遇到离线排查困难的情况,最直接的手段是脱离浏览器,手动模拟请求看响应头。用命令行工具模拟一个跨域请求,只要加上 Origin 头即可:
curl -i -X OPTIONS 'http://localhost:8080/api/user' \ -H 'Origin: http://localhost:5173' \ -H 'Access-Control-Request-Method: POST'
服务端返回的响应头里如果有 Access-Control-Allow-Origin ,说明配置链路是通的;如果连 OPTIONS 都没有正常响应,那就是入口层的问题。这个方法最大的好处是绕开浏览器复杂的缓存和策略,直接看服务端真实行为。把手动请求的结果和浏览器网络面板里的结果放在一起对比,通常一眼就能分辨是后端没返回头,还是浏览器不认可返回的头。
跨域配置这件事,最后说点实在话
跨域问题在整个后端开发里不算难,但它往往藏在一堆耦合逻辑后面,一不留神就会耗费整个下午。我的习惯是:项目初期就规划好来源白名单,把允许的域名放进配置中心或配置文件,用 allowedOriginPatterns 加上精确匹配,尽量不让 * 出现在线上配置里。接口调试阶段如果出现跨域报错,先看响应头再改配置,不盲目在代码里加料。
另一个体会是,跨域配置不要做成“一人一份”。团队里如果每个人都按自己想法加配置,很快就会出现重复响应头、来源列表膨胀、某些环境成功某些环境失败的问题。最好规定一个唯一入口,比如网关统一配,或者全局配置类统一管,其他人不去改动,这类问题会少很多。
最后再分享一个小技巧:排查跨域问题时,在浏览器控制台里执行一次 fetch 请求,把返回值打印出来看报错信息。浏览器给出的英文报错里通常已经写明了原因,比如“expecting a wildcard”或者“multiple values”,再去搜索引擎搜这一段,命中率极高,比反复试配置快得多。跨域这个坑,前几年我一年能踩十几次,把排查路径理顺之后,现在基本十分钟内就能定位。
到此这篇关于SpringBoot中跨域配置方法与常见坑点全解析的文章就介绍到这了,更多相关SpringBoot跨域配置内容请搜索爱读视界以前的文章或继续浏览下面的相关文章希望大家以后多多支持爱读视界!
您可能感兴趣的文章:
- SpringBoot中CORS跨域访问如何实现?全局与局部配置
- SpringBoot跨域问题怎么解决?CORS配置方法详解
- SpringBoot+Vue跨域配置(CORS)问题得解决过程
- 解读SpringBoot中addCorsMappings配置跨域与拦截器互斥问题的原因
- Springboot通过配置WebMvcConfig处理Cors非同源访问跨域问题
- Springboot处理配置CORS跨域请求时碰到的坑