Nginx CORS 跨域配置实战:从同源策略原理到预检请求排错完整指南

为什么你的接口在 Postman 里正常,在浏览器里却报 CORS 错误

很多站长第一次遇到跨域问题,都会经历同一个困惑:用 Postman、curl 或者后端自己写脚本调接口,一切正常,数据也能返回;可一旦换成浏览器里的 fetch 或 XMLHttpRequest 去请求,控制台立刻红一片:Access to fetch at 'https://api.example.com/user' from origin 'https://www.example.com' has been blocked by CORS policy。于是开始怀疑是不是后端挂了、是不是 Nginx 配置错了、是不是防火墙拦了,折腾半天没有头绪。

这里要先说清楚一个关键事实:CORS 不是服务器拒绝服务,而是浏览器主动拦截了响应的读取。请求其实已经发出去了,服务器也处理并返回了,只是浏览器在拿到响应后检查发现「响应头里没有允许我这个来源访问的声明」,于是把响应内容丢弃,并且不允许你的 JavaScript 代码读取。这也是为什么 Postman 正常——Postman 不是浏览器,它不执行同源策略,自然也不会做这个校验。理解了这一点,排查方向就完全不一样了:你要做的不是去找服务器为什么拒绝,而是去给它补上正确的响应头。

这篇文章从同源策略的底层原理讲起,一步步带你把 Nginx 上的 CORS 配置写对,覆盖简单请求与预检请求的区别、常见配置错误的排错方法、以及生产环境下必须注意的安全细节。

同源策略:浏览器的安全底线

同源策略(Same-Origin Policy)是浏览器最核心的安全机制之一。所谓「同源」,要求协议、域名、端口三者完全一致。只要有一项不同,就算跨域:

https://www.example.com/a.html  请求  https://www.example.com/api   同源,放行
https://www.example.com/a.html  请求  http://www.example.com/api    协议不同,跨域
https://www.example.com/a.html  请求  https://api.example.com/api    域名不同,跨域
https://www.example.com/a.html  请求  https://www.example.com:8080/api  端口不同,跨域

如果没有同源策略,任何网站的 JavaScript 都能随意读取你在其他网站的登录态、订单信息、邮箱内容,只需要在你的浏览器里发起请求即可,因为浏览器天然携带 Cookie。所以浏览器选择默认全部拦截跨域读取,需要放行时由服务端显式声明——这就是 CORS(Cross-Origin Resource Sharing,跨源资源共享)的由来。

需要再次强调:同源策略限制的是「读取」,不是「发送」。跨域的简单请求即使没有 CORS 头,服务器依然会收到并可能产生副作用(比如已经完成了一次删除操作)。这也是为什么后端接口绝不能只靠 CORS 做权限控制,CORS 是浏览器的礼貌约束,不是安全边界。

简单请求与预检请求:两套完全不同的流程

CORS 请求分两种,流程差别很大,配置写错往往就错在没分清这两种。

简单请求(Simple Request)

同时满足以下条件的请求属于简单请求,浏览器会直接发出,不预先询问:请求方法是 GET、HEAD、POST 之一;请求头只包含 Accept、Accept-Language、Content-Language、Content-Type 等少数几个安全头;并且 Content-Type 只能是 application/x-www-form-urlencodedmultipart/form-datatext/plain 三种之一。简单请求的流程是:发请求 → 服务器返回 → 浏览器检查响应头里有没有 Access-Control-Allow-Origin → 有且匹配才把响应交给 JS。

预检请求(Preflight Request)

只要不满足简单请求的条件,浏览器就会先发一个 OPTIONS 请求去问服务器「我能不能这样请求」,这就是预检。触发预检的常见情况:

1. 使用了 PUT / DELETE / PATCH 方法(RESTful 接口几乎必然触发)
2. 请求头里带了自定义头,比如 Authorization、X-Token、X-Requested-With
3. Content-Type 是 application/json(这是最容易被忽略的一条)
4. 使用了 XMLHttpRequest.upload 或 ReadableStream

注意第 3 条:所有前后端分离项目里 POST 一个 JSON 的请求,都会触发预检。因为 application/json 不在简单请求允许的 Content-Type 白名单内。预检请求会带上三个关键头:Origin(来源)、Access-Control-Request-Method(真实请求要用什么方法)、Access-Control-Request-Headers(真实请求要带哪些头)。服务器必须在预检响应里逐一答复,浏览器才会发出真正的业务请求。

