☰
@CrossOrigin配置不生效?Spring Boot微服务跨域问题排查与解决
2026/10/8 2:51:21 网站建设 项目流程

最近一个群里有人问了个特别典型的问题:Spring Boot 微服务中控制器上明明写了@CrossOrigin(origins = "*"),前端 Vue 项目请求过来还是报跨域,而且后端不报任何异常。这种问题我在实际项目里踩过不止一次,看着是注解配置缺失或者写错的小事,实际上牵扯到整个请求链路里各个环节对 HTTP 头部的处理。如果你也在微服务架构里遇到过类似情况,这篇内容基本能帮你把问题收敛到具体某个层面。

先说结论:@CrossOrigin(origins = "*")这种写法本身并没有错,但它不是万能的。尤其在微服务场景下,前面挂了网关、加了 Spring Security、或者自定义了 Filter,任何一个环节拦截了 OPTIONS 预检请求,或者覆盖已经设置好的 CORS 响应头,前端照样跨域。这不是注解没生效,而是它生成的头部根本没机会到达浏览器。

1. 跨域问题的本质,以及为什么"配置了"不等于"生效了"

1.1 浏览器同源策略到底拦的是什么

前端跨域问题从根上讲是浏览器在搞事情,不是后端拒绝响应。浏览器要求当前页面地址的协议、域名、端口三者都和请求目标地址一致,才允许页面里的 JavaScript 读取响应内容。任意一项不同,就属于跨域请求。

但要注意一个细节:跨域请求在大多数情况下其实已经发出去了,后端也处理了,只是浏览器在拿到响应之后,检查响应头里有没有Access-Control-Allow-Origin这个字段,并且该字段的值是否和当前页面域名匹配。

如果不匹配或者根本没有这个字段,浏览器就会把响应丢进黑名单,前端 JavaScript 拿到的是网络层面的报错,看起来就像请求失败了一样。

所以在排查这类问题的时候,第一件事就是要先看响应头里有没有 CORS 相关的字段,不要一上来就怀疑后端没处理。

1.2 简单请求与预检请求的两个阶段

跨域请求又分简单请求和非简单请求。简单请求一般指 GET、HEAD、POST 这三种,并且请求头只包含浏览器默认允许的字段,比如 Accept、Content-Type 的三种值之一。简单请求不会触发预检,浏览器直接发真实请求。

一旦请求方法为 PUT、DELETE,或者 Content-Type 为application/json,或者请求头里带了自定义字段如X-Token、Authorization,浏览器就会先发一个 OPTIONS 请求做预检。预检通过之后,才发真实请求。

问题就出在这里。很多后端同学只给真实请求的接口配置了@CrossOrigin,却忽略了 OPTIONS 预检请求也走的是同一个 Controller 映射。如果这个 OPTIONS 请求在到达 Controller 之前就被拦截器、过滤器或者安全框架挡掉了,返回的响应没有 CORS 头,浏览器就直接认为预检失败,真实请求根本不会发出。

这也就是标题里描述的现象:明明注解配置了,前端还是跨域。

1.3 从响应头角度理解配置生效链路

不管是注解方式、配置类方式,还是网关统一配置,最终目的就是让响应头携带三样关键东西:

响应头字段作用
Access-Control-Allow-Origin声明允许哪个来源读取响应,*表示所有来源
Access-Control-Allow-Methods声明允许哪些 HTTP 方法,如 GET、POST、PUT、DELETE
Access-Control-Allow-Headers声明允许哪些自定义请求头,如 Authorization、X-Token

这三样字段缺少任何一个,预检或真实请求都可能出问题。而且要注意,响应头里的Access-Control-Allow-Origin如果设置为*,浏览器还要求前端请求不要携带凭证信息,比如 Cookie。因为带凭证的跨域请求不允许*,必须精确指定域名。这个细节也是很多人的坑。

理解了这一层,才能明白排查重点在于找出哪个环节丢弃或覆盖了这些响应头。

2. 为什么 @CrossOrigin(origins = "*") 配置了却不生效的四大场景

2.1 Spring Security 直接拦截了 OPTIONS 请求

