前端在 Vercel、后端在自建云主机,两个域名互相调接口,浏览器直接拦截,前端同学第一反应是”后端把跨域放开不就行了”。我当时也这么想过,但放行所有 origin 加允许带 Cookie,等于把后端接口敞开给任意网站调用,安全风险太大。最终平台用 Spring Boot 的 CORS 配置 + 动态白名单 + 凭据策略三件套,把跨域问题解决了,也没留下安全隐患。下面把配置思路、关键响应头和踩过的坑都过一遍。
一、CORS 是什么
先弄清楚问题本身。浏览器同源策略规定,协议 + 域名 + 端口 任意一项不同,请求就不能携带 Cookie 和自定义 Header,这是浏览器层面的安全边界,目的是防止恶意网站冒充用户调用别的网站接口。但前后端分离架构天然跨域,前端调后端就得有一条合规的通道。CORS(Cross-Origin Resource Sharing)就是 W3C 制定的协议,由服务器通过 Access-Control-* 系列响应头声明”哪些 origin 可以访问我”,浏览器看到声明后才放行。
理解这个机制有个关键点:CORS 是服务器声明、浏览器执行的约束,服务端本身并不拦截请求,真正拦在门口的是浏览器。所以后端配置错了,从 curl、Postman 或服务端调用完全看不出来,只有打开浏览器控制台才报错。这个认知帮我们省了不少排查时间——最初定位问题时,团队差点去查服务端日志,实际请求根本没到业务代码。
二、最简配置
Spring Boot 提供了一套开箱即用的 CORS 配置方式,注册一个 WebMvcConfigurer 实现 addCorsMappings 即可。这是大多数人网上搜到的第一版写法:
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
这段代码能跑通本地联调,但生产环境绝不能直接用——allowedOriginPatterns("*") 加 allowCredentials(true) 组合起来,意味着任何网站都能带上你的 Cookie 调接口,等于把登录态暴露给全世界。我当时用公司安全团队的扫描工具跑了一遍,直接标成高危。所以这版配置只适合开发环境起步,上线前必须换掉。
三、平台的白名单方案
生产环境的做法是把允许的 origin 从通配符换成精确列表,从配置文件读取,改域名不用重新编译。平台最终用的是 CorsConfigurationSource 方案,注册一个 Bean,配置读取 application.yml 里的白名单:
@Configuration
public class CorsConfig {
@Value("${app.cors.allowed-origins}")
private List<String> allowedOrigins;
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(allowedOrigins); // 精确白名单
cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
cfg.setAllowedHeaders(List.of(
"Authorization","Content-Type","token","X-Requested-With"));
cfg.setExposedHeaders(List.of("Authorization","Content-Disposition"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource src = new UrlBasedCorsConfigurationSource();
src.registerCorsConfiguration("/**", cfg);
return src;
}
}
application.yml:
app:
cors:
allowed-origins:
- https://eval.example.com
- https://admin.example.com
- http://localhost:5173 # 本地 Vite 开发
用 allowedOrigins 精确列表之后,allowCredentials(true) 才被允许,Spring 在启动时不会抛异常。这个方案的好处是配置文件化:测试环境、生产环境的白名单分开维护,灰度域名上线时只改配置、不碰代码。白名单一定要精确到子域,别图省事写 *.example.com,否则挂了个不受控的子站等于把跨域放开了。
四、关键响应头解读
配置写完,浏览器实际收到的是哪些响应头,值得逐个弄清楚,否则出问题只能瞎猜。下面是平台实际用到的六组关键头:
| 响应头 | 含义 | 平台设置 |
|---|---|---|
Access-Control-Allow-Origin |
允许的 origin | 回显白名单中的匹配项 |
Access-Control-Allow-Credentials |
是否允许带 Cookie | true(前端用 Cookie 存 session) |
Access-Control-Allow-Methods |
允许的 HTTP 方法 | GET/POST/PUT/DELETE/OPTIONS |
Access-Control-Allow-Headers |
允许的请求头 | Authorization/Content-Type/token |
Access-Control-Expose-Headers |
浏览器可读的响应头 | 加 Content-Disposition(文件下载文件名) |
Access-Control-Max-Age |
预检缓存秒数 | 3600(避免每次 OPTIONS) |
这里 Expose-Headers 是最容易被忽略的一个。默认情况下浏览器只暴露少数几个响应头给前端 JS 读取,下载文件时前端要拿 Content-Disposition 里的文件名,不加这个头就拿不到。平台在对接评测报告下载时就在这里卡过一次,前端一直拿不到文件名,加上 Content-Disposition 才通。
五、预检请求处理
浏览器对非简单请求会先发一个 OPTIONS 预检,问服务器”我准备用 POST、带 token 头,你允许吗”。服务器必须正确响应预检,真实请求才会发出。平台用 CorsConfigurationSource 后,Spring 自动处理预检,不需要自己写 OPTIONS 接口。一次完整的预检交互长这样:
OPTIONS /api/eval/submit
Origin: https://eval.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: token,Content-Type
→ 200 OK
Access-Control-Allow-Origin: https://eval.example.com
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS
Access-Control-Allow-Headers: token,Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
注意 Access-Control-Allow-Headers 必须包含前端实际要带的头,漏一个,预检就直接失败,前端报”Request header field token is not allowed”——这是平台线上遇到过的真实报错。另外 Max-Age: 3600 让浏览器缓存预检结果一小时,避免每次请求都先发一次 OPTIONS,减少一次往返延迟。
六、Cookie 跨域的关键点
平台用 Cookie 存 session,跨域时带 Cookie 有三件事必须同时满足:前端 withCredentials: true、后端 Allow-Credentials: true、后端 origin 不能是通配符。缺任何一环,Cookie 都不会带上,用户会莫名其妙”每请求一次就掉登录”。前端 axios 配置:
import axios from 'axios';
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE,
withCredentials: true, // 关键!
timeout: 30000,
});
后端必须显式指定 origin(不能用 *),且 Allow-Credentials: true。这就是为什么不能用 allowedOriginPatterns("*")。调试这个问题的经验:在浏览器 Network 面板看请求的 Request Headers 里有没有 Cookie,没有就说明 withCredentials 没生效,或预检没通过。
七、与鉴权拦截器的配合
平台后来把 session 从 Cookie 换成了自定义 token Header 存储,因为前后端分离场景下 Header 比 Cookie 更好控制、也更适合移动端复用。切换时有个连锁动作:CORS 的 allowedHeaders 必须把 token 加进去,否则预检直接拦掉:
cfg.setAllowedHeaders(List.of("Authorization","Content-Type","token","X-Requested-With"));
OPTIONS 预检通过后,真实请求会带 token 头,鉴权 AOP 才能正常解析。这里埋过一个坑:加了 token 头但预检缓存还挂着旧配置,浏览器一直复用旧的 OPTIONS 结果,改了配置不生效,把 Max-Age 临时设成 0 才能强制重新预检。
八、踩过的坑
跨域配置看着简单,实际部署时踩过的坑一个比一个隐蔽,都列在这里。
allowedOrigins("*")+allowCredentials(true)直接报错:Spring Boot 6+ 启动就抛 IllegalArgumentException,必须用allowedOriginPatterns或者列白名单。- Nginx 反代后 CORS 头重复:Nginx 已加
add_header Access-Control-Allow-Origin又经过 Spring Boot,浏览器看到两个 Allow-Origin 直接报错。要么只在 Spring 配,要么在 Nginx 配proxy_pass_header不透传。 - 本地 localhost 跨域:开发时
http://localhost:5173与http://127.0.0.1:5173是不同 origin,必须都加白名单。 - CSRF 误伤:Spring Security 默认开 CSRF,前后端分离架构会拦截 POST,要么关掉 CSRF 要么只用 token 鉴权(不依赖 Cookie),平台选后者。
- 网关层 CORS 与应用层 CORS 重复:SluGateway/Kong 已加 CORS 时,应用层要写
setAllowedOriginPatterns("*")兜底,否则会被网关拦截。
Nginx 头重复这个坑排查了很久,当时浏览器控制台报 “The ‘Access-Control-Allow-Origin’ header contains multiple values”,一度以为是 Spring 配错,后来抓包才发现 Nginx 和 Spring 各加了一次。约定只有一层加 CORS 后问题就消失了。
九、生产环境的安全实践
配置稳定后,还要从安全角度再过一遍。平台总结出五条生产规范,每条都是真实踩坑或扫描告警逼出来的:
- 白名单精确到子域:不要写
*.example.com,明确写https://app.example.com。 - 限制允许的 Header:不要
*,只列业务需要的。 - 限制暴露的 Header:避免把内部 Header 暴露给前端。
- Max-Age 不要设太大:1 小时合适,开发期可以设 0 方便调试。
- 监控异常 origin:日志里记下所有
Origin头,发现异常来源立即封禁。
其中监控异常 origin 这条收益明显:上线后日志里出现过几次陌生域名尝试调用,我们靠 Origin 日志快速定位并封了来源 IP。CORS 配置本身只是”声明允许谁”,真正的安全边界还要靠鉴权兜底,两层都做才算完整。到这里,从配置到预检、从 Cookie 到安全实践的跨域方案就完整了。
常见问题(FAQ)
Q1:用了 Nginx 反代还需要 CORS 配置吗?
如果 Nginx 和后端同源(都在 eval.example.com 下),不需要;前后端不同源必须配,可以放 Nginx 也可以放 Spring Boot,二选一不要重复。
Q2:手机 App 调用需要 CORS 吗?
不需要,CORS 是浏览器的限制。原生 App / Postman / curl 都没有这层。
Q3:上线后改了前端域名怎么办?
改 application.yml 里的 app.cors.allowed-origins 重启即可。建议把这部分配置放 Nacos/Apollo 配置中心,改完无需重启。