这解释了一个非常典型的故障现象:接口 GET 能通,POST 报跨域。原因就是 GET 简单请求不需要预检,而 POST JSON 需要,服务器的 OPTIONS 响应没有配好,预检失败,浏览器连业务请求都不发。

Nginx CORS 配置完整模板

下面这套配置可以直接用,我把每一行的作用都标注清楚。假设你的接口域名是 api.example.com,前端来源是 https://www.example.com

server {
    listen 443 ssl http2;
    server_name api.example.com;

    # 允许的来源。生产环境务必写具体域名,不要用 *
    set $cors_origin "";
    if ($http_origin ~* ^https://(www\.)?example\.com$) {
        set $cors_origin $http_origin;
    }

    location / {
        # 关键:把上面计算出的来源写回响应头
        add_header Access-Control-Allow-Origin $cors_origin always;
        # 允许携带 Cookie / Authorization(必须配合具体来源,不能用 *)
        add_header Access-Control-Allow-Credentials "true" always;
        # 预检响应里告诉浏览器现实请求可用什么方法
        add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, PATCH, OPTIONS" always;
        # 预检响应里告诉浏览器现实请求可带什么头
        add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Token" always;
        # 暴露给前端 JS 可读的响应头(默认只有几个简单头可读)
        add_header Access-Control-Expose-Headers "Content-Length, Content-Range, X-Request-Id" always;
        # 预检结果缓存 24 小时,减少 OPTIONS 请求
        add_header Access-Control-Max-Age "86400" always;

        # 预检请求直接返回 204,不交给后端
        if ($request_method = OPTIONS) {
            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, PUT, DELETE, PATCH, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Token" always;
            add_header Access-Control-Max-Age "86400" always;
            add_header Content-Length 0;
            add_header Content-Type text/plain;
            return 204;
        }

        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

几个必须理解的设计要点:

第一,用变量 $cors_origin 而不是直接写死或写 *。直接写 add_header Access-Control-Allow-Origin * 表面上最省事,但一旦需要携带 Cookie(Allow-Credentials: true),浏览器会直接拒绝这个组合,报 The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'。用 if 判断来源并回写,既支持多域名又支持 Cookie。

第二,OPTIONS 请求要在 Nginx 这一层直接返回,不要透传给后端。很多 PHP/Java 后端的预检处理做得很差,或者直接被路由框架拦截返回 404/405,导致预检失败。在 Nginx 层 return 204 是最稳的做法,同时也省掉了后端的无效负载。

第三,OPTIONS 块里必须重复写一遍 add_header。这是 Nginx 一个著名陷阱:add_header 指令的作用域规则是「子作用域中只要出现任何一条 add_header,父作用域的 add_header 全部失效」。上面 location 里写了 add_header,里面的 if 块又写了 return,如果 if 块不重复声明,预检响应就会缺少 CORS 头。

生产环境高频踩坑与排查方法

坑一:把 CORS 头加到后端,被 Nginx 覆盖或重复

如果后端(比如 PHP)已经输出了 Access-Control-Allow-Origin,Nginx 又 add_header 一次,浏览器会收到两个同名头,直接判定为无效配置并报错。排查方法很简单,看真实响应头:

curl -sI -H "Origin: https://www.example.com" https://api.example.com/user | grep -i access-control

# 如果输出两行 Access-Control-Allow-Origin,就是重复了
# 解决办法:二选一,推荐统一在 Nginx 处理,后端删掉相关 header

坑二:改完配置没重载,或者重载了但在排查缓存

Nginx 配置改完必须 nginx -t && nginx -s reload。同时浏览器和 CDN 都可能缓存带 CORS 头的响应,CDN 尤其坑——它在边缘节点缓存了没有 CORS 头的旧响应,你源站改对了但用户拿到的还是旧的。排查时务必带时间戳绕过缓存:

# 先测源站,绕过 CDN(直连源站 IP)
curl -sI -H "Origin: https://www.example.com" -H "Host: api.example.com" \
  https://源站IP/user --resolve api.example.com:443:源站IP

# 再测 CDN 层,带随机参数打掉缓存
curl -sI -H "Origin: https://www.example.com" \
  "https://api.example.com/user?debug=$(date +%s)"

坑三:304 响应丢掉了 CORS 头

当浏览器发送带条件的请求(If-None-Match / If-Modified-Since)时,服务器返回 304 Not Modified,响应体为空。如果 add_header 没有加 always 参数,Nginx 默认只在 200/204/301/302/304 之外的某些状态码上添加。更准确地说,add_header 默认只对 2xx、3xx 生效,但实践中大量案例表明,不带 always 时部分非 2xx 响应(包括某些场景下的 304 与错误码)会丢失 CORS 头,导致偶发性跨域报错。稳妥做法是所有 CORS 相关的 add_header 都加上 always,上面的模板已经这样写了。

坑四:混淆了 CORS 与 CSRF、把 Allow-Origin 当成访问控制

有些站长为了让「前端能调通」,直接配置成允许所有来源加携带凭据,这等于把接口完全敞开。要明确:CORS 头只影响浏览器,攻击者用 curl 或自己的服务器调你的接口完全不看这个头。真正的接口鉴权必须靠 Token、签名、Referer 校验、IP 白名单等机制。CORS 只是让合法前端能正常读数据,不是安全防线。

一套完整的验证流程

配置完成后,不要只靠浏览器刷新试,按下面顺序逐层验证,定位问题会快得多。

# 1. 手动模拟预检请求,看服务器是否正确答复
curl -si -X OPTIONS https://api.example.com/user \
  -H "Origin: https://www.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,authorization"

# 期望:状态码 204,且包含以下头
#   Access-Control-Allow-Origin: https://www.example.com
#   Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
#   Access-Control-Allow-Headers: ..., authorization, content-type(不区分大小写)
#   Access-Control-Max-Age: 86400

# 2. 模拟带凭据的真实请求
curl -si https://api.example.com/user \
  -H "Origin: https://www.example.com" \
  -H "Cookie: sessionid=xxx"

# 期望:Access-Control-Allow-Origin 反射为具体来源,且 Allow-Credentials: true

# 3. 验证非白名单来源被拒绝(返回为空字符串或无该头)
curl -si https://api.example.com/user -H "Origin: https://evil.com" | grep -i access-control-allow-origin
# 期望:无输出,或该头为空值

同一台服务器上多站点多域名的处理

个人站长常常一台服务器托管多个站,需要给不同站点放行不同的跨域来源。最清晰的做法是在 server 块级别定义来源映射,而不是在一个 location 里堆一大串正则。如果有大量来源,可以借助 map 指令把判断逻辑从 if 里挪出来,既提升性能(map 在请求早期求值且不用正则回溯)也让配置更易读:

map $http_origin $cors_origin {
    default                       "";
    "~^https://www\.example\.com$"   $http_origin;
    "~^https://blog\.example\.com$"  $http_origin;
    "~^https://m\.example\.com$"     $http_origin;
}

server {
    server_name api.example.com;

    location / {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Vary "Origin" always;
        # ... 其余配置同上
    }
}

这里额外加了一个 Vary: Origin。这个头非常重要但极易被忽略:如果你对不同的 Origin 返回不同的 Allow-Origin 值,就必须声明 Vary: Origin,否则 CDN 或浏览器缓存会把针对 A 站点的响应直接返回给 B 站点,造成偶发、难以复现的跨域失败。凡是 Allow-Origin 是动态值(反射来源)的场景,都应该带上 Vary。

写在最后

CORS 的问题看起来玄学,根子其实只有一条:浏览器在响应头里找不到它需要的许可声明。所以排查永远沿着「浏览器到底发了什么请求 → 服务器到底回了什么头 → 两者是否匹配」这条线索走,用 curl 手动模拟预检和真实请求,一比对就清楚了,完全不需要靠猜。

配置时守住三条底线:来源要具体不要用通配、带凭据时禁止通配、OPTIONS 在 Nginx 层直接返回 204。再把 Vary: Originalways 参数补齐,绝大多数跨域问题都能一次配好、长期稳定。

最后提醒一句:不要让 CORS 成为你的唯一防线。接口该鉴权鉴权、该限流限流,CORS 头是给浏览器看的礼貌约定,真正挡住攻击者的是你的 Token 校验和访问控制策略。把这两件事分清,你的站点在安全性和可用性上都能站得更稳。

Last modification:September 15th, 2026 at 12:23 pm

Leave a Comment