如果你写过前后端分离的项目,大概率见过这么一行控制台报错:Access to XMLHttpRequest at 'http://xxx' has been blocked by cors policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. 我到现在都记得第一次看到这个报错时有多懵,前端说接口能通,后端说服务没报错,两边对着截图扯了半天,最后才发现是浏览器把跨域请求给拦了。这就是典型的跨域问题,而Spring里最通用、最好排查的解法之一,就是自己动手写一个CORS Filter。
这篇文章我会把CORS的前因后果、Spring里几种常见方案、以及手写Filter的完整细节一次讲清楚。不管你是刚接触Spring Boot的新人,还是被线上跨域问题折磨过的老手,只要跟着把Filter的原理和坑点弄明白,以后再遇到这类报错基本就是看一眼Network面板就能定位的事情。
1. 跨域问题到底是怎么发生的
1.1 同源策略是这一切的起点
要理解CORS,先得理解浏览器为什么拦你。浏览器的同源策略规定:一个页面里的JavaScript只能读取同源(协议、域名、端口三者完全一致)的接口返回数据。比如你的页面跑在http://localhost:8080,接口在http://localhost:9090,这俩端口不一样,浏览器就会判定为跨域,然后把你用fetch或axios发出去的请求结果整个藏起来。
很多人会困惑:请求到底发出去了没有?答案是发出去了。浏览器把请求发出去了,服务端也正常处理并返回了,但浏览器检查响应头里没有Access-Control-Allow-Origin,或者这个头的值和当前页面源不匹配,就直接把响应丢弃,并且向控制台抛出一句我们熟悉的"has been blocked by cors policy"。
这个策略的本意是保护用户数据安全,防止恶意网站通过脚本去偷偷调用其他网站的接口。但在前后端分离成为主流的今天,这个策略反而成了开发阶段最常见的拦路虎,所以服务端必须主动在响应里声明"我允许你这个源来访问",这个声明机制就是CORS(Cross-Origin Resource Sharing,跨域资源共享)。
1.2 简单请求和预检请求的区别
CORS把请求分成两类,处理方式完全不同。
简单请求需要同时满足几个条件:请求方法是GET、POST、HEAD三者之一;请求头只有Accept、Content-Type这类浏览器允许的常见头;Content-Type仅限于application/x-www-form-urlencoded、multipart/form-data、text/plain。凡是带自定义头(比如Authorization、X-Requested-With),或者Content-Type是application/json的,都算非简单请求。
非简单请求在真正发送业务请求之前,浏览器会先发一个OPTIONS请求,这个请求叫预检请求(preflight request),目的就是问服务端:"我待会儿要用POST加JSON格式来请求你,你允许吗?"服务端必须在响应头里明确告诉浏览器允许哪些方法、哪些请求头,浏览器确认通过后才会发出真实请求。
这就是为什么很多人在后端日志里看到一堆OPTIONS请求觉得很奇怪——那不是攻击,是浏览器在正常"探路"。如果预检请求的响应不合格,浏览器就会报另一句经典错误:response to preflight request doesn't pass access control check。
1.3 涉及的响应头逐个说明
服务端解决CORS,本质就是往响应里塞几个特定的头,我用一张表说明各自的作用。
| 响应头 | 作用 | 是否必配 |
|---|---|---|
Access-Control-Allow-Origin |
允许哪个源访问,可以是具体源或* |
必须 |
Access-Control-Allow-Methods |
允许哪些HTTP方法,如GET、POST、PUT、DELETE | 预检时必须 |
Access-Control-Allow-Headers |
允许哪些请求头,如Content-Type、Authorization | 预检时必须 |
Access-Control-Allow-Credentials |
是否允许携带Cookie凭证,值为true |
按需 |
Access-Control-Max-Age |
预检结果可以缓存多少秒,减少重复预检 | 建议配置 |
Access-Control-Expose-Headers |
允许前端读取哪些响应头 | 按需 |
我们写Filter,干的事情就是手动把这些头加进去,并且正确处理OPTIONS预检请求。理解了这层原理,后面无论用Spring自带的方案还是自己写,你都知道每一步是在做什么。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Spring生态里解决CORS的几种主流方案
2.1 @CrossOrigin注解:最直接的写法
如果业务逻辑不复杂、跨域接口也不多,最省事的就是在Controller类或方法上加一个@CrossOrigin注解。
java复制@RestController
@RequestMapping("/api/user")
@CrossOrigin(origins = "http://localhost:5173")
public class UserController {
@GetMapping("/info")
public UserInfo info() {
// ...
}
}
这个注解的本质是Spring MVC框架帮你把CORS相关响应头加到了返回结果里,同时内部通过AbstractHandlerMapping处理了OPTIONS预检。优点是零成本,缺点是侵入性强、分散在各个Controller里,而且一旦接口是通过HandlerInterceptor以外的路径处理的,注解可能就失效了。多几个Controller就得重复写,维护起来很烦,我的建议是只适合临时调试,别带到生产。
2.2 WebMvcConfigurer全局配置:业务代码零侵入
不想改Controller代码,可以用WebMvcConfigurer里的addCorsMappings方法做全局配置。
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:5173")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
这种方式配置集中、便于统一管理,适合标准的Spring MVC项目。但它的处理逻辑是挂在HandlerMapping上的,如果你的项目里存在不走Spring MVC链路的请求(比如某些Filter直接响应了请求),或者和Spring Security的过滤链发生了冲突,这种配置就不一定能生效。
2.3 CorsFilter + UrlBasedCorsConfigurationSource:最通用
Spring官方其实提供了一个现成的CorsFilter,它不依赖Spring MVC,本质就是一个标准的Servlet Filter,只要是进入Servlet容器的请求它都能拦到。
java复制@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
// 允许的源,注意:Spring Boot 2.4以后建议用allowedOriginPatterns
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
这也是标题里说的"CORS Filter"最常见的官方实现。addAllowedOriginPattern("*")和addAllowedOrigin("*")的区别,我会在后面的报错章节细讲,这里先记住一个结论:只要开了allowCredentials(true),就不能用*当允许源,否则运行期会直接抛异常,这是网上80%踩坑帖的来源。
2.4 三种方案怎么选
| 方案 | 适用场景 | 优点 | 不足 |
|---|---|---|---|
@CrossOrigin |
接口少、临时联调 | 快速直接 | 侵入业务代码、分散维护 |
WebMvcConfigurer |
标准Spring MVC项目 | 配置集中、无侵入 | 依赖MVC链路,和Security配合要小心 |
CorsFilter |
所有Servlet场景、有Security | 通用性强、链路最靠前 | 需要理解Filter注册顺序 |
我的经验是:不管项目里最终选了哪种,底层原理都是一样的,无非是"谁在什么阶段往响应里加了CORS头"。把原理吃透了,任何方案对你来说都只是写法差异。
3. 手写一个CORS Filter的完整过程
3.1 Filter的核心逻辑与代码实现
理解了原理以后,手写Filter就顺理成章了。虽然Spring有现成的CorsFilter,但在某些特殊场景下,比如需要动态根据请求参数决定允许的源、或者需要从配置中心读取白名单,自研一个Filter反而更灵活。我这里给出一个经过生产验证的完整实现。
java复制@Component
public class CorsFilter implements Filter {
// 允许的源,生产环境建议从配置文件或配置中心读取
private static final List<String> ALLOWED_ORIGINS = Arrays.asList(
"http://localhost:5173",
"https://admin.example.com"
);
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
// 获取请求来源
String origin = request.getHeader("Origin");
if (origin != null && ALLOWED_ORIGINS.contains(origin)) {
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS");
response.setHeader("Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With, Accept, Origin");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Expose-Headers", "Content-Disposition");
}
// 预检请求直接返回,不进入业务链路
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return;
}
chain.doFilter(servletRequest, servletResponse);
}
}
这里有几个关键点要特别说明。
第一,Access-Control-Allow-Origin我回写的不是*,而是具体的origin来源值。因为后面我开了Allow-Credentials: true,如果用*,浏览器会直接拒绝。具体源还有一个好处是配合白名单天然实现了"只允许哪些站点访问"的控制。
第二,OPTIONS请求必须在这里直接返回,绝不能继续往下走chain.doFilter。因为预检请求本来就不应该触发真实的业务逻辑,如果漏掉这个分支,Spring Security可能把OPTIONS请求拦截下来要求认证,到时候前端就会看到"preflight request doesn't pass"。
第三,这个方法有一个先天缺陷:如果业务Controller在处理请求的过程中抛了异常,且异常被@ControllerAdvice处理并返回了JSON,那么Filter已经执行完了chain.doFilter,响应头是正常写入的没问题。但如果异常发生在更外层的Filter(比如Security的认证失败),那CORS头可能还没写上去,这种情况我会在第4章单独说。
3.2 注册顺序和路径匹配的坑
手写Filter最大的坑不在代码本身,而在注册顺序。Filter的执行是有顺序的,如果CORS Filter执行得太晚,前面的Filter可能已经返回了响应,CORS头就永远加不上去了。
最稳妥的做法是用FilterRegistrationBean手动指定顺序,把CORS Filter放到最高优先级。
java复制@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<CorsFilter> corsFilterRegistration(CorsFilter corsFilter) {
FilterRegistrationBean<CorsFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(corsFilter);
registration.addUrlPatterns("/*");
// 数值越小优先级越高,放最高优先级
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
return registration;
}
}
注意@Component已经让Spring容器管理了CorsFilter,再用FilterRegistrationBean注册是同一个实例,不会重复执行。之所以要手动注册,是为了能设置setOrder。如果你不加FilterRegistrationBean,Spring Boot默认给@Component的Filter注册的顺序是Ordered.LOWEST_PRECEDENCE,也就是最后执行,这样在和其他Filter配合时很容易出问题。
路径匹配上,addUrlPatterns("/*")是匹配所有路径,注意不是"/**"。/*是Servlet的路径匹配规则,/**是Spring的Ant路径规则,很多人把这两者搞混,写错了Filter就拦截不到路径,表现就是CORS头时有时无。这个细节网上问的人特别多,我在下面整理成了一条排查要点。
3.3 与Spring Security一起用时的正确姿势
项目里一旦引入了Spring Security,情况会变复杂。因为Security自己有一套Filter链,http.cors()这个配置决定了Security怎么处理CORS。推荐的做法是直接在Security配置里开启CORS,并且不要让手写的CORS Filter和Security的配置职责重叠。
java复制@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 开启CORS,配合下面的corsConfigurationSource()
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 开发调试阶段可以先关掉CSRF,生产按需开启
.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 config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
这里有两个必须理解的点。
第一,.cors()并不是一个开关那么简单,它是从Spring容器里找CorsConfigurationSource或CorsFilter的Bean,然后把它接入Security的Filter链,并且位置在认证授权Filter之前。也就是说,预检请求在到达认证逻辑之前就已经被CORS处理了,OPTIONS请求不需要登录也能拿到正确的CORS响应头。
第二,requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()这行是双保险。不加这行的话,有些低版本Spring Security即使CORS配置了,遇到需要认证的接口,OPTIONS预检也可能被拦成401。加上以后,预检请求就直接放行了,避免了很多"怎么加了CORS还是不通"的诡异问题。
如果你非要自己写Filter并用在Security项目里,请务必确保自定义Filter的Order比Spring Security的FilterChainProxy的Order还要靠前。Spring Boot里Security的FilterChainProxy默认Order是-100,所以你的CORS Filter Order要低于-100才能排在它前面,比如Ordered.HIGHEST_PRECEDENCE(即Integer.MIN_VALUE)就可以。这也是为什么很多教程强调CORS Filter要用最高优先级注册的原因。
4. 最常见的几个报错和排查实录
4.1 no 'access-control-allow-origin' header is present
这是最常见的报错,整个报错是:has been blocked by cors policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
字面意思是:服务端返回的响应里根本没有Access-Control-Allow-Origin这个头。排查思路分四步走。
第一步,打开浏览器DevTools的Network面板,找到那个被拦截的请求,看它的响应状态码。如果请求显示的是红色的CORS错误,点进去看Response Headers,排查有没有CORS相关的头。如果没有,说明服务端根本没执行到设置CORS头的代码。
第二步,确认请求到底有没有到达后端。看后端日志,如果日志里根本没有这个请求的访问记录,说明请求可能被更外层的代理(Nginx)拦了,或者被其他Filter拦了。开发环境最常见的其实是后端服务没启动、请求直接被代理返回了404页。
第三步,确认Filter是不是真的对当前路径生效了。检查@CrossOrigin注解的路径、CorsRegistry的addMapping路径、以及Filter注册的urlPatterns是否覆盖了报错接口的路径。这里特别容易犯的错误就是/*和/**写混。
第四步,确认响应是不是被"截胡"了。比如你用了统一响应体封装,或者某些组件在业务代码执行前就直接返回了结果,导致CORS Filter设置的响应头被覆盖或丢失。用抓包工具或者DevTools看实际响应头是最直接的判断方法。
4.2 response to preflight request doesn't pass access control check
完整报错是:has been blocked by cors policy: Response to preflight request doesn't pass access control check: It does not have HTTP ok status.
这句话的意思是:预检请求(OPTIONS)返回的状态码不是200。常见原因有两个。
第一个原因:后端有拦截器或Filter拦截了OPTIONS请求,返回了401、403、405之类的状态码。比如Spring Security里如果没加requestMatchers(HttpMethod.OPTIONS, "/**").permitAll(),预检请求可能被认证机制拦下。又比如你写了一个Token校验Filter,对所有请求先校验Token,OPTIONS没有Token就直接返回401了。
第二个原因:后端把OPTIONS当成普通请求处理了,返回了一个只处理GET/POST的Controller的404或405。所以在设计业务接口时,OPTIONS请求压根不应该进入Controller,最好在Filter层就返回。
处理办法也很明确:在Filter层对OPTIONS直接返回200,并且在Security配置里放行OPTIONS请求。我见过有人在自定义拦截器里逐个排除OPTIONS路径的,也能解决,但明显不如在Filter统一处理来得干净。
4.3 通配符与Credentials冲突的经典问题
这个报错一般出现在后端代码里写了Access-Control-Allow-Origin: *,同时又开了Access-Control-Allow-Credentials: true。浏览器这时候会直接拒绝,因为当凭证模式是include时,响应头的Access-Control-Allow-Origin不能是通配符*。
如果你用Spring Boot 2.4以上的版本,在CorsConfiguration里同时配置allowedOrigins("*")和setAllowCredentials(true),项目启动时就会直接抛IllegalArgumentException。因为这个版本开始框架就主动校验了这个组合。解决办法是改用allowedOriginPatterns("*"):
java复制CorsConfiguration config = new CorsConfiguration();
// 用Pattern代替固定通配符
config.addAllowedOriginPattern("*");
config.setAllowCredentials(true);
allowedOriginPatterns和allowedOrigins的区别在于:前者不直接输出*作为响应头的值,而是在响应时动态地把请求的Origin回写为Access-Control-Allow-Origin的值,这样既满足"不返回通配符"的浏览器要求,又实现了通配效果。这段逻辑是Spring在DefaultCorsProcessor里做的,理解了这个细节,你再去看看Spring源码就会觉得豁然开朗。
5. 生产环境里的避坑清单和实操心得
5.1 线上别用allowOriginPatterns("*")一放了之
开发环境用通配符图省事没问题,但生产环境如果接口涉及用户数据,强烈建议把允许的源收敛成明确的域名列表。原因很简单:CORS不是安全漏洞,但配置不当会扩大攻击面。如果允许任何源访问,任何恶意站点都可以在用户浏览器里发起请求,虽然浏览器的同源策略限制了恶意站点读取响应,但如果带上Cookie凭证且服务端也允许了Allow-Credentials,数据泄露风险就真实存在了。
建议把允许源放到Nacos、Apollo这类配置中心里,动态刷新。我自己在做网关层转发时,还会在CORS Filter里顺便校验一下Origin是否在内部服务白名单中,不在直接拒绝。不要嫌麻烦,线上出过一次事故你就会感谢这个白名单。
5.2 预检请求的缓存别忽略
Access-Control-Max-Age这个头值得设置。它表示预检请求的结果可以在浏览器里缓存多少秒,缓存期间对同一个接口的非简单请求就不用再发OPTIONS预检了。不设置的话,每次请求都要先发一次预检,请求量上来以后既浪费带宽也拖慢响应。
我用3600秒作为默认值,也就是1小时。有些场景下如果前端会频繁新增自定义请求头,这个值可以适当调小;总之根据业务实际来,但别不设置。
5.3 Spring Boot 2.4版本差异和JDK版本对应
如果你还在维护老项目,要特别注意版本差异。Spring Boot 2.4之前CorsConfiguration对allowedOrigins("*")和allowCredentials(true)的组合容忍度更高,升级到2.4之后启动就报错,这是很多老项目升级时莫名其妙挂掉的原因。另外,Servlet版本从javax升级到jakarta那波,自定义Filter的import也要跟着变,不然编译都过不了。
java复制// Spring Boot 2.x 及以前
import javax.servlet.Filter;
// Spring Boot 3.x
import jakarta.servlet.Filter;
这个坑在Spring Boot 3全面铺开以后,几乎每个从2.x升级的人都会踩一遍。Filter的doFilter方法签名都一样,但import换了个包名,不仔细改编译就直接报红。
5.4 排查CORS问题的一套标准动作
根据我踩过的坑,总结一套排查流程,你按这个顺序做基本能覆盖90%的问题。
第一步,先在浏览器DevTools的Network里确认请求是否发出去、响应里有没有CORS头。这能区分是浏览器层面拦截、还是服务端没返回正确的头。
第二步,看后端日志确认请求有没有到达应用层。如果没到,检查Nginx、网关、以及最外层的Filter。
第三步,用curl直接模拟请求,绕过浏览器看服务端返回。比如模拟预检请求:
bash复制curl -X OPTIONS http://localhost:8080/api/user/info \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: GET" \
-v
看返回头里有没有Access-Control-Allow-Origin。这一步能把"浏览器问题"和"服务端问题"彻底切开。
第四步,如果curl响应头正常但浏览器还是报错,多半是浏览器缓存了旧的预检结果,强制刷新或者无痕窗口试一下。
5.5 后端网关层面处理CORS的一个补充
如果你的服务是微服务架构,前端请求会先到网关再转发到下游服务,CORS处理建议放在网关层统一做,而不是每个微服务各自处理。否则同一个域名下有多个服务,每个服务的CORS配置不一致,前端就会遇到"这个接口能通、那个接口不通"的诡异现象。
网关层的处理方式依然是Filter,只不过是在Spring Cloud Gateway里用CorsWebFilter,或者在Zuul里用类似Filter。处理的头、预检逻辑、允许源校验都和Servlet版一致。把CORS收敛在网关层,下游业务服务完全不用关心跨域,职责单一,排查问题也快。
我个人在实际操作中最深的一个体会是:CORS是"浏览器的规矩",不是"HTTP协议的规矩"。所以无论后端怎么调试,只要浏览器觉得不合规就一定会拦。遇到问题时不要光盯着后端代码看,先学会用DevTools和curl判断"到底是浏览器没放行,还是后端没返回正确的头"。把这套方法练熟了,你会发现跨域问题从"老大难"变成一个十分钟就能解决的常规问题。
最后再分享一个小技巧:前端联调时如果嫌后端配CORS麻烦,可以在自己本地起一个代理服务器转发请求,让浏览器认为所有请求都是同源的,这也是一种规避方案。但线上环境的跨域能力一定得后端保证,Filter这条路是绕不开的根基,值得花点时间把它彻底吃透。
