前一阵子有个读者跑来问我:后端接口在 Swagger 里测试全通,前端一联调就报跨域,后端代码里@CrossOrigin也加了,为什么还是不行?我让他把 Spring Security 的过滤器链截图发过来,一看就明白了。
SpringSecurity 里的跨域,跟普通 SpringBoot 项目的跨域配置有本质区别。如果你只会在 Controller 上加注解、或者配一个WebMvcConfigurer,在 Security 环境下大概率会掉坑里。这篇文章我把这些年处理 SpringSecurity 跨域问题的经验、代码、踩坑记录全部整理出来,从同源策略讲到过滤器链顺序,从@CrossOrigin失效原因讲到生产环境最推荐的配置方式,保证你看完能直接上手。
1. 跨域的根子在哪,SpringSecurity 为什么总会“插一脚”
1.1 同源策略不是后端限制,是浏览器行为
很多人把“跨域报错”当成后端的问题,其实这是浏览器做的安全校验。所谓“同源”就是协议、域名、端口三者完全一致,只要有一个不同,浏览器就会认为这是跨域请求。
举例来说,前端页面跑在http://localhost:8080,后端接口跑在http://localhost:9090,端口不同,属于跨域;前端http://a.com请求http://b.com,域名不同,也属于跨域。浏览器发起这些请求后,如果响应头里没有携带正确的 CORS 字段,浏览器就会把响应“拦截”下来,控制台报No 'Access-Control-Allow-Origin' header is present。
这里有个关键点:请求可能已经到达后端,后端也可能已经正常处理并返回了结果,但浏览器不让 JS 读取这个结果。这就是为什么你用 Postman、Swagger 测试时一切正常,一到浏览器里就报跨域的原因——因为那些工具不走浏览器的同源策略校验。
1.2 简单请求和预检请求,怎么区分
CORS 请求分成两类,处理方式不一样:
- 简单请求:请求方法只允许
GET、POST、HEAD,且 Content-Type 只能是application/x-www-form-urlencoded、multipart/form-data、text/plain中的一种,同时不能带自定义请求头。这类请求浏览器会直接发出去,响应时检查 CORS 头。 - 预检请求:只要带自定义 Header(比如
Authorization)、或者 Content-Type 是application/json、或者方法不是简单方法,浏览器就会先发一个OPTIONS请求,问服务器“允不允许我这么干”,服务器返回允许后,浏览器才会发真正的业务请求。
前后端分离项目里,POST + JSON 是常态,还要带 Token,所以几乎所有请求都会触发预检。如果预检请求没有正确响应,浏览器根本不会发后续的真实请求。
1.3 Security 过滤器链在 CORS 处理之前就把请求拦了
这是 SpringSecurity 跨域问题最核心的根源。
Spring MVC 处理跨域,实际是在DispatcherServlet分发之后,通过HandlerInterceptor或@CrossOrigin注解在方法执行前给响应设置 CORS 头。但 Spring Security 是 Servlet 容器层面的过滤器链,它比你整个 MVC 链路都要靠前。
想象一下这个执行顺序:
浏览器预检请求进来 -> Spring Security 过滤器链(认证、授权逻辑) -> 如果没通过,直接返回 401/403,根本走不到 DispatcherServlet -> 响应头里没有 CORS 字段 -> 浏览器判定跨域失败。
也就是说,当你的接口需要登录才能访问时,Spring Security 会在你跨域配置还没有机会执行之前就返回了错误响应。前端看到的是 CORS 报错,但本质是接口已经被 Security 拦截了,只是拦截后的响应缺少 CORS 头,浏览器把这口锅算在了“跨域”头上。
明白了这个链路,你就能理解为什么@CrossOrigin在 SpringSecurity 环境下经常会失灵。
1.4 后端跨域和前端跨域,到底该谁来解决
关于跨域,业界一直有两种方向:后端配置 CORS 响应头,或者前端通过代理(比如 Nginx 反向代理、Vue DevServer Proxy)让浏览器觉得请求是同源的。
两种方案都是合法的,但如果你后端接了 Spring Security,我建议优先走后端正确配置 CORS,而不是教前端去开代理。原因很简单:你无法控制所有调用方的环境,有时候是 H5 原生页面直接调接口,有时候是第三方系统内置 WebView 调接口,前端代理在这些场景下不一定可用。后端把 CORS 响应头配好,所有浏览器环境都能兼容。
JSONP、iframe + postMessage 那些方案我也在后文提一下,但不是首选,因为 JSONP 只支持 GET、无法处理 RESTful 风格接口,在现在的项目里基本是历史方案了。
2. 基础跨域配置为什么在 Security 下不生效
2.1 @CrossOrigin 注解的适用边界
@CrossOrigin可以直接加在 Controller 类上或方法上,是很多人最先接触到的跨域配置。比如:
@RestController @RequestMapping("/api/user") @CrossOrigin(origins = "http://localhost:5173") public class UserController { @GetMapping("/info") public Result<UserInfo> info() { return Result.success(userService.getInfo()); } }这个配置的本质,是在当前 Controller 处理的响应上添加 CORS 响应头。它能解决的问题是:请求能到达这个 Controller 且没有被更前置的拦截器拦截的情况下,给响应补上跨域头。
问题就出在这里。Spring Security 的过滤器链在整个 MVC 链路之前,如果你的接口在 Security 那边就被判定为未认证、未授权,根本就不会走到 Controller,@CrossOrigin自然没有机会执行。你接口返回 401 时,响应体里没有任何 CORS 头,浏览器直接报跨域错误。
所以@CrossOrigin只适合两种情况:一是项目没接 Spring Security,二是接口本身就是匿名可访问的路径(Security 放行了)。一旦接口进入 Security 的保护范围,它就成了摆设。
2.2 WebMvcConfigurer 全局配置,同样只能处理 MVC 层
如果不喜欢在 Controller 上一个个加注解,很多人会写一个全局配置:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("http://localhost:*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }这个配置在普通 SpringBoot 项目里是好用的,因为它注册到了 HandlerMapping 的 CORS 处理逻辑里。但只要 Spring Security 在前面拦截了请求,你配的这套东西同样没有执行机会。
我在实际项目里见过有人加了这个配置,又在 Security 里做了requestMatchers("/api/**").permitAll()放行,结果接口能通,就误以为是这个配置生效了。后来某个接口需要登录权限,一收紧又跨域,他才发现根本不是这个配置的功劳。
2.3 失效的核心:过滤器链顺序
把上面的问题归结成一句话:Spring Security 作为 Servlet Filter 运行在 DispatcherServlet 之前,它有权在 CORS 处理逻辑执行前结束请求。
图示大概是这样的:
浏览器请求 -> Tomcat 容器 -> springSecurityFilterChain -> [认证过滤器] -> [授权过滤器] -> 若通过 -> DispatcherServlet -> HandlerMapping CORS 处理 -> Controller -> 响应 -> 浏览器。
@CrossOrigin和WebMvcConfigurer的 CORS 处理都发生在 DispatcherServlet 之后的环节。所以要让跨域真正生效,必须把 CORS 处理的“站位”提前,放到 Spring Security 过滤器链之前或者链内靠前位置。
这也是整篇文章最核心的方法论:跨域配置要配合 Security 过滤器链的层级去处理,而不是只盯着 MVC 那一层。
3. 正确姿势:在 SpringSecurity 过滤器链上挂 CORS
3.1 核心配置:CorsConfigurationSource 和 http.cors()
Spring Security 从 4.2 开始就原生支持 CORS,正确做法是提供一个CorsConfigurationSourceBean,然后在HttpSecurity配置里调用http.cors(),Security 会把它内置的CorsFilter放到过滤器链的靠前位置,在认证、授权之前就把 CORS 头写好。
先看一个完整的配置类:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpHeaders; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.List; @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 启用 CORS,会从容器中查找 CorsConfigurationSource Bean .cors(cors -> {}) // 跨域+无状态Token场景下,建议关闭 CSRF,理由后面讲 .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/error").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()); return http.build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); // 这里不要用 *,后面说明原因 config.setAllowedOriginPatterns(List.of( "http://localhost:*", "https://your-domain.com" )); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }重点看.cors(cors -> {})这一行。它做两件事:第一,向过滤器链注册一个CorsFilter,位置在认证过滤器之前;第二,自动从 Spring 容器中查找CorsConfigurationSource类型的 Bean 来填充 CORS 策略。
这意味着 CORS 响应头会在 Spring Security 做权限校验之前就写入响应。即使请求因为未认证被拦截,返回的 401/403 响应里也会带Access-Control-Allow-Origin等头,浏览器就能正常识别错误,而不是误报跨域。前端此时看到的才是 401,而不是那一堆 CORS error。
3.2 为什么 allowedOrigin 不建议用 *,以及 allowedOriginPatterns 的作用
如果你把allowedOrigins直接配成*,同时allowCredentials(true),在 Spring 5.3 以上版本会直接抛出异常:
When allowCredentials is true, allowedOrigins cannot contain the special value "*"原因很好理解:Access-Control-Allow-Origin: *表示允许任意源携带请求,但不能配合Access-Control-Allow-Credentials: true使用,否则浏览器出于安全考虑会直接拒绝响应。而带 Cookie 的跨域场景几乎都需要allowCredentials(true)。
allowedOriginPatterns是 Spring 5.3 引入的替代方案,允许使用通配符模式匹配 Origin,同时兼容allowCredentials(true)。它会根据实际请求的 Origin 动态生成具体的Access-Control-Allow-Origin响应头,而不是简单返回*。
所以我的建议是:能用具体域名就用具体域名,多个环境可以写多个 allowedOriginPatterns。实在要通配,就配allowedOriginPatterns("*"),而不是allowedOrigins("*")。
3.3 到底要不要关闭 CSRF
跨域配置经常会连带遇到 CSRF 的问题。默认情况下 Spring Security 开启 CSRF 防护,对 POST、PUT、DELETE 等修改类请求要求携带 CSRF Token。前后端分离后,如果你通过 Cookie 之外的 Header 传递 Token,且不使用 Session,那 CSRF 防护的意义就很弱,反而会导致每一次 POST 请求都报 403 Forbidden。
很多团队在引入 SpringSecurity 做 JWT 登录后会执行csrf(csrf -> csrf.disable()),这在跨域+无状态 Token 的场景下是合理的。因为 CSRF 防御的核心逻辑是基于 Session Cookie 的身份认证,你已经不用了,继续开反而处处碰壁。
如果出于某些原因必须保留 CSRF(比如部分老功能依赖 Session),你至少要保证跨域预检请求不受 CSRF 影响。可以在 Security 里对OPTIONS请求放行:
.authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/auth/**").permitAll() .anyRequest().authenticated() )同时,如果使用 Cookie 传递 CSRF Token,跨域下还需要配合CookieCsrfTokenRepository来写 Cookie,复杂度会上升。我的建议是除非有硬性合规要求,否则 JWT + 无状态接口直接禁用 CSRF,简单可靠。
3.4 Spring Security 6.x 与 5.x 的写法差异
上面的示例是 Spring Security 6.x / Spring Boot 3.x 的写法。如果你还在用 Spring Boot 2.x / Security 5.x,有几处差异要注意:
authorizeHttpRequests在 5.5 之前是authorizeRequests,写法是.antMatchers(...)。csrf().disable()在 6.x 里改成了csrf(csrf -> csrf.disable()),5.x 可以直接链式调用。- 5.4 之前
SecurityFilterChain的写法可能要求继承WebSecurityConfigurerAdapter,这是旧版最常见的一种写法。
遇到报错时,先看你的 Spring Boot 版本,然后对症调整,不要照抄网上的代码直接跑,版本不匹配的问题很常见。
4. 兜底方案和几种特殊场景
4.1 自定义 CorsFilter 挂到最前面
就算你正确配置了CorsConfigurationSource,在某些极端情况下还是可能出问题,最常见的是:项目里引入了其他全局过滤器,或者 Security 版本太老不支持http.cors()。这时候可以自己写一个CorsFilter放最前面。
import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class CustomCorsFilter extends CorsFilter { public CustomCorsFilter() { super(configurationSource()); } private static UrlBasedCorsConfigurationSource configurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOriginPatterns(java.util.List.of("*")); config.setAllowedMethods(java.util.List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(java.util.List.of("*")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }注意类上的@Order(Ordered.HIGHEST_PRECEDENCE),目的是让这个过滤器优先于 Spring Security 的过滤器链执行。Spring Boot 会把它注入到过滤器链最前端,这样任何请求(包括被 Security 拦截的请求)都会先经过 CORS 处理。这个方式跟方案二并不冲突,可以一起用,也可以单独用。
不过我要提醒一下:这属于兜底方案,正常项目建议优先用http.cors()+CorsConfigurationSource,因为那样 CORS 策略完全由 Spring Security 管理,配置集中、可维护性高。自定义 Filter 适合老项目改造或者几个过滤器之间顺序不好控制的情况。
4.2 OPTIONS 预检请求怎么放行
跨域请求的预检是OPTIONS方法,很多时候报错不是因为 CORS 配置不对,而是OPTIONS请求本身在 Security 层面被拦截了。
如果你用的是方案二(http.cors()),Spring Security 内置的CorsFilter实际上会直接拦截并处理OPTIONS预检请求——检测到是预检后,它会返回 200 并写入 CORS 头,不再往下传递。但如果你用了自定义 Filter 或者 Security 配置里还有额外拦截器,就要小心预检请求被拦截。
保险起见,在authorizeHttpRequests里对OPTIONS做放行:
.authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() )这段配置对任何情况都无害,加了之后能省很多排查时间。
4.3 反向代理和网关场景下的 CORS 该在哪一层配
现在服务化架构很常见,前端请求通常会先经过 Nginx 或 Spring Cloud Gateway 再转发到后端服务。这时候跨域处理位置有讲究。
如果网关层做了统一 CORS 配置(例如 Nginx 的add_header,或 Gateway 的GlobalCorsConfiguration),那么后端服务本身可以不配 CORS。这是因为浏览器实际请求的地址是网关地址,只要网关返回 CORS 头即可。反向代理的本质是让浏览器认为请求发生在同源下,如果代理配置正确,甚至不会有跨域问题。
如果网关没有配 CORS,请求落到后端服务,那后端 SpringSecurity 配置的 CORS 依然要生效,但要注意响应头不能重复。我曾经遇到一个项目,Nginx 配了add_header Access-Control-Allow-Origin *,后端 SpringSecurity 也配了 CORS,结果浏览器同时收到了两个不同值的同名响应头,导致解析失败,报错内容特别隐蔽。
这类问题的排查方式是打开浏览器 F12 看响应头,如果Access-Control-Allow-Origin出现两次,就是多级都加了 CORS 配置。要么去掉一层,要么保证每层的 Header 值一致。
4.4 iframe、JSONP、3D 模型等历史方案的边界
在自然延伸一下其他跨域场景。有些人还在用 JSONP 解决跨域,它只能发 GET 请求,并且需要后端接口改成 JSONP 回调格式,我这边用到它的场景基本只有对接老系统的接口。iframe 跨域的问题靠的是postMessage通信,适合嵌入第三方页面做消息传递,而不是作为接口调用的替代方案。
前端绘图、加载 3D 模型这块也经常踩跨域坑。比如用 Three.js 加载外部 3D 模型、使用 wasm 模块、加载远程图片纹理时,如果静态资源服务器没有返回 CORS 头,浏览器照样会拦截。像 l-painter 这类 canvas 绘制库请求远程图片资源时也会遇到同样的问题。这类问题跟 SpringSecurity 无关,解决思路是给静态资源服务配 CORS,或者通过本地代理转发,让它变成同源请求。
5. 高频踩坑与排查清单
5.1 前端一调用就报 No 'Access-Control-Allow-Origin' header
最常见。按以下顺序排查:
- 看请求到底有没有发到后端。F12 Network 里如果看到
OPTIONS状态是 200,但POST状态是 403 或 401,说明后端 Security 拦截了;如果OPTIONS本身就报错,说明 CORS 配置没生效。 - 用 curl 模拟带 Origin 的请求,直接看响应头:
curl -i -X OPTIONS http://localhost:8080/api/user/info \ -H "Origin: http://localhost:5173" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Authorization,Content-Type"重点看返回的响应头有没有Access-Control-Allow-Origin。如果有,说明 CORS 配置没问题,问题在后续的认证授权流程;如果没有,说明配置还没生效。
- 检查 Spring Security 配置里有没有写
.cors(cors -> {}),只写了CorsConfigurationSourceBean 是不够的,必须在过滤链里开启。
5.2 allowCredentials(true) 与 allowedOrigins("*") 冲突
报错一般是:
java.lang.IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*"把setAllowedOrigins(List.of("*"))改成setAllowedOriginPatterns(List.of("*"))即可。如果你只需要支持固定域名,直接写具体域名更好。
5.3 预检请求返回 200,但真实请求永远是 401/403
这种情况最迷惑人,因为OPTIONS是 200,浏览器认为跨域规则没问题,但业务请求还是被 Security 拦截了。
原因一般是:你的接口需要认证,前端在OPTIONS通过后发起真实请求时带了Authorization头,但 Token 过期、格式不对、或者 Security 的 filter 没有正确解析 Token。这是 SpringSecurity 本身的问题,不是 CORS 的锅。
排查方式:F12 看真实请求的响应体,里面写的是Authentication token was expired还是别的什么。如果响应体确实带了业务 JSON,说明 Security 已经放行,那就是 CORS 处理的问题;如果响应体是 Security 默认的AuthenticationExceptionJSON,问题在认证链路。
5.4 联调环境正常,一上生产就跨域
这种情况通常是生产环境和开发环境的域名不一致。开发环境前端可能是http://localhost:5173,生产环境变成了https://admin.example.com,而后端配置里只允许了 localhost。
排查方法:看生产请求的Origin头是什么,然后跟后端 CORS 配置里allowedOriginPatterns是否匹配。如果你的配置里写了http://localhost:*,那就把生产域名也加进去。更稳妥的做法是配置成动态从配置文件读取:
@Value("${app.cors.allowed-origins:http://localhost:*}") private String[] allowedOrigins;部署时按环境注入,不会出现代码写死域名的问题。
5.5 火狐和其他浏览器的本地限制
火狐浏览器在本地开发时也经常出现跨域问题,尤其是使用file://协议打开 HTML 页面时,浏览器对跨域限制更严格。Chrome 和 Edge 相对宽松,有时候本地开发起来能通,火狐就报跨域。
我的建议是本地开发不要用file://直接打开页面,而是通过前端开发服务器的http://localhost:xxxx访问。如果碰到火狐本地测试报跨域,优先考虑是不是把本地文件当成了 Origin,而不是急着给后端加配置。也尽量不要通过关闭浏览器安全策略来绕过跨域,这样会把本地环境搞得很奇怪,而且无法模拟真实用户的问题。
5.6 响应头出现重复的 Access-Control-Allow-Origin
如果你用了 Nginx 转发、Gateway 网关,或者同时配置了CorsFilter和http.cors(),就可能出现两个相同的响应头。浏览器会取两个值做对比,不一致时直接判定 CORS 失败。
排查方法:F12 看响应头的重复情况,找到哪一层加了重复配置,删掉一层。一般原则是:网关/Nginx 配了就不要再让后端配,后端配了就别让网关重复加。如果一定要都配,保证生成的Access-Control-Allow-Origin值完全一致。
6. 我的实际生产配置和一点经验
最后把我最常用的一套生产配置贴出来,算是做个总结。这套配置在 Spring Boot 3.x + Spring Security 6.x 上跑了两个项目,一直很稳定。
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(cors -> {}) .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/auth/**", "/actuator/health").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOriginPatterns(Arrays.asList( "http://localhost:*", "https://admin.example.com" )); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(Arrays.asList("*")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }几个关键点再强调一遍:
http.cors(cors -> {})是必选项,只定义CorsConfigurationSourceBean 不会自动生效。OPTIONS放行可以帮你免去预检请求被 Security 拦截的烦恼。allowedOriginPatterns代替allowedOrigins("*"),同时配合allowCredentials(true),Cookie 和前端自定义 Header 都能正常工作。- 前后端分离 + JWT 时,关闭 CSRF 是合理的,别被“CSRF 必须开启”的说法吓到。
踩过几次坑之后,我现在排查跨域问题的顺序已经很固定:先确认请求是否真的到达后端,再看响应头里 CORS 字段是否齐全,最后检查 Security 过滤器链顺序。前端报的 CORS 错误,八成是后端认证拦截导致的连锁反应,不要一上来就改 CORS 配置,先把 Security 的拦截逻辑捋清楚,很多问题就自然解开了。