最近在一个前后端分离项目中,接口服务并不是直接暴露给浏览器访问,而是通过 Nginx/OpenResty 作为统一入口进行反向代理。浏览器访问的是对外的 API 域名,请求先进入 Nginx/OpenResty,再由反向代理转发到内部后端服务。
在这个架构下配置跨域时,遇到了一个比较隐蔽的问题:同一个 API,在一个前端站点可以正常访问,换到另一个前端站点后却被浏览器拦截,提示 Access-Control-Allow-Origin 不匹配。表面上看像是某个来源域名没有被允许,实际根因是反向代理层和后端服务同时返回了 CORS 响应头,导致浏览器收到重复且冲突的跨域配置。
本文记录这次问题的排查过程、根因分析和最终处理方式,适用于 Nginx/OpenResty 反向代理后端接口时遇到的 CORS 异常。
实际场景如下:
- API 域名:
https://api.example.com - 前端域名之一:
https://site-a.example.com - 前端域名之二:
https://site-b.example.com - 接入方式:Nginx/OpenResty 作为反向代理网关转发后端服务
- 请求接口:
/v1/chat/completions
最终通过在 Nginx 反代配置中隐藏后端返回的 CORS 响应头解决:
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
问题现象
浏览器控制台报错类似下面这样:
已拦截跨源请求:同源策略禁止读取位于 https://api.example.com/v1/chat/completions 的远程资源。
原因:CORS 头 'Access-Control-Allow-Origin' 不匹配 '*, https://site-b.example.com'
一开始也出现过类似:
原因:CORS 头 'Access-Control-Allow-Origin' 不匹配 '*, *'
这个报错的关键不在于某个域名没有被允许,而在于响应里出现了多个 Access-Control-Allow-Origin 值。
浏览器期望 Access-Control-Allow-Origin 只能是一个明确值,例如:
Access-Control-Allow-Origin: https://site-b.example.com
或者在不携带凭证的情况下是:
Access-Control-Allow-Origin: *
但如果响应最终变成:
Access-Control-Allow-Origin: *, https://site-b.example.com
浏览器就会认为这个 CORS 响应头无效,从而拦截请求。
为什么一个站点能访问,另一个站点不行
这类问题容易让人误以为是某个域名没有加入白名单。例如 site-a.example.com 可以访问,site-b.example.com 不可以访问,看起来像是 Nginx 的“允许访问的域名”没有配置好。
但实际问题是:
- Nginx/OpenResty 设置了一份 CORS 响应头
- 后端服务也设置了一份 CORS 响应头
- 两边返回的值不完全一致
- 浏览器收到合并后的响应头,例如
*, https://site-b.example.com - 浏览器判定 CORS 头非法,直接拦截
也就是说,问题不是“没有配置跨域”,而是“跨域配置重复了”。
根因分析
在 Nginx 反向代理架构中,一个请求大致会经过:
浏览器 -> Nginx/OpenResty -> 后端服务
如果后端服务本身已经返回了:
Access-Control-Allow-Origin: *
同时 Nginx 又追加了:
Access-Control-Allow-Origin: https://site-b.example.com
那么最终浏览器看到的可能就是:
Access-Control-Allow-Origin: *, https://site-b.example.com
这个值不是合法的 CORS 配置。
Access-Control-Allow-Origin 不能同时存在多个不同值,也不能写成逗号分隔的多个来源。即使两个来源都看起来“合理”,浏览器也不会接受。
排查思路
遇到类似问题时,可以按照下面顺序排查。
1. 先看浏览器 Network 面板
打开浏览器开发者工具,进入 Network,找到失败的请求,重点查看:
- Request Headers 里的
Origin - Response Headers 里的
Access-Control-Allow-Origin - 是否存在多个 CORS 响应头
- OPTIONS 预检请求是否正常
- 实际 POST/GET 请求是否正常
一个常见误区是只看 OPTIONS 预检请求。OPTIONS 可能是正常的,但真正的 POST 响应里仍然可能出现重复 CORS 头。
2. 对比 OPTIONS 和实际请求
跨域请求一般分为两步:
OPTIONS 预检请求 -> 实际 POST/GET 请求
有时候 OPTIONS 返回正确:
Access-Control-Allow-Origin: https://site-b.example.com
但实际 POST 返回了:
Access-Control-Allow-Origin: *, https://site-b.example.com
这种情况下,预检看起来没问题,但浏览器仍然会在实际请求阶段拦截。
3. 判断 CORS 头来自哪里
如果响应头里出现重复值,一般来源有两个:
- 后端框架的 CORS 中间件,例如 Node.js、FastAPI、Spring Boot、Go 框架等
- Nginx/OpenResty 的
add_header配置
最佳实践是:只保留一处负责 CORS。
在反代场景下,通常建议让 Nginx/OpenResty 统一处理 CORS,后端服务不再对外暴露 CORS 细节。
最终解决方案
本次问题最终通过 proxy_hide_header 解决。
示例配置如下:
location /v1/chat/completions {
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_pass http://127.0.0.1:你的后端端口;
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
}
核心是这几行:
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
它们的作用是:隐藏后端服务返回的 CORS 响应头,避免后端和 Nginx 同时输出 CORS 配置。
这样最终对浏览器暴露的 CORS 头就只剩 Nginx 生成的一份,不会再出现 *, https://site-b.example.com 这种非法组合。
为什么不用简单地全部配置成 *
很多人遇到跨域问题时,第一反应是把允许来源改成:
add_header 'Access-Control-Allow-Origin' '*' always;
但这并不总是可行。
如果前端请求携带了 Cookie、Authorization 或者设置了 credentials,浏览器要求:
-
Access-Control-Allow-Origin不能是* - 必须返回具体的 Origin
- 同时需要
Access-Control-Allow-Credentials: true
因此在需要支持凭证的场景下,更合理的方式是:
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
不过生产环境不建议无条件信任所有 $http_origin,最好配合白名单判断,只允许可信域名。
关于 $http_origin 的配置说明
在 Nginx 中,$http_origin 是一个内置变量,它的值直接取自请求头中的 Origin 字段,即浏览器自动携带的当前页面来源。用它来动态设置 Access-Control-Allow-Origin 是一种常见做法,但也有几个需要注意的地方。
直接使用 $http_origin 的风险
add_header 'Access-Control-Allow-Origin' '$http_origin' always;
这行配置的效果是:无论请求来自哪个域名,Nginx 都会将其原样回写到响应头中。也就是说,任何网站发起的跨域请求都会被放行。这在开发阶段可能很方便,但在生产环境中存在安全隐患:
- 任意第三方网站都可以跨域读取你的接口响应数据
- 如果接口涉及用户身份(Cookie、Token),可能导致 CSRF 攻击
- 浏览器只是校验
Access-Control-Allow-Origin是否匹配当前页面的 Origin,不会帮你判断这个 Origin 是否可信
空 Origin 的处理
某些请求可能不携带 Origin 头,例如同源请求、服务端发起的请求、或者 curl 等工具直接调用。这时 $http_origin 为空,add_header 会输出一个值为空的 Access-Control-Allow-Origin: 响应头。虽然浏览器不会因此报错,但属于不必要的响应头泄露。
更稳妥的做法是配合 map 做白名单过滤,不在白名单内的 Origin 不会返回任何 CORS 头。
map 白名单的工作原理
map $http_origin $cors_origin {
default "";
"https://site-a.example.com" $http_origin;
"https://site-b.example.com" $http_origin;
}
map 指令的工作方式是:
- 用
$http_origin的值去匹配左边的 key - 命中则返回对应的 value(这里是
$http_origin本身,即原样回写) - 未命中则返回
default定义的值(空字符串)
当 $cors_origin 为空时:
add_header 'Access-Control-Allow-Origin' $cors_origin always;
Nginx 不会输出这个响应头(值为空时 add_header 不会生效),浏览器收不到 Access-Control-Allow-Origin,自然拒绝跨域访问,从而达到白名单拦截的效果。
多环境支持
如果需要同时支持开发和生产环境的域名,可以这样配置:
map $http_origin $cors_origin {
default "";
"https://site-a.example.com" $http_origin;
"https://site-b.example.com" $http_origin;
"http://localhost:3000" $http_origin;
"http://127.0.0.1:3000" $http_origin;
}
开发环境可以放行 localhost,生产环境只允许正式域名,通过同一个 map 统一管理。
与 Access-Control-Allow-Credentials 配合
当需要携带 Cookie 或 Authorization 头时,必须同时设置:
add_header 'Access-Control-Allow-Credentials' 'true' always;
此时浏览器要求 Access-Control-Allow-Origin 必须是具体的域名值,不能是 *。使用 $http_origin 或 $cors_origin(经过白名单过滤)正好满足这个要求。
需要注意:开启 Allow-Credentials 后,白名单管控就更加重要,否则任何网站都能携带用户凭证访问你的接口。
更安全的白名单写法
如果只允许固定几个站点访问,可以使用 map 做 Origin 白名单。
map $http_origin $cors_origin {
default "";
"https://site-a.example.com" $http_origin;
"https://site-b.example.com" $http_origin;
}
server {
location /v1/chat/completions {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type, Accept, Origin' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_pass http://127.0.0.1:你的后端端口;
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Access-Control-Allow-Methods';
proxy_hide_header 'Access-Control-Allow-Headers';
proxy_hide_header 'Access-Control-Allow-Credentials';
}
}
这样可以避免任意网站都能跨域调用接口。
经验总结
这次问题的核心经验是:Nginx 反代场景下,CORS 不能只看“有没有配置”,还要看“是不是重复配置”。
几个关键结论:
-
Access-Control-Allow-Origin不能出现多个不同值 -
*, https://xxx这种响应头是非法的 - OPTIONS 正常不代表实际 POST/GET 一定正常
- 后端和 Nginx 不要同时负责 CORS
- 反代层统一处理 CORS 时,可以用
proxy_hide_header隐藏后端 CORS 头 - 如果请求携带凭证,
Access-Control-Allow-Origin不能使用* - 生产环境建议使用 Origin 白名单,而不是无条件返回
$http_origin
最终修复点并不是继续增加更多 CORS 配置,而是减少重复输出,让浏览器只看到一份明确、合法的 CORS 响应头。