这是最高频的原因。项目里用了 Spring Security,如果配置里没有对 OPTIONS 请求放行,安全过滤器链会拦截所有请求,包括预检请求。预检请求被 Security 拦截后返回的响应里通常不带 CORS 头,浏览器就会判定预检失败。

而且 Spring Security 还有一层自己的 CORS 配置机制。你可以启用http.cors(),它会自动查找名为corsConfigurationSource的 Bean 或者 MVC 框架提供的 CORS 配置。如果你的项目里既在 Controller 上写了@CrossOrigin,又在 Security 配置里配了http.cors(),两者之间可能发生配置覆盖或合并的混乱情况。

我在实际项目里遇到过一种诡异的情况:Security 配置里没有调用http.cors(),但自定义了一个 CorsFilter 装载到过滤器链里,结果该 Filter 的顺序不对,被排在了 Spring Security 的过滤器之后,导致预检请求先进了 Security,被拦截后才轮到 CorsFilter,响应头上被添加的 CORS 信息根本没有机会生效。

2.2 自定义 Filter 或拦截器在作怪

微服务项目里往往有自定义的登录校验过滤器、请求日志过滤器、统一响应包装过滤器等。这些过滤器如果在请求链路中提前返回了响应,而没有继续执行后续的过滤器链,就会跳过 Spring MVC 框架自动添加 CORS 头的逻辑。

举个典型例子:某个自定义 Filter 里对请求做了分发路由判断,发现请求不符合条件就直接返回 401,使用的是response.getWriter().write(),没有做任何跨域头设置。这种情况下浏览器自然拿不到Access-Control-Allow-Origin。

还有一种情况更隐蔽:过滤器里使用了响应包装类,比如ContentCachingResponseWrapper,目的是记录响应体做日志。如果包装类没有正确透传响应头,服务层设置的 CORS 头可能被丢掉。

排查思路就在请求链路里,把每个环节的关键响应头都打出来,看看最后到底是谁动了头部。

2.3 Controller 类与方法上的注解互相覆盖

Spring 的@CrossOrigin注解支持加在类上和方法上。很多人只把注解加在了方法上,类上又没加。这本身没问题。但如果类里面有多个方法,不同方法上的@CrossOrigin配置不一致,或者类上配置了某个域名,方法上又配置了另一个域名,Spring 内部会做合并处理,合并逻辑有时不是你想的那样。

比如类上有:

@CrossOrigin(origins = "http://localhost:8080") public class UserController

方法上有:

@CrossOrigin(origins = "*") @GetMapping("/list") public Result list()

这种情况下,Spring 会对两个注解的属性做合并,origins被方法级别覆盖为*,这没问题。但如果类上的注解没有把allowCredentials显式写为 false,而方法上写的是 true,合并出来就会带上Access-Control-Allow-Credentials: true。一旦响应头带上这个字段,浏览器就会拒绝Access-Control-Allow-Origin: *的组合,强制要求精确域名。

这个坑很难一眼看出来,因为注解本身写得看起来都合理。但浏览器报的错就那一句话:The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credential mode is 'include'。

2.4 网关层 CORS 配置覆盖了服务层配置

微服务架构里最让人头疼的就是这一层。请求链路通常是:浏览器 -> 网关 -> 微服务。浏览器请求的目标地址是网关地址,所以浏览器只关心网关返回的响应头。如果网关层没有配置 CORS,或者配置方式和子服务的配置冲突,子服务就算把 CORS 头加得再完整也没用。

有一种情况是设置了全局 CORS 处理器,比如基于 Spring Cloud Gateway 的GlobalCorsConfiguration,同时在子服务里也加了@CrossOrigin注解。网关处理完请求后附加了 CORS 头,子服务处理完后也附加了 CORS 头,两个头内容不一致时,浏览器会以最终收到的头为准。

但如果网关层对 OPTIONS 请求直接做了拦截,不转发到下游,只是返回空响应,也不加任何 CORS 头,那问题就变成 OPTIONS 完全没有人管。

网关层还有一个典型问题:当网关根据路径路由到不同服务时,子服务返回的响应中原本的 CORS 头并不会被网关自动透传。有些网关过滤器会在转发过程中重写响应头,导致 CORS 头丢失。

3. 微服务场景下 CORS 配置的正确做法与实操方案

3.1 网关层统一配置而不是分散到每个服务

