前后端联调的时候,浏览器控制台突然冒出一整屏红色的报错,像这样:
Access to fetch at 'http://api.localhost:8080/user/info' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.第一反应是先上网搜错误码,搜出来的帖子十个里有八个让加个@CrossOrigin注解,另外两个让配CorsFilter。然后照着抄了一通,重启服务,发现有的接口好了,有的接口还在报;有的请求通了,带Authorization头的请求又断了。最后折腾半天才发现,问题根本不在“有没有配CORS”,而在“Filter到底放在了哪一层、配置类到底有没有被加载”。
这篇文章只讲一件事:Spring里用Filter方式解决跨域,到底该怎么写、怎么配、怎么排坑。我在项目里从Spring MVC一路用到Spring Boot,Filter也手动注册过、通过OncePerRequestFilter继承过、跟Spring Security打过架,踩过的坑比文档里写的内容多得多。这篇东西没有废话,全是可直接上手的配置、原理和排查经验。
1. 跨域报错的根源:同源策略与预检请求
1.1 为什么浏览器会拦你的请求
跨域的全称叫“跨源资源共享”,英文是Cross-Origin Resource Sharing,缩写CORS。这个机制说的是:浏览器在发起请求时,会检查当前页面所在的源(协议+域名+端口)和请求目标地址的源是否一致。如果两者不一致,就叫跨域。
这里的关键在于,跨域限制是浏览器的行为,不是服务器的行为。你的Spring后端其实已经把数据返回了,浏览器只是收到了响应之后,发现响应头里没有Access-Control-Allow-Origin,于是自行拦截,不让JavaScript读取。所以很多新手会奇怪:明明F12里能看到网络请求都是200,为什么前端就拿到不到数据?正是因为拦截发生在这两个环节之间。
我见过的最常见误解是把跨域问题当成“后端没返回数据”。实际上用curl或Postman去访问,一切正常,只有浏览器环境里才会报错。这说明服务端逻辑没问题,缺的是让浏览器放行的那几个响应头。
哪些请求会被浏览器特殊对待?简单说有三个特征:GET/HEAD/POST这三种方法、自定义Header不能随便加、Content-Type只能是text/plain、multipart/form-data或application/x-www-form-urlencoded。只要超出这个范围,浏览器就会先发一个OPTIONS预检请求(Preflight),试探服务器是否允许真实请求。这也是为什么很多接口在Swagger或Postman里调试没问题,一接前端就出状况。
1.2 Filter、Interceptor、注解,三条路线怎么选
Spring里解决跨域有几种手段:@CrossOrigin注解、WebMvcConfigurer里重写addCorsMappings方法、实现OncePerRequestFilter,以及直接注册CorsFilter。
@CrossOrigin注解最轻量,适合单接口快速调试。但缺点很明显:每个Controller都要加,接口一多就漏。addCorsMappings是Spring MVC层面的全局配置,对Controller接口生效,但如果请求在进入DispatcherServlet之前就被Filter拦截(比如Spring Security),这个配置就完全没用。Filter方式是最底层的,因为Filter在Servlet容器里就执行了,理论上能覆盖到所有请求,所以也是最稳妥的选择。
项目的演进路径通常是这样的:初期用@CrossOrigin快速验证,后来接口多了改addCorsMappings,再后来接入了Spring Security或者自定义了登录鉴权Filter,发现配置失效,最终转向CorsFilter这条路。这篇文章讲的CorsFilter,指的就是第三种方案,也是我最终在多个项目里沉淀下来的稳定做法。
2. CorsFilter的核心原理:一次请求在其中经历了什么
2.1 从注册到生效,CorsFilter的两种装配方式
Spring里实现CORS Filter的入口是org.springframework.web.filter.CorsFilter。这个类继承自OncePerRequestFilter,保证了每次请求时只执行一次过滤逻辑,避免多次包含过滤链导致的重复执行。使用它之前需要准备一个CorsConfigurationSource,通常用UrlBasedCorsConfigurationSource实现。
第一种方式是基于Java配置的显式注册。如果项目是Spring Boot,可以专门定义一个配置类:
@Configuration public class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://localhost:3000"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }第二种方式是在Spring Boot的配置文件里直接声明。Spring Boot对CORS有相对完善的自动配置,只用这些属性就能覆盖多数场景:
spring.web.cors.allowed-origins=http://localhost:3000 spring.web.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS spring.web.cors.allowed-headers=* spring.web.cors.allow-credentials=true spring.web.cors.max-age=3600不过application.properties里的这些配置最终也还是要通过CorsRegistration转成CorsConfigurationSource,再由内部的CorsFilter来生效。如果你不需要精细到路径级别的控制,这种方式省事;需要分路径定制策略时,还得回到Java配置。
2.2 请求匹配与包装:CorsFilter内部是怎么判断和响应预检的
CorsFilter的doFilterInternal方法里做的事情,可以简单拆成三步。
第一步,通过CorsProcessor去拿请求里的Origin头。如果请求里没有Origin,说明不是跨域请求,CorsFilter会直接放行,不添加任何CORS相关头。
第二步,按请求路径从CorsConfigurationSource里匹配对应的CorsConfiguration对象。比如你注册了/api/**的配置,那/api/user/list能命中,/static/js/app.js不会命中。这里要注意的是,路径匹配用的是Spring的AntPathMatcher语法,/**才是匹配所有路径的写法。很多人只配了/*,结果只匹配到一级路径,二三级路径直接失效——这是特别蠢又特别隐蔽的一个坑。
第三步,检查请求方法。如果是OPTIONS请求且带有Access-Control-Request-Method头,就认定为预检请求,直接调用preflightResponse处理。如果预检通过,由它返回200的响应并写上那些允许跨域的响应头;如果预检不通过,直接返回403,根本不会进入Controller。如果是普通GET/POST请求,就把CorsConfiguration里定义的允许跨域头复制到响应头上。
CorsFilter处理完之后,响应头里会出现这几个关键Header:
Access-Control-Allow-Origin:允许的来源Access-Control-Allow-Methods:允许的方法Access-Control-Allow-Headers:允许的自定义请求头Access-Control-Expose-Headers:允许前端JavaScript读取的响应头Access-Control-Max-Age:预检结果缓存时间,单位秒
理解了请求匹配机制,很多灵异问题都能解释通。比如为什么Filter配置了/api/**,根路径的接口还是报CORS错误,因为压根没走到Filter的匹配分支里。
3. 配置项逐一拆解:每个参数背后的真实含义
3.1 allowedOrigins与“*”的误区:Credentials才是罪魁祸首
这是CORS配置里最大的坑,我这里单独拿出来说。
很多人图省事,直接把allowedOrigins设置成*,表示允许所有来源访问。这个写法在部分场景下确实能用,但一旦跟allowCredentials(true)搭配,立刻失效。因为浏览器的规范明确要求:使用Credentials时,Access-Control-Allow-Origin不能是通配符*。服务器如果这样返回,浏览器直接拒绝响应。
什么叫Credentials?简单说就是请求带着Cookie或者Authorization头这种凭证信息。前端Axios里设置withCredentials: true,或者带了Authorization: Bearer xxx请求头,都属于这类。而这恰恰是现代前后端分离项目最常见的请求方式。
所以在生产项目里,我建议不要用*,而是明确列出允许的来源,或者动态从配置项里读取:
config.addAllowedOrigin("https://admin.example.com"); config.addAllowedOrigin("https://app.example.com");如果确实有多个环境(本地、测试、预发)共用一套代码,就把允许的来源放进环境配置文件里,用@Value注入。这样既不会踩*的坑,也不会在环境切换时手忙脚乱。
这里还有个容易被忽略的细节:有些浏览器对带端口和不带端口的源是分别识别的,http://localhost:3000和http://localhost是两个完全不同的源,都要显式声明。
3.2 allowedMethods、allowedHeaders与exposedHeaders
allowedMethods决定了允许哪些HTTP方法跨域。一般设置成*或列出GET,POST,PUT,DELETE,OPTIONS都行。但注意一个细节:如果前端请求方法不在列表里,预检请求就会直接失败。有些老项目只用POST和GET,safe起见可以设置*,反正不会被允许之外的方法命中。
allowedHeaders是另一个高频踩坑点。前端如果自己加了X-Token、X-Requested-With之类的自定义请求头,这个配置里必须包含对应头名,否则预检请求直接挂掉。设置成*能省事,但配合allowCredentials(true)时,同样存在不能使用通配符的限制。稳妥的做法是列全,除了常用的Content-Type,Authorization之外,把自己项目里自定义的头都写进去。
exposedHeaders和allowedHeaders是两个方向的东西。allowedHeaders控制浏览器能带哪些请求头,exposedHeaders控制前端JavaScript能读取到哪些响应头。默认情况下,前端脚本只能读取Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma这几个标准响应头。如果后端自定义了X-Total-Count这种业务响应头,不配置exposedHeaders,前端用response.headers['X-Total-Count']拿到的一定是undefined。
我遇到过一个真实场景:前端做表格分页,数据总数放在响应头X-Total-Count里,调试了半天取不到值,最后才发现是exposedHeaders没配置。这个点特别不容易排查,因为接口返回正常、网络面板里肉眼也能看到那个响应头,但JavaScript就是读不到。
3.3 maxAge:有百利而无一害的配置
maxAge指定的是预检请求结果在浏览器端能缓存多少秒。单位是秒,比如设置3600就是一小时。
为什么不设置就会拖慢请求?因为每次跨域请求发出前,如果预检结果没有过期,浏览器不会重新发起OPTIONS请求,直接跳过预检;一旦没有缓存,每个带自定义头的POST请求都会先来一次OPTIONS,接口延迟肉眼可见地增加,网络请求量也直接翻倍。
生产环境里建议设置一个合理的缓存时间,比如600秒或3600秒。它在绝大多数情况下只有好处没有坏处,除了偶尔改了CORS配置后预检结果不即时生效——清理浏览器缓存或者在DevTools里勾选Disable cache就能解决。
4. 实战配置多场景:标准版、容器版、Security版
4.1 标准Spring Boot集成方案
把第一节里那套Java配置补全成可落地的版本,需要注意的点都写在注释里:
@Configuration public class CorsConfig { @Value("${cors.allowed-origins:http://localhost:3000}") private String[] allowedOrigins; @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); // 显式声明来源,而不是使用通配符 for (String origin : allowedOrigins) { config.addAllowedOrigin(origin); } config.addAllowedMethod("*"); config.addAllowedHeader("*"); // 允许携带凭证(Cookie / Authorization) config.setAllowCredentials(true); config.setExposedHeaders(Arrays.asList("X-Total-Count", "Content-Disposition")); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } }在application.yml里维护允许的来源列表:
cors: allowed-origins: - http://localhost:3000 - https://admin.example.com这套配置能解决90%以上的场景。只要Filter被Spring管理到,就生效,不用在每个Controller上加注解,也不依赖Spring MVC的路由匹配。
4.2 传统Spring MVC下的Filter注册方式
非Spring Boot项目,或者Servlet容器中手动部署的WAR包项目,需要在web.xml或JavaConfig里声明Filter。因为CorsFilter本身是普通的javax.servlet.Filter,所以装配方式和自定义Filter一样。
JavaConfig版本的写法:
public class WebInitializer implements WebApplicationInitializer { @Override public void onStartup(ServletContext servletContext) { CorsConfiguration config = new CorsConfiguration(); config.addAllowedOrigin("http://localhost:3000"); config.addAllowedMethod("*"); config.addAllowedHeader("*"); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); FilterRegistration.Dynamic corsFilter = servletContext.addFilter("corsFilter", new CorsFilter(source)); corsFilter.addMappingForUrlPatterns(EnumSet.allOf(DispatcherType.class), false, "/*"); } }web.xml版本就是在web.xml里加<filter>和<filter-mapping>声明。核心思路一样,就是把CorsFilter注册到所有URL上,并确保它排在其它可能返回响应的Filter前面。
4.3 与Spring Security共存:顺序决定生死
如果你的项目里有Spring Security,那么CORS配置失效的概率极高。网络上大量帖子提到“Spring Security里CORS不生效”,原因普遍出在过滤链顺序上。
Spring Security的过滤器链会先于DispatcherServlet工作。如果CorsFilter没有在Security的HttpSecurity配置里显式声明,那么很多被Security拦截的请求根本走不到自定义的CorsFilter。更麻烦的是,Security自己处理OPTIONS预检的方式比较特殊,默认情况下预检请求会被直接拒绝,因为你没有给它放行的规则。
正确做法是给Security开启CORS支持,并注册CorsFilter:
@Configuration @EnableWebSecurity public class SecurityConfig { private final CorsFilter corsFilter; public SecurityConfig(CorsFilter corsFilter) { this.corsFilter = corsFilter; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http // 启用Spring Security的CORS支持,它会在过滤器链中插入CorsFilter .cors(Customizer.withDefaults()) .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() ) // 手动把CorsFilter添加到Security过滤器链中,确保它在认证逻辑之前执行 .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }这里有两个动作,缺一不可。第一,调用.cors(Customizer.withDefaults())让Spring Security知道这个应用允许CORS;第二,手动addFilterBefore把自定义的CorsFilter放到认证Filter之前,保证预检请求在进入认证逻辑之前就被处理掉。另外再给OPTIONS请求放行权限,否则即使CORS头正确,Security的鉴权也会阻止预检请求。
如果你完全依赖Spring Security,也可以不自己定义CorsFilter,而是在HttpSecurity里通过CorsConfigurationSource配置直接构造。不过这种写法把两种方案耦合在一起,排错时不容易分清问题出在哪一层。我的习惯是:统一使用独立的CorsFilter作为CORS的唯一入口,Spring Security里只做“放行”和“顺序”的控制。这样CORS相关逻辑集中在一个类里,后续改配置、查问题都方便得多。
5. 踩坑实录:几个真实项目的排错过程
5.1 加了Filter还是报错:先查Filter到底有没有被加载
有一位同事在自己的Spring Boot项目里按示例抄了CorsConfig,测试环境一切正常,部署到某一台机器上后死活报跨域错误。第一反应是代码没有发上去,对比之后发现分支一致、构建时间一致、产物一致,但就是不行。
最后排查到那里是内网网关层做了请求转发,同时网关自己处理了OPTIONS请求。请求到网关就被截住了,根本没有转发到后端应用,所以无论后端CORS Filter写得多么完美,都不会有对应的响应头。解决方式是在网关层统一加上CORS头。
这个案例的教训是:跨域报错不一定是后端代码的问题,链路上任何一层都可能拦截。排错时要先确认请求确实到达了目标服务,再看后端返回的响应头里有没有Access-Control-Allow-Origin。可以写一个简单的临时Controller,打印所有请求头和请求URI,然后看访问日志里有没有对应记录。
另一个更常见的原因是Filter没有生效。Spring Boot组件扫描默认扫描的是启动类所在包及其子包,如果CorsConfig放在了和启动类不同的包路径下,配置类静默失效,连日志都没有。所以我一般建议把CORS配置类放在config子包下,并且启动类最好在根包上。
5.2 allowCredentials和allowedOrigins="*"的组合病
这是最经典的CORS配置组合冲突。很多教程里给出的基础示例就是addAllowedOrigin("*")+setAllowCredentials(true),让人误以为这是标准搭配。实际运行起来,浏览器会抛出这样的错误:
The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.这个报错出来的时候,后端其实已经正常返回了200和响应头,浏览器仍然拒绝前端读取。我排查过几个项目,最终发现都是这种组合。解决方式也不复杂:显式列出允许的来源,替代通配符。
还有一种相关但更隐蔽的场景:当来源非常多,根本没法穷举时,又必须要配合Credentials使用。这时可以自定义CorsConfigurationSource,在getCorsConfiguration方法里动态读取请求里的Origin值,把它动态添加到配置里。这种做法在某些需要支持任意来源但还要带Cookie的场景下特别实用:
@Component public class DynamicCorsConfigurationSource implements CorsConfigurationSource { @Override public CorsConfiguration getCorsConfiguration(HttpServletRequest request) { String origin = request.getHeader("Origin"); CorsConfiguration config = new CorsConfiguration(); if (StringUtils.hasText(origin)) { config.addAllowedOrigin(origin); } config.addAllowedMethod("*"); config.addAllowedHeader("*"); config.setAllowCredentials(true); config.setMaxAge(3600L); return config; } }动态来源方案的缺点是安全性有所降低,任何来源都能通过,建议只在不需要严格校验来源的接口上使用。
5.3 同一请求里出现两个不同的Allow-Origin响应头
有一次在浏览器控制台看到“The 'Access-Control-Allow-Origin' header contains multiple values”的报错。这个报错的字面意思很直观:响应头里出现了不止一个Access-Control-Allow-Origin,比如一个值是具体域名,另一个是*,浏览器就懵了。
原因通常是项目里同时存在两套CORS配置,比如Spring Security的.cors()启用了一次CorsFilter,自己又注册了一个全局CorsFilter,两者都往响应里写了CORS头。于是同一个请求被两个Filter各处理了一遍,响应头自然多出来一份。
排查方法是在响应里直接看,到底有几个Access-Control-Allow-Origin。如果看到多个,要么去掉自定义的CorsFilter注册,要么在Security里不要启用.cors(),二选一。我这里推荐把CORS统一托管到一个Filter里,然后重复代码检查一下。
5.4 重定向请求的跨域:302之后头被“吃掉”
这个更冷门一些,但真实存在于文件下载、登录跳转这类场景里。后端接口返回302重定向,浏览器跨域请求跟随重定向时,CORS头在某些情况下会被丢弃,导致前端还是报“no Access-Control-Allow-Origin”。
原因是重定向后的响应可能来自另一个端点的另一个路径,那个路径上若没有CORS配置,返回里自然没有对应头。要么给重定向目标Path也注册CORS规则,要么用代理方式解决:前端请求同源的一个接口,由后端代理转发到目标地址。
这种问题特别难排查,因为你看F12网络面板,请求列表里有一条302和一条最终的GET,两者的响应头完全不同,而浏览器报错对应的是最终那条没有CORS头的响应。一旦意识到这点,解决思路就清晰了。
6. 生产环境里的CORS治理思路
6.1 开发、测试、生产环境的配置分层
CORS配置在不同环境里应该有不同的策略。开发环境来源通常只有localhost:3000,测试环境可能有多个测试域名,生产环境则需要严格限制。
我习惯的做法是把允许的来源做成配置项,而不是硬编码在代码里。@Value注入的方式就行,也可以用Spring的@ConfigurationProperties管理一组来源。这样打包时通过Profile区分,上线时只需改配置,不重新编译。
@ConfigurationProperties(prefix = "cors") public class CorsProperties { private List<String> allowedOrigins = new ArrayList<>(); private List<String> allowedMethods = new ArrayList<>(); private List<String> allowedHeaders = new ArrayList<>(); private boolean allowCredentials = true; private long maxAge = 3600L; // getter / setter }6.2 结合网关统一解决跨域
如果你的架构有网关层(Spring Cloud Gateway、Nginx、Kong),跨域问题还可以在网关层面统一解决。网关里配置CORS头,转发给后端服务时再清理掉后端返回的CORS头,保证整个请求链路上只有一层CORS处理逻辑。
以Nginx为例,简单配置如下:
location /api/ { if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' 'https://admin.example.com'; add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type'; add_header 'Access-Control-Max-Age' 3600; return 204; } add_header 'Access-Control-Allow-Origin' 'https://admin.example.com'; proxy_pass http://backend; }网关层统一解决的好处是后端各服务不用重复配置,但要注意预检请求默认不会带着Authorization头往下游传递,很多网关配置就漏在了这一层。反正每条链路只保留一个CORS生产者,这是核心原则。
6.3 到底该用Filter还是WebMvcConfigurer
很多人会纠结这个选择。简单归纳:
| 对比项 | CorsFilter | WebMvcConfigurer |
|---|---|---|
| 生效层次 | 最底层,无需经过Spring MVC | Spring MVC层,依赖DispatcherServlet |
| 覆盖范围 | Filter链能到达的全部路径 | Controller映射路径 |
| 与Spring Security先后 | 可自行控制Filter顺序 | 在Security之后执行,预检会被Security先处理 |
| 配置复杂度 | 需要注册Bean和Source | 配置简单,重写方法即可 |
| 适合场景 | 所有项目,尤其涉及Security和网关 | 纯API应用,不涉及额外Filter链 |
只要项目里存在Spring Security、自定义Filter、网关转发这些环节,我优先推荐CorsFilter。没有这些因素、纯接口对外提供时,WebMvcConfigurer更清爽。
7. 最后分享几个经验和调试技巧
7.1 快速复现和验证CORS请求
调试CORS问题时,光看浏览器报错往往不够。一个高效的办法是直接用curl手动模拟预检请求:
curl -i -X OPTIONS 'http://localhost:8080/api/user/list' \ -H 'Origin: http://localhost:3000' \ -H 'Access-Control-Request-Method: GET' \ -H 'Access-Control-Request-Headers: Authorization,Content-Type'看响应头里是否有Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers。这个方法能很直观地判断服务端CORS配置到底生效了没有,可以迅速定位问题在哪一层。如果curl模拟时有这些头,而浏览器报错,问题大概率在网关或者代理层;如果curl都没有,问题就在后端配置上。
7.2 避免未来的自己踩坑的几个习惯
第一,CORS配置类单独放一个类,不要塞到某个Controller里。
第二,过滤器的注册顺序不要依赖默认顺序,Spring Boot多Filter共存时@Order注解或者FilterRegistrationBean.setOrder()显式指定,数值小的先执行。
第三,启动日志里偶尔可以看到Filter的注册路径,用logging.level.org.springframework.web=DEBUG能帮助确认Filter是否被加载。
第四,前端的withCredentials必须和后端的allowCredentials(true)保持一致,任何一端没有声明,都会导致Cookie或Authorization无法跨域携带。
第五,尽量避免“复读机式”地查CSDN博客,新版本的Spring Boot(2.7+)和Spring Security(5.8+)里API有变动,老帖子里的写法很可能已经过时。看官方文档的CORS章节永远是最准确的信息来源。
我自己的项目到今天都是靠CorsFilter这套配置撑着的,期间经历过浏览器升级、Spring Boot版本从2.x跨到3.x、前端框架从Vue2换到Vue3,CORS这块代码基本没动过。稳定大于炫技,配置简单但覆盖全面的方案,往往才是生产环境里最省心的选择。