☰
Spring Boot跨域问题全解:四种解决方式与生产实践
2026/10/6 21:39:39 网站建设 项目流程

做前后端分离项目的人,十有八九都见过这行让人血压升高的红色报错:Access to XMLHttpRequest at 'http://localhost:8080/api/xxx' from origin 'http://localhost:5173' has been blocked by CORS policy。第一次遇到的人很容易慌,因为后端日志里明明有请求进来、接口也返回了数据,可浏览器就是不让前端读。这就是Spring Boot项目里最常见的跨域问题窗口期。

后半句才是关键:CORS报错不等于后端没收到请求,而是浏览器按同源策略把响应“扣住”了。Spring Boot里解决跨域的方式不少,但我在实际项目里稳定用下来的,主要就是这么四种:@CrossOrigin注解、全局WebMvcConfigurer配置、CorsFilter配合Spring Security,以及nginx反向代理。这篇文章就把这四种方式掰开揉碎讲清楚,包括它们各自的原理、代码、适用场景,以及我在生产环境里踩过的那些坑。如果你也被跨域搞到头皮发麻,这篇应该能帮你少走三四个弯路。

1. 跨域问题到底是谁拦的:先理清CORS的运行机制

1.1 同源策略是浏览器的“户口本”制度

先说一个我经常在面试里问的基础问题:跨域到底是谁规定不能跨的?答案是浏览器。协议、域名、端口三个东西,只要有一个不一致,浏览器就认为这是跨域。举个例子,前端页面在http://localhost:5173,后端接口在http://localhost:8080,端口不一致,这就跨域了。

但是跨域不等于不能发请求。浏览器的同源策略默认禁止的是“跨域读取响应”,而不是“发出请求”。所以后端接口照样会收到请求、照样会执行逻辑、照样会把数据返回,只不过浏览器在拿到响应之后发现缺少了允许跨域的关键响应头,于是狠狠心把整个响应丢进了垃圾桶,然后在控制台抛出一句“has been blocked by CORS policy”。

那么什么响应头才是关键?最常见的是Access-Control-Allow-Origin。这个头告诉浏览器:我允许来自http://localhost:5173的脚本读取本次响应。只要这个头和当前请求的Origin能对上,浏览器就会放行。Spring Boot里解决跨域,本质上就是给响应补上这一系列Access-Control-*头。

1.2 简单请求与预检请求:为什么你总是看到OPTIONS

很多人在Network面板里发现请求列表里多了一条OPTIONS请求,就以为后端接口被调用了两次,还怀疑是前端重复请求。其实这个OPTIONS大概率是跨域预检请求,是浏览器主动发出来的“考前询问”,用来探测后端到底允不允许跨域。

什么情况下会触发预检?简单说就是请求比较复杂的时候。浏览器规定,只有同时满足以下条件才属于“简单请求”:

  • 请求方法为GET、HEAD、POST之一;
  • 请求头只能包含几个固定的安全头,比如Accept、Content-Language、Content-Type等;
  • Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain之一。

只要你的实际请求里带了自定义Header(比如Authorization、X-Requested-With),或者用了PUT、DELETE、PATCH这些方法,又或者Content-Type用了application/json,那么浏览器就会先发一个OPTIONS请求,请求头里会带Access-Control-Request-Method和Access-Control-Request-Headers。

后端拿到这个OPTIONS请求,需要正确返回允许的方法和允许的请求头。如果后端没有处理OPTIONS,或者某个拦截器把OPTIONS也给拦截了,那浏览器就认为跨域不被允许,真正的请求压根不会发出。这就是为什么明明理论上“只要配置了CORS就行”,却总又多了很多莫名其妙的拦截器问题。

1.3 定位跨域问题的三条线索

遇到跨域报错,先别急着加代码。我一般先打开浏览器开发者工具,看Network里那条失败的请求,分三步判断:

第一步,看响应头里有没有Access-Control-Allow-Origin。如果没有,说明后端根本没走到CORS配置,可能被过滤器或拦截器提前拦截了。

第二步,看请求是不是OPTIONS。如果是,并且OPTIONS的状态码不是2xx,那基本就定位了:预检请求没通过。

第三步,在命令行里用curl直接请求后端接口,例如:

curl -i -X OPTIONS http://localhost:8080/api/user \ -H "Origin: http://localhost:5173" \ -H "Access-Control-Request-Method: GET"