我的经验是:在微服务架构里,不要在子服务里依赖@CrossOrigin注解处理跨域,而是把 CORS 配置统一收敛到网关层。核心原因很简单:浏览器最终面对的就是网关这个"门面",你在每个子服务里配置得再整齐,经过网关一转发,业务响应头上带的 CORS 信息可能就没了,排查起来还要挨个服务看,非常痛苦。

如果用的是 Spring Cloud Gateway,可以在配置文件里设置全局 CORS,这是基于 WebFlux 的处理器,配置方式如下:

spring: cloud: gateway: globalcors: cors-configurations: '[/**]': allowed-origin-patterns: "*" allowed-methods: "*" allowed-headers: "*" allow-credentials: true max-age: 3600

或者用 Java 配置类:

@Configuration public class CorsGlobalConfiguration { @Bean public CorsWebFilter corsWebFilter() { CorsConfiguration corsConfig = new CorsConfiguration(); corsConfig.addAllowedOriginPattern("*"); corsConfig.addAllowedMethod("*"); corsConfig.addAllowedHeader("*"); corsConfig.setAllowCredentials(true); corsConfig.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", corsConfig); return new CorsWebFilter(source); } }

用addAllowedOriginPattern而不是addAllowedOrigin,在允许携带凭证时这一点很重要,前者才能支持*与allowCredentials(true)的组合。

这样网关层持有唯一的 CORS 策略,子服务专注于业务逻辑,跨域问题一举收敛。子服务已有的@CrossOrigin注解建议移除,避免双重配置出现奇怪的头部叠加。

3.2 子服务不得不配时该选哪种方式

子服务里有时出于接口复用或独立调试的考虑,还是要在应用层配置 CORS。这种情况下我的建议是用 WebMvcConfigurer 的全局配置类,而不是注解。

原因是配置类可以集中管理,权限范围更大,还能避免每个 Controller 都要加注解的重复劳动。

@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); } }

这段代码全局生效。配合 Spring Security 时还需要在安全配置里调用http.cors(),让 Spring Security 感知到 MVC 的 CORS 配置并纳入它的过滤器链。

如果项目是 Spring WebFlux 风格,比如基于 WebFlux 的响应式服务,则要使用WebFluxConfigurer接口而不是WebMvcConfigurer。两个接口名字接近,但所在的包完全不同,用错了配置不会生效,而且不会报任何错误。

3.3 与 Spring Security 集成的注意事项

Spring Security 6 的配置方式和旧版变化不小,这里给出一段可以实际用的配置:

@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()); return http.build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.addAllowedOriginPattern("*"); configuration.addAllowedMethod("*"); configuration.addAllowedHeader("*"); configuration.setAllowCredentials(true); configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } }

三个动作缺一不可:显式声明corsConfigurationSource这个 Bean,http.cors()开启,并且对 OPTIONS 请求放行。

很多同事只做了后两步,没有显式声明 Bean,这样 Spring Security 会去容器里自动查找CorsConfigurationSource,如果找不到,它就会内部建一个不完整的配置,效果等于没有 CORS。

3.4 前端调试时可以通过 fiddler 代理辅助定位

后端 coder 排查跨域问题时,如果办公室里有前端同事,或者自己也装了抓包工具,用 fiddler 这类代理工具辅助定位是非常快的。操作上很简单:启动 fiddler 监听本机 8888 端口,前端请求由它代理转发到你的后端接口,请求路径改写后,就能在 fiddler 的 Inspectors 面板里直接看到请求头、响应头的完整数据,包括是否有Access-Control-Allow-Origin、Access-Control-Allow-Methods等字段。

我一般建议的做法是:先用 curl 直接调用后端接口,或者用 fiddler 构造一个 Option 预检请求,观察后端响应头的情况。

如果 curl 都能拿到 CORS 头,说明后端配置没问题,问题很可能出在浏览器的判断逻辑或者前端请求方式上。如果 curl 拿不到,那就是后端的锅,顺着过滤器链往下查。

curl 模拟预检请求的方法:

curl -i -X OPTIONS http://localhost:8080/api/user/list \ -H "Origin: http://localhost:8081" \ -H "Access-Control-Request-Method: GET" \ -H "Access-Control-Request-Headers: content-type,authorization"

