做后端开发的朋友,十有八九都见过浏览器里那行红字:has been blocked by CORS policy。前端同事抓耳挠腮,后端开发者打开接口用Postman一调,一切正常,于是“是不是你那边的问题”就成了经典的甩锅开场。其实这个锅谁也不该背,CORS跨域配置本来就是一个“写起来十分钟、踩坑踩一天”的环节。今天我不打算再讲概念图,直接以Spring里最常用、最彻底的CORS Filter实现为线索,从浏览器同源策略开始一路拆到可上线的Filter代码,最后再把几个高频报错逐一拿出来过一遍。这篇内容适合正在做前后端分离、Spring Boot接口服务,或者被跨域问题折腾过的后端同学,看完可以直接在自己的项目里落地。
1. CORS跨域到底在“跨”什么
1.1 Origin才是源头,域只是历史的误会
很多中文资料把CORS翻译成“跨域资源共享”,但你要是看英文全称,Cross-Origin Resource Sharing,里面压根儿没有Domain这个词,只有Origin。那为什么大家习惯叫跨域?这其实是个历史翻译习惯问题:早期国内技术圈把Origin和Domain都翻译成“域”,后来规范明确Origin由协议+域名+端口三部分组成,中文资料却已经叫开了“跨域”。所以你在理解时记住一件事:Origin是源头,跨域跨的是“源”,比“域”更严格——域名一样但端口不同,照样跨。
一个极简例子:http://localhost:8080 和 https://api.example.com 是不同源,http://example.com 和 http://example.com:8080 也是不同源。我见过有人把“跨域”和“跨站”混为一谈,实际上源和站(Site)用的是registrable domain那套规则,两者完全不是一回事。
1.2 浏览器才是那个“拦路警察”
跨域问题不是服务器拒绝了你,而是浏览器拒绝了你。我在排查问题的第一件事就是告诉同事:你Postman能通是正常的,因为Postman不是浏览器,它没有同源策略,也不需要CORS。真正的流程是——浏览器发现一个A源的页面要请求B源的资源,它会把这当作一次“跨国快递申报”:如果这个请求需要预检,浏览器先发一个OPTIONS探路,确认B源“允许”后,再发真正的请求。如果B源在响应里没带对CORS头,浏览器直接把响应吞掉,控制台报错,而服务端其实已经处理完请求了。
理解这个机制是配好CORS Filter的第一步:你要配的不是“让服务端接受请求”,而是“让服务端告诉浏览器,我有资格接收这个请求”。所以CORS Filter本质上是加响应头,不是改业务逻辑。
1.3 需要跨域的真实场景
跨域需求不是凭空出现的。前后端分离后,前端跑在开发服务器(比如http://localhost:3000),后端跑在http://localhost:8080,这两个源天然不同。微服务架构下,网关、认证中心、业务服务各自部署在不同域名或端口,前端要挨个访问,也是一堆跨域。再比如第三方开放平台,你的服务要被别的站点以正式接口方式调用,也需要主动声明CORS策略。这些场景落到Spring项目里,最简单的处理方式就是加一个全局CORS过滤器。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 预检请求与CORS协议的核心细节
2.1 简单请求与非简单请求的分界线
CORS协议把请求分成两类。简单请求是指GET、HEAD、POST这三种方法,且头信息只有浏览器允许的几个(如Accept、Content-Type里的三种固定值),这类请求浏览器直接发送,不带预检。其余情况都算非简单请求:比如把Content-Type设成application/json,或者用了自定义头X-Requested-With,或者方法是PUT、DELETE,浏览器都会先发一个OPTIONS探路请求。
为什么要预检?打个比方:GET请求只是“看一眼”,风险低;DELETE是把资源删了,浏览器觉得不能贸然执行,先问服务器“你允许我删吗”,服务器返回允许的Method和Header列表后,浏览器才发出真正的DELETE。如果服务器在OPTIONS阶段没给出正确的Access-Control-Allow-*响应头,后面的真实请求根本不会发出去。这正是一堆人看到response to preflight request doesn't pass的底层原因。
2.2 决定成败的几个响应头
CORS Filter的核心工作就是往响应里塞这些头,我把常用几个列出来:
| 响应头 | 作用 | 常见坑 |
|---|---|---|
Access-Control-Allow-Origin |
声明允许哪个来源访问 | 回显时不能和Allow-Credentials同时用通配符* |
Access-Control-Allow-Methods |
声明允许哪些HTTP方法 | 漏了OPTIONS必挂 |
Access-Control-Allow-Headers |
声明允许哪些自定义头 | 前端带什么头,后端就要认什么头 |
Access-Control-Allow-Credentials |
是否允许携带Cookie | 设true后Origin不能再是* |
Access-Control-Max-Age |
预检结果缓存秒数 | 设为-1则每次请求都预检,性能吃亏 |
这里最容易被坑的是带Cookie的请求。你想,浏览器毕竟不能把站点凭据随便给别的源,所以如果后端设了Access-Control-Allow-Origin: *,同时又设Access-Control-Allow-Credentials: true,浏览器会识别出这是一个非法组合,直接拦截。正确的做法是把Allow-Origin回显成请求的Origin值,而不是用通配符。
2.3 Spring中CORS Filter的位置与价值
Spring里处理CORS的姿态其实有好几种:Controller方法加@CrossOrigin注解、WebMvcConfigurer里注册CorsRegistry、以及最底层的CorsFilter。我为什么强调用Filter?因为我经手的项目里,跨域往往是全局性需求,用注解就得每个Controller都碰一遍,容易遗漏;用CorsRegistry虽然全局,但它的生效节点在HandlerMapping,也就是说配置上更“偏Spring MVC”,稍有不慎和自定义拦截器顺序协调不好,依然会出事。而CORS Filter是一个标准的Servlet过滤器,它在请求进入DispatcherServlet之前就把CORS响应头处理完了,覆盖面最彻底,还天然支持对OPTIONS预检的短路径返回。
说白了,Filter这种方式对这个问题的回答很直接:跨域是HTTP层的事,就应该在过滤链的最外层解决。
3. 从零手写一个可上线的Spring CORS Filter
3.1 推荐继承OncePerRequestFilter
先说明,Spring Boot 3.x用jakarta.servlet包,Spring Boot 2.x用javax.servlet包,下面的代码以Spring Boot 3.x为例,2.x改成javax.servlet即可。实现CORS过滤器有两种选择:直接实现javax.servlet.Filter,或者继承Spring提供的OncePerRequestFilter。我建议选后者。
原因很简单:Servlet规范里过滤器对同一个请求可能会被调用多次,典型场景是发生forward或error时,直接用Filter会有重复执行问题;OncePerRequestFilter通过request属性里的一个标记字段,保证了一次请求最多执行一次过滤逻辑。这在本项目也许无所谓,但架构上稳妥一点,以后遇到更复杂的转发逻辑就省心多了。
3.2 完整代码与逐段解释
先看配置绑定类,我习惯用@ConfigurationProperties把跨域白名单抽出来,方便不同环境切换:
java复制@ConfigurationProperties(prefix = "app.cors")
public record CorsProperties(
List<String> allowedOrigins,
List<String> allowedMethods,
List<String> allowedHeaders,
boolean allowCredentials,
long maxAge
) {}
然后在启动类或用@EnableConfigurationProperties注册这个Properties,接着写过滤器:
java复制@Component
@RequiredArgsConstructor
public class CustomCorsFilter extends OncePerRequestFilter {
private final CorsProperties corsProperties;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String origin = request.getHeader("Origin");
// 如果不是跨域请求(没有Origin头),直接放行
if (origin == null || !corsProperties.allowedOrigins().contains(origin)) {
filterChain.doFilter(request, response);
return;
}
// 通过Origin白名单校验后,写入响应头
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Methods",
String.join(",", corsProperties.allowedMethods()));
response.setHeader("Access-Control-Allow-Headers",
String.join(",", corsProperties.allowedHeaders()));
response.setHeader("Access-Control-Max-Age",
String.valueOf(corsProperties.maxAge()));
if (corsProperties.allowCredentials()) {
response.setHeader("Access-Control-Allow-Credentials", "true");
}
// 预检请求直接短路,不再走后续业务逻辑
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return;
}
filterChain.doFilter(request, response);
}
}
这段代码里有几个关键决定值得展开说。
第一个是Origin为空时的处理。同源请求、非浏览器客户端(比如服务器间调用)、部分特殊场景下不会带Origin头,此时没有必要加任何CORS响应头,直接放行即可。如果此时你硬塞一个Access-Control-Allow-Origin进去,等于允许所有跨域来源访问,那是安全事故。
第二个是Access-Control-Allow-Origin我用的是回显Origin,而不是写死某个域名。因为一个服务可能有多个合法前端源,写死了就得每次加代码,而回显加白名单校验,既保证了灵活性,也拦住了不在列表里的陌生来源。
第三个是OPTIONS短路。预检请求本来就不应该进入Controller,更不应该触发数据库操作,拿到CORS响应头后直接返回200是最优解。很多项目把OPTIONS也交给Controller处理,导致前端日志出现大量404或500的OPTIONS记录,就是因为这个短路没做好。
3.3 用FilterRegistrationBean控制过滤范围
@Component会让过滤器作用于整个应用的请求,这固然省事,但如果你希望过滤器只对/api/**生效,建议用显式注册代替自动扫描:
java复制@Configuration
public class CorsFilterConfig {
@Bean
public FilterRegistrationBean<CustomCorsFilter> corsFilterRegistration(
CustomCorsFilter customCorsFilter) {
FilterRegistrationBean<CustomCorsFilter> registration =
new FilterRegistrationBean<>(customCorsFilter);
registration.addUrlPatterns("/api/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
return registration;
}
}
setOrder(Ordered.HIGHEST_PRECEDENCE)不是说它抢业务逻辑的优先级,而是确保它尽早执行。有一种异常场景必须提醒:如果项目里加了Spring Security,CORS Filter一定要排在Security FilterChain之前,否则Security拦截链会把OPTIONS请求给挡了,导致预检永远不通过。用FilterRegistrationBean显式指定注册顺序,比靠@Component被自动扫描到的顺序要可控得多。
3.4 配置文件示例
在application.yml里配置白名单:
yaml复制app:
cors:
allowed-origins:
- http://localhost:3000
- https://admin.example.com
allowed-methods:
- GET
- POST
- PUT
- DELETE
- OPTIONS
allowed-headers:
- Content-Type
- Authorization
- X-Requested-With
allow-credentials: true
max-age: 3600
关于max-age,我多说一句。生产环境建议设为3600秒甚至更长,因为浏览器会缓存这个预检结果,期间再次发起跨域请求就不用重新OPTIONS了。开发环境则可以设短一点,不然你改了后端配置,浏览器还拿缓存的旧预检结果来跟你较劲,又得Ctrl+Shift+R硬刷新,何苦呢。
4. 高频报错与排查技巧实录
4.1 谷歌浏览器下的跨域“新增量”
很多老项目以前配的好好的,突然某天谷歌浏览器升级后接口就跨域失败了。这不是后端代码变了,而是浏览器策略收紧。比较典型的是私有网络访问(Private Network Access)规则:如果公网HTTPS页面去请求内网HTTP接口,浏览器会额外要求预检响应里出现Access-Control-Allow-Private-Network: true这个头,没有就不放行。
这属于浏览器“对你不够放心”的防御机制。应对办法是先看报错信息是不是提到private network。如果是,在CORS Filter里增加一条响应头:
java复制response.setHeader("Access-Control-Allow-Private-Network", "true");
但说句公道话,这个头不应该当成默认配置,毕竟公网页面直连内网资源本身就是一种危险行为,符合规则的方式是让公网客户端先通过网关或代理。
4.2 排查利器:curl手动模拟预检
不少读者可能问:前端报错后,我怎么判断是浏览器的问题还是后端的问题?我给出标准操作:用curl手动模拟OPTIONS预检请求,看服务端到底回了什么头。
bash复制curl -X OPTIONS \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type" \
-v http://localhost:8080/api/order
对照返回内容,如果缺少Access-Control-Allow-Origin或者某个Allow-Methods不包含POST,那就是后端配置不完整;如果响应头齐全,而且带了200,问题大概率出在浏览器缓存或者前端请求头超出了Allow-Headers的声明。这个命令是我排查CORS问题最常用的手段,比开DevTools看半天还直观。
4.3 CORS测试工具怎么选
浏览器开发工具和curl是最基础的,我再补充两个更好用的点:
- 直接在浏览器DevTools的Network面板里筛
OPTIONS请求,点开查看Response Headers,能直接看到服务器回给你的CORS头。 - 如果要批量测多个源、多个方法,建议写个简单脚本。用curl遍历白名单里所有Origin组合,自动输出响应头里的
Access-Control-Allow-Origin,用脚本代替手工一条条敲,效率能翻倍。
顺带吐槽一句:Postman这类工具发请求时不会受CORS限制,所以在Postman里测“能不能通”是无效的,只有模拟浏览器预检流程才有参考意义。
4.4 “response to preflight request doesn't pass”逐项排查
这个报错信息在我收到的咨询里堪称高频中的高频。我整理了一个排查顺序,按这个顺序走基本几分钟就能定位:
- 先看服务端有没有正确返回OPTIONS状态。如果OPTIONS被Spring Security或网关拦截,后面一切都是空谈。
- 看
Access-Control-Allow-Origin是否与当前页面源完全一致。http://localhost:3000和http://localhost:3000/都算不同字符串,必须精确匹配。 - 看
Access-Control-Allow-Headers里是否包含了前端实际请求头。前端带Authorization,你的允许头里没有,预检直接不通过。 - 看
Access-Control-Allow-Methods是否包含实际方法。PUT、DELETE这些方法特别容易在配置时被漏掉。 - 最后检查是否同时使用了
*和credentials,这个组合在任何浏览器里都过不了。
4.5 另类偏方:PHP跨域与JSONP
提一嘴PHP是因为不少前端同事以前搞PHP后端,习惯写header("Access-Control-Allow-Origin: *"),这种全放开写法在PHP时代很常见。而JSONP则是一种更古老的绕过方案:通过<script>标签加载跨域脚本,因为<script>不受同源策略限制,服务器把数据包装成callback({...})格式返回。但JSONP有几个硬伤:只能GET、不能带自定义头、错误处理不完善、还有注入风险。所以现在新项目我强烈不推荐JSONP,CORS已经覆盖了所有主流现代浏览器的需求,兼容性也在逐年提升。
如果你维护的是老系统,偶尔会遇到别家只提供JSONP接口的情况,那是历史包袱。可一旦自己能控制接口,老老实实用CORS才是正路。
5. 安全边界、微服务网关与性能沉淀
5.1 跨域配置绝不是越宽松越好
我见过不少项目图省事,直接Access-Control-Allow-Origin: *。如果你接口业务里没有任何Cookie、Token、用户凭证相关的东西,这个配置问题还不大;可一旦接口涉及用户身份,任何人都能从一个恶意网站发起请求来蹭你的接口,浏览器还真会放行,因为你的配置告诉他“随便哪个源我都允许”。这是典型的跨域配置导致的安全漏洞。正确的思路是维护一个精确的Origin白名单,生产环境尤其要严格。
从另一个角度说,CORS Filter是网络安全里的一道闸门,它不是“把路修宽”而是“给特定车牌发通行证”。把这道闸门当成摆设,不如不配。
5.2 Filter、注解、CorsRegistry到底怎么选
我之前谈了Filter的全局优势,但也不能一棒子打死所有方式。实战里的选型逻辑我给你理一理:
| 选型 | 适合场景 | 劣势 |
|---|---|---|
@CrossOrigin注解 |
少数Controller或方法需要开跨域 | 零散,容易漏;无法统一管理来源白名单 |
WebMvcConfigurer.addCorsMappings |
Spring MVC项目全局生效 | 基于HandlerMapping,和某些自定义拦截器配合时顺序要花心思 |
CorsFilter |
全站统一网关策略、有自定义过滤链 | 代码相对底层,需要自己处理一些响应头细节 |
我这几年接手过的项目里,凡是有统一认证、网关、多环境需求的,最后都回到了CorsFilter方案。它最直观,也最可靠。
5.3 Spring Cloud微服务体系下的CORS另一种解法
跨域问题在微服务里会多一层决策点:到底是统一在网关处理,还是让每个下游服务各管各的?我的建议是能统一就在网关处理。比如Spring Cloud Gateway里就支持全局CORS配置,网关照例拦截OPTIONS请求并注入CORS响应头,下游服务就不用关心了。前端只和网关打交道,源只有一个,配置清晰。
要注意的是,如果你在网关和下游服务都配了CORS,响应头可能会出现重复。多数情况下浏览器能容忍重复,但叠加Security机制时会多出不少奇怪的报错。所以约定清楚“谁对外,谁处理跨域”很重要。
还有个容易被忽略的工程点:Python服务或者其他语言写的微服务如果也要被浏览器直接调用,同样得各自实现CORS策略,别以为只有Spring项目需要操这个心。服务网关统一做是最省事的选择。
5.4 性能视角:OPTIONS请求并非零成本
每个预检请求虽然在Filter里被短路了,但它依然要经过Nginx、网关、Servlet容器,占连接资源。如果前端同时发起50个跨域请求,而maxAge没设,就会产生50个OPTIONS请求,接口压力翻倍。这也是为什么Access-Control-Max-Age值得认真配置的另一个原因。
我做过一个优化案例:一个报表后台,前端一打开页面要并发拉几十个接口,原先方案没配maxAge,预检请求占了一半流量;配上86400秒之后,只有第一次请求会有OPTIONS,后续请求浏览器全部复用缓存,整体QPS压力下降非常明显。这类优化不是花架子,是真的能改变服务器负载形态的。
另外,我在实际部署中还发现,部分老版本浏览器对maxAge的处理不一致,Safari在某种情况下不认大数字。但考虑到现在浏览器更新节奏,这个问题逐渐淡出视野了。
6. 一点私人总结与建议
写到最后,分享几条我踩坑后的固定习惯:
第一,新项目接手,先查项目里有没有全局CORS配置,再查入口是不是有Spring Security/网关层。跨域配置经常配了但被Security抢先拦截,这种问题排查成本特别高,不如一开始就把链路捋清楚。
第二,永远不要把“关闭浏览器安全策略”或者装一个“Allow CORS”浏览器插件当解决方案。那只解决了你自己电脑的问题,同事电脑、测试环境、客户浏览器照样挂。我之前就见过有人靠插件调通了,结果发到生产环境直接在客户面前翻车。
第三,接到跨域报错,先复现,再定位,再动手。curl模拟预检、DevTools看响应头、确认浏览器是不是新版策略收紧,三步走完,基本没有解决不了的跨域问题。CORS Filter不是玄学,它就是一个标准的HTTP响应头协商过程,你给浏览器足够的“安全感”,它自然放行你的请求。
只要把这个过滤器的原理吃透,以后前端同事再抱着红色报错来找你,你就能淡定地开一个curl命令,然后点出响应头里缺了哪个字段,三分钟收工。
