Nginx反向代理场景下CORS头重复导致跨域失败的排查与解决

最近在一个前后端分离项目中,接口服务并不是直接暴露给浏览器访问,而是通过 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 响应头。


Nginx反向代理场景下CORS头重复导致跨域失败的排查与解决

https://blog.ckh-cn.site/index.php/2026/05/20/Nginx-CORS.html

作者

CKH

发布时间

2026-05-20

许可协议

CC BY 4.0

OS: Linux 115a654af43f 5.15.0-113-generic #123-Ubuntu SMP Mon Jun 10 08:16:17 UTC 2024 x86_64
CPU Info:
Memory Info:
评论