看返回结果中是否有 CORS 相关字段,一目了然。这也是我排跨域最快的办法,比在浏览器里反复刷新看 Network 面板快得多。

3.5 使用 PHP 或其他非 Java 后端时的参考思路

虽然这个标题聚焦在微服务和@CrossOrigin注解,但群里也有写 PHP 后端的朋友来问跨域。基于最近搜到的“php 跨域 + jsonp”这种热词,说明还有不少老项目在用 jsonp 处理跨域。jsonp 的思路是利用<script>标签不受同源策略限制的特点,让后端返回一段可执行脚本,前端通过回调函数拿数据。但它存在明显局限:只支持 GET 请求,无法处理 POST、PUT 等复杂请求,也已经不在新项目的主流方案里。

如果是 PHP 后端做跨域配置,主流做法是在中间件或入口文件中统一加响应头:

header("Access-Control-Allow-Origin: *"); header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS"); header("Access-Control-Allow-Headers: Content-Type, Authorization");

需要注意的还是老问题:如果请求需要携带 Cookie 或认证信息,就不能用*,得精确指定来源域名,并加上Access-Control-Allow-Credentials: true。这一点规则在所有语言的后端里是通用的。

Java 微服务排障时,也可以参考同样的头部检查原则,先确认服务返回的 CORS 头是否符合前端的请求模式。

4. 现场排障实录与问题速查表

4.1 一个完整的排查过程记录

说一个我印象比较深的真实排障过程。某个项目前端是 Vue 3 部署在 8080 端口,后端网关是 Spring Cloud Gateway 部署在 9000 端口,子服务是用户服务跑在 8082 端口。前端请求http://localhost:9000/api/user/info,带上Authorization头。

浏览器报错:Access to XMLHttpRequest has been blocked by CORS policy。

后端同事说网关里已经配了全局 CORS,子服务控制器上也加了@CrossOrigin(origins = "*")。听起来配置很全,但就是不行。

我当时做的第一步是用 fiddler 抓包,先看 OPTIONS 请求到底打到哪、返回什么。结果发现 OPTIONS 请求根本没打到网关,还在浏览器层面就被拦下了。仔细一看,前端代码里 axios 请求的withCredentials被设置成了true,并且网关的 CORS 配置又设置了allowCredentials: true,同时 allowed-origin-patterns 配的是*。按照 CORS 规范,带凭证信息时不允许通配符,必须指定具体来源。

这个问题的修复方式不是把*改掉,而是在前端明确使用具体的请求来源地址,网关配置allowedOriginPatterns精确到前端域名。如果前端地址经常变,比如本地开发和测试环境不同,也可以在网关层维护一个域名白名单列表。

替换配置后重启网关,浏览器请求恢复正常。

这种问题最磨人的地方在于,你看了后端配置会觉得明明都对,但浏览器就是跟你对着干。所以建议前端代码里尽量少用withCredentials: true,如果业务上确实要带 Cookie 做单点登录,那么后端每个 CORS 配置都要注意来源精确匹配,并且不能跨服务共享一个全局*。

4.2 高频症状对应根因速查表

症状可能根因快速验证方式
OPTIONS 请求返回 401 / 403Spring Security 未放行预检检查安全配置中是否对 OPTIONS 做了 permitAll
OPTIONS 请求返回 200 但无 CORS 头拦截器或过滤器提前返回响应抓包看响应头,确认过滤器链路中是否跳过滤链
响应头有Allow-Origin: *,同时有Allow-Credentials: true带凭证跨域不允许通配符精确指定来源域名,不要用*
GET 请求正常,POSTapplication/json跨域预检未通过确认 OPTIONS 请求能否正常返回 CORS 头
子服务接口用 curl 调用有 CORS 头,前端访问网关没有网关层未透传或覆盖在网关层统一配置 CORS
本地改了配置对方说还是不行浏览器缓存了 OPTIONS 结果启动时配置maxAge调小或清缓存验证

4.3 两条压箱底的排查经验

第一,跨域问题永远从响应头入手,而不是从代码注释里抠。用 curl 或者抓包工具先拿到原始响应,看看有没有 CORS 头,没有就顺着链路找哪个环节丢弃了,有就看头里的配置值是否和前端期望匹配。这个思路能帮你绕开 80% 的弯路。