如果curl返回的响应头正常,唯独浏览器里报错,那问题就集中在浏览器端拿不到正确的CORS响应头,顺着这条线查,比无头苍蝇一样百度和改配置高效得多。

2. 方式一:@CrossOrigin注解——最轻量的局部放行

2.1 在Controller上的标准写法

@CrossOrigin是Spring MVC从4.2版本开始内置的注解,专门用来给单个Controller或单个方法配置跨域。用法很简单,直接加在类上:

@CrossOrigin(origins = "http://localhost:5173", maxAge = 3600) @RestController @RequestMapping("/api/order") public class OrderController { // ... }

也可以只给某一个接口方法加:

@CrossOrigin(origins = "http://admin.example.com") @GetMapping("/detail") public Result getOrderDetail(@RequestParam Long orderId) { return orderService.getDetail(orderId); }

如果不填origins参数,默认是允许所有来源,等价于origins = "*"。但要注意,一旦你设置了allowCredentials = true又要用通配符*,在Spring 5.3之后会直接启动报错或者运行时抛异常,原因后面专门讲。

2.2 注解的边界:只对当前Controller有效

这个方式的优点是很轻,加一行就能搞定,适合单个接口联调。但缺点同样明显:它只能作用于你加了注解的Controller或方法上。

如果项目里有几十个Controller,想在每个Controller上都加一遍注解,不仅代码看起来冗余,还特别容易漏。更尴尬的是,有些接口不是你自己写的,比如公司内部的公共SDK、依赖第三方jar包提供的Controller,你根本没法改它的源码,这时候注解方式就直接失效了。

另外一个容易被忽略的问题:@CrossOrigin本质上是走Spring MVC的AbstractHandlerMapping和HandlerMethod机制来完成配置的,如果请求还没进入Controller就被Servlet层面的过滤器拦截了,注解再怎么写也帮不上忙。尤其是后面要讲到的登录拦截器拦截OPTIONS的场景,注解方式根本救不了你。

所以我的结论很明确:单模块Demo、临时联调、独立小接口,可以用@CrossOrigin;凡是项目要长期迭代、接口数量超过二十个,就别指望注解能扛住跨域这个全局性问题。

2.3 类上和方法上同时有注解时,方法级覆盖类级

这里还有个细节,@CrossOrigin可以同时出现在类和方法上。如果两个都写了,方法上的注解会覆盖类上的注解,生效的是方法上的配置。这一点我见过有人踩坑:类上配了origins = "http://a.com",某个方法上配了origins = "http://b.com",结果那个方法只允许b.com访问,而团队的人以为继承了类配置,a.com也能访问,最后联调时白白排查了半天。

所以用法上我给个建议:如果确实要在一个Controller里同时开放多个来源的差异化跨域,方法级覆盖反而是一个可用的手段;但如果不是出于这个目的,最好统一只在类上写一次,避免认知偏差。

3. 方式二:全局CORS配置——WebMvcConfigurer是我的默认首选

3.1 实现addCorsMappings的配置代码

如果项目不需要Spring Security,那我最常用的就是实现WebMvcConfigurer接口,重写addCorsMappings方法。这是Spring Boot里最正统的全局CORS配置方式,一份配置,全项目生效。

直接看代码:

@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowCredentials(true) .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") .allowedHeaders("*") .exposedHeaders("Content-Disposition") .maxAge(3600); } }

这份配置一加,所有路径下的接口都会在响应里带上CORS相关的头。addMapping("/**")表示拦截所有路径,你也可以改成/api/**或者/admin/**,按路径粒度控制放行范围,这在公司内部有多套管理端和用户端时很好用。

3.2 参数逐一拆解:别只抄配置不看含义

很多人网上抄一份配置就跑,一旦出问题就抓瞎。我把这里几个关键参数掰开说一下。

  • allowedOriginPatterns:允许的来源。支持通配符*,也支持https://*.example.com这种域名通配。注意不是allowedOrigins,后者在Spring 5.3之后如果和allowCredentials(true)一起用会出现兼容问题。
  • allowCredentials(true):是否允许跨域请求携带Cookie。如果你的接口依赖JSESSIONID或者登录态Cookie,这里必须要设成true。
  • allowedMethods:允许的HTTP方法。因为预检请求会直接问“允许哪些方法”,所以这里务必把OPTIONS带上,最好把项目里常用的GET、POST、PUT、DELETE、PATCH都写上。
  • allowedHeaders:允许的请求头。最省事的方法是写"*",但如果你用了某些自定义头又不想全放,可以列出来,比如"Content-Type, Authorization, X-Requested-With"。
  • exposedHeaders:允许前端读取的响应头。默认情况下,浏览器里的JavaScript只能读取少量基本响应头。如果后端返回了自定义头,比如文件下载时常用的Content-Disposition,前端想要通过response.headers.get('Content-Disposition')拿到它,就必须在这里显式声明。
  • maxAge:预检请求结果可以缓存多少秒。设置成3600,就意味着一小时内浏览器不会重复发送OPTIONS预检,直接复用首次的预检结果,能显著减少无效请求。

3.3 一个踩过的坑:拦截器把OPTIONS请求拦掉了

这个方法在普通Spring MVC项目里很灵,但在到处是拦截器的项目里容易翻车。我遇到过最典型的场景是这样的:项目里有一个登录拦截器,对所有/api/**请求做Token校验,校验不通过就返回401。

浏览器跨域预检的OPTIONS请求不会携带业务Token——因为预检请求的目的只是问“能不能跨域”,属于浏览器发起的“探测”,不带业务数据。结果这个OPTIONS请求一进后端,登录拦截器一看没有Token,直接返回401。响应头里自然也没有Access-Control-Allow-Origin,浏览器就判定跨域失败,真正的业务请求根本不会发出。

解决方式也很简单,在登录拦截器的preHandle方法里先放行OPTIONS请求:

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } // 其他业务校验逻辑 }

或者注册拦截器的时候直接排除掉OPTIONS请求的路径:

registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/login");

这个坑很隐蔽,因为表面上看你确实写了CORS全局配置,但被拦截器一挡,配置根本走不到响应阶段。日志里甚至能看到OPTIONS请求进来了,可前端就是报跨域。记住这个经历,排查效率会高很多。

4. 方式三:CorsFilter配合Spring Security——有安全框架时别绕路

4.1 为什么加了Spring Security后,前两种方式会失效

如果你的Spring Boot项目里集成了Spring Security,前面说的WebMvcConfigurer全局配置往往会失效。原因要从请求处理顺序说起。

Spring Security是基于Servlet过滤器链运行的,它在请求进入Spring MVC的DispatcherServlet之前就已经把请求拦截下来做了安全处理。CORS的响应头如果只在Spring MVC层通过addCorsMappings配置,安全过滤器链并不知道这件事。安全过滤器如果发现请求未认证,直接返回401/403,这个时候响应头里的CORS信息根本还没加上,浏览器就只看到一片红色跨域错误。

这就是很多人在Security项目里反复加WebMvcConfigurer配置却始终没效果的根本原因。要跨域配置在Security环境里生效,得让CORS过滤器加入Security的过滤器链,并且提前处理好预检请求。

4.2 标准写法:CorsConfigurationSource + http.cors()

正确姿势是先声明一个CorsConfigurationSource的Bean,然后在Security的过滤器链配置里显式启用CORS。

@Configuration public class CorsConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOriginPatterns(Collections.singletonList("*")); config.setAllowCredentials(true); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(Collections.singletonList("*")); config.setExposedHeaders(Collections.singletonList("Content-Disposition")); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }

然后在Spring Security配置里这样写:

@Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); return http.build(); }

关键就是.cors(Customizer.withDefaults())这行。它的作用是从Spring容器里找CorsConfigurationSource,如果找到了,就用它来构建CORS过滤器,并且把过滤器放到安全过滤器链的靠前位置。这样一来,预检请求就能在进入后续安全拦截逻辑之前得到正确响应。

如果你的项目还在用旧版本Spring Security,也可以写成.cors().and()这种链式写法,但新版我更推荐Customizer.withDefaults()这种函数式风格。

4.3 为什么不能用allowedOrigins("*")和allowCredentials(true)并存

这个坑几乎每个从老项目升级过来的人都会遇到。在Spring 5.3之前,很多人写:

config.setAllowedOrigins(Collections.singletonList("*")); config.setAllowCredentials(true);

看着没毛病,但浏览器实际上不允许Access-Control-Allow-Origin: *和Access-Control-Allow-Credentials: true同时出现。因为一旦*代表任意来源都可访问,再搭配允许携带凭证,浏览器就不知道把凭证发给谁,被明确禁止。

Spring 5.3之后直接在容器启动阶段就会抛出异常,提示When allowCredentials is true, allowedOrigins cannot contain the special value "*"。所以要分开处理:如果允许所有来源,并且需要跨域携带Cookie,就改用allowedOriginPatterns("*")而不是allowedOrigins("*")。allowedOriginPatterns会用匹配算法判断来源,但实际返回的Access-Control-Allow-Origin会具体到某个匹配的来源上,而不是直接给*,这样浏览器才认账。

4.4 手动注入CorsFilter的备用方案

补充一种备用写法:如果你不想依赖http.cors(),也可以直接注册一个CorsFilterBean。

@Bean public CorsFilter corsFilter(CorsConfigurationSource source) { return new CorsFilter(source); }

在这种写法下,CORS过滤器会作为独立的Servlet过滤器直接注册到过滤器链中。不过要注意,在Spring Security环境里,如果你没有显式调用http.cors(),CorsFilter和Security过滤器的执行顺序未必符合预期。我自己的习惯是优先使用CorsConfigurationSourceBean +http.cors()的组合,这个组合能保证CORS过滤在安全过滤之前执行,省心很多。

5. 方式四:nginx反向代理——架构层一劳永逸的终极大法

5.1 反向代理是怎么“消灭”跨域的

前面三种方式,本质都是后端增加CORS响应头,让浏览器放行。第四种方式思路完全不同:既然跨域是因为前后端Origin不一致,那我干脆让浏览器看到的请求和目标处于同一个域里面。

做法很简单:前端页面部署在nginx上,所有请求都直接请求到nginx,比如页面地址是http://example.com,前端请求也发到http://example.com/api/xxx,浏览器一看请求目标和页面同源,完全不存在跨域。nginx再把/api/开头的请求反向代理到真正的后端Java服务上。

这是一次“架构级”解决,不是改一行注解那么简单,但一劳永逸。尤其在生产环境里,前端和后端往往本来就是分开放的,nginx转发正好天然解决了跨域和静态资源托管的问题。

看一份最基础的配置:

server { listen 80; server_name example.com; location /api/ { proxy_pass http://127.0.0.1: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; } }

这里有个细节容易出错:location /api/后面的路径和proxy_pass末尾的斜杠会共同决定转发路径。http://127.0.0.1:8080/末尾带斜杠,意味着会把/api/这个前缀去掉再转发。比如请求/api/user/list会被转发到http://127.0.0.1:8080/user/list。如果你希望后端接口本身也带/api/前缀,就要去掉斜杠:

proxy_pass http://127.0.0.1:8080;

这个坑不处理好的话,转发后的接口路径直接404,而且报错信息还很绕,排查半天才发现是少写了一个斜杠。

5.2 如果后端不想动,就在nginx层补CORS响应头

有时候后端服务是外部团队维护的,没法改Java代码,此时nginx还能再帮一个忙:直接由nginx在响应上补CORS头。

location /api/ { add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Credentials true 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:8080/; }

这串配置写完整了,我再解释几个细节为什么这么写。

Access-Control-Allow-Origin用$http_origin而不是*,是为了能和Access-Control-Allow-Credentials: true共存。浏览器要求配合凭证时,Allow-Origin必须明确写出来源而不能是通配符,$http_origin会动态取当前请求的Origin,刚好满足。

add_header后面的always参数也很关键。不加always,nginx只在正常2xx响应里输出这些头,一旦后端返回4xx或5xx,add_header就失效了,前端依然报跨域。加上always后,任何状态下都会把CORS头带出来。

最后那个if ($request_method = 'OPTIONS')是直接拦截预检请求,返回204,不打到后端。这么做的好处是预检请求不消耗后端资源,也不受后端逻辑影响,纯粹就是浏览器一个探路请求。

5.3 nginx方案的优缺点:不是所有环境都需要

nginx方案的优势很明显:不侵入Java代码,一套配置同时解决跨域、静态资源托管、HTTPS卸载、负载均衡等问题。比如同一台nginx后面挂着多个微服务,前端不用关心每个服务地址,只需要按路径规则访问nginx即可。

缺点也有。首先本地开发通常不会起nginx,全靠本地直连后端调试,所以跨域主要靠后端配置解决。其次,生产环境如果只靠nginx转发,后端日志里看到的所有请求来源都变成了nginx地址,需要额外配置X-Forwarded-For之类的头才能拿到真实IP。再者,如果你们公司已经有网关层,比如Spring Cloud Gateway,那跨域配置放在网关层可能比nginx更合适,本质思路是一样的:多个入口统一到一个面,对外只有网关一个Origin。

所以我通常在微服务架构里把跨域解决在网关层,前端只认网关地址,各微服务不再需要各自配CORS;在传统前后端分离部署里则用nginx。这个一定要根据团队现有的基础设施来定,不要机械照搬。

6. 四种方式怎么选:不只看代码,还要看部署环境

6.1 一张表看明白适用场景

到了这里,四种方式全部讲完了。很多人会有个误解:只要代码能跑就是好方案。其实不是,选哪种方式更多要看你处于什么阶段、有没有安全框架、部署架构长什么样。

方式生效范围依赖组件是否兼容Spring Security适合场景
@CrossOrigin注解单个Controller/方法Spring MVC不推荐,容易失效本地调试、少数接口临时放行
WebMvcConfigurer全局配置全项目/指定路径Spring MVC会被安全过滤链拦截普通前后端分离项目、无Security环境
CorsFilter + CorsConfigurationSource全局过滤器Spring Web,可在Security链上生效推荐集成了Spring Security的项目
nginx反向代理入口层,不依赖Javanginx完全兼容生产环境前后端分离、微服务网关

这张表是我自己的选型习惯,不是绝对标准。比如小型外网API服务既没Security也不用nginx,那全局WebMvcConfigurer就够了;但如果项目有Security,我几乎不会去依赖前两种。

6.2 不同项目阶段的建议

这几种方式不要看成互斥选项,完全可以组合使用。我说一下我的实践习惯。

本地开发阶段,项目还没上nginx,我会直接用全局WebMvcConfigurer配置放开跨域,保证每个接口都能被前端本地访问到。如果项目有Security,就一上来就配好CorsConfigurationSource,别等联调出问题再补。

到部署阶段,生产环境统一走nginx,前端所有请求相对后端都是同源。这个时候即使后端还留着全局CORS配置,也不冲突,因为请求根本没跨域,CORS头形同虚设。但我会建议把后端CORS配置保留着,毕竟以后本地联调还得用。

如果项目是微服务架构,apigw这个入口,那跨域配置放网关层最重要。网关负责把外部请求映射到各个服务,浏览器看到的永远是网关地址,微服务内部的互相调用压根没有浏览器参与,也就没有跨域问题。

再补一句:用了Spring Cloud Gateway时,你可以在网关的application.yml里配置spring.cloud.gateway.globalcors,效果类似于全局CORS配置,但作用在网关的转发逻辑上。这种方案和nginx本质相同,我就不展开写了。

6.3 配置完仍报错时的排查清单

最后,给你一份我排查跨域问题时的顺序清单。遇到这种报错,照着这个顺序走一遍,大多数问题都能找到位置。

第一步,看Network面板里失败请求的响应头,确认有没有Access-Control-Allow-Origin。没有,进入第二步;有,但前端仍然报错,检查是否是因为allowCredentials与来源不匹配。

第二步,看请求方法是否为OPTIONS,并看OPTIONS的状态码是不是2xx。不是2xx,重点查拦截器、过滤器、Security配置有没有把OPTIONS拦掉。

第三步,用curl模拟跨域请求:

curl -i -X OPTIONS http://localhost:8080/api/user \ -H "Origin: http://localhost:5173" \ -H "Access-Control-Request-Method: GET" \ -H "Access-Control-Request-Headers: Content-Type,Authorization"

如果curl返回正常,浏览器反而不行,那大概率是浏览器缓存了预检结果,可以勾选Network面板里的Disable cache再试。

第四步,检查是否有nginx或网关在响应链上覆盖了后端的CORS头。比如后端明明返回了Access-Control-Allow-Origin: http://localhost:5173,nginx又加一个Access-Control-Allow-Origin: *,两个值冲突,浏览器认后一个,然后照样报错。

第五步,确认前端的请求地址是不是真的跨域。同一域名的不同路径不算跨域,很多人把http://localhost:5173和http://localhost:5174搞混,或者理直气壮说“我域名一样的啊”,结果端口不一样,一样跨域。

我自己在做项目时有个习惯:跨域属于“地基性”问题,越早全局处理越好,不要今天加个注解明天改个配置。先看清项目有没有Spring Security,再看部署有没有nginx或网关,然后一次性把对应的全局方案配到位。真正做对了,跨域这个东西是可以做到项目里“再也没有出现过”的。

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

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

立即咨询