第二,微服务架构中网关层的 CORS 配置优先级最高。如果网关已经配好了,子服务尽量不要重复配置。有时候网络上下文里的配置你自己看觉得没问题,但一旦多个配置叠加,某个中间环节意外合并了两个头,就会出现“单看都对,拼起来就坏”的情况。

4.4 本地开发连不上网关时的小技巧

本地开发时,很多前端同事不愿意把所有请求都走网关,图省事直接请求子服务端口。但子服务的 CORS 配置和网关注定是两套逻辑,于是跨域问题就一个个冒出来。

我的建议是尽量让前端开发环境也走网关,保证和生产环境链路一致。实在不行就自己加一层代理,Vite 或 webpack 的 devServer 配置代理是最轻量的方式:

// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:9000', changeOrigin: true } } }

这样前端请求看起来就是同源请求,浏览器层面根本不会触发跨域,开发体验能提升一大截。不过这只是开发期的绕行方案,生产环境的跨域策略一定还是要靠后端正确配置去解决。

5. 针对网关与子服务组合场景的方案建议

5.1 子服务保留注解但网关不做检查时的配置参考

有的团队代码版权或者历史包袱太重,子服务里面的@CrossOrigin注解暂时没法删,这时最重要的就是要保持配置结果的一致性。网关层尽量不要再设置 allowCredentials,避免和子服务注解产生叠加冲突。

如果子服务里很多 Controller 都写了自己的@CrossOrigin注解,而且内容还不统一,有时间的话还是建议周末统一改成配置类方式,既能一致管理,也方便后续调整。用注解临时顶着的项目,常常过了几个月就没人敢动那一层,跨域问题变成历史遗留问题。

5.2 网关统一 CORS 后子服务怎么移除注解

移除@CrossOrigin其实没有技术难点,工作量大头在梳理接口。可以先通过 IDE 全局搜索找出所有使用@CrossOrigin的地方,评估每个 Controller 对外的访问来源,然后把配置整合到网关注入配置文件里。这里有一个要注意的小细节:如果接口里用到了HttpServletResponse手动设置响应头,比如自己写了:

response.setHeader("Access-Control-Allow-Origin", "*");

那这种代码和网关配置也会出现冲突。建议全局搜索setHeader和Access-Control关键字,把所有手动设置统一删掉。

5.3 如果前端强制要求 JSONP 的兼容处理

现实中偶尔会遇到历史遗留的前端页面用了 JSONP 方式,已经没有改动计划。这时后端可以在特定接口上支持 JSONP 响应格式。

思路是拦截请求的callback参数,如果存在则返回一段 JavaScript 包装的数据:

@GetMapping("/jsonp") public String jsonp(@RequestParam("callback") String callback, String data) { return callback + "(" + data + ")"; }

这种方式只能说应急,不要用来设计新接口。JSONP 的安全风险明显,无法支持 POST、无法感知请求完成状态,而且与微服务网关配置 CORS 的设计方向相违背。

我个人的观点是:能统一 CORS 就统一,JSONP 只作为兼容历史接口的过渡手段。

6. 最后再分享一个我自己总结的排障小工具

排障的时候不建议每次到浏览器控制台里翻红字,半天看不出原因。我习惯先在浏览器控制台手动发一段模拟代码,直接把结果打印出来看:

fetch('http://localhost:9000/api/user/info', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer xxx' } }) .then(res => res.text()) .then(text => console.log(text)) .catch(err => console.warn(err));

如果这个代码在跨域时报错,重点看err对象内部的 message,浏览器给出的错误信息往往直接把问题指向了某类配置。比如 "Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource",那基本就能确定响应里没有 CORS 头,接下来就去查过滤器链和网关配置。

再分享一个经验:处理这种问题不要一个一个接口去试,先把所有接口路径的配置文件或者注解写法和请求模式梳理出来,统一检查一遍,比啥日志都来得快。自测阶段每条核心链路至少过一遍简单 GET 请求、POST JSON 请求、带自定义头的请求,基本就能把跨域配置的坑提前排掉。

这些经验碰到过实际问题才会知道有多值钱。配置看似几行代码,但微服务链路上的每个节点都在等你出错。希望这篇内容能让你少走一次弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询