Nginx 跨站请求(CORS)配置完全指南:从原理到生产实践

在 App、小程序、H5 的开发过程中,"接口能通但浏览器报跨域"几乎人人都遇到过。本文从浏览器的同源策略讲起,给出 Nginx 上从"开发可用"到"生产可靠"的三套配置方案,并逐条拆解 add_header 的几个经典陷阱------这些陷阱会让你的配置看起来写对了、实际却不生效。

一、先搞清楚:到底是谁在跨域

跨域(Cross-Origin)限制来自浏览器的同源策略 :只有「协议 + 域名 + 端口」三者完全一致,才算是同源。https://m.example.com 请求 https://api.example.com 就已经跨域,因为域名不同。

一个常见的误解是"做 App 就一定会遇到跨域"。实际情况是:

|--------------------------------------|-----------|-----------------------------------|
| 客户端环境 | 是否受同源策略限制 | 说明 |
| 原生 App(iOS / Android / Flutter / RN) | 否 | 系统网络库不实现 CORS,无需 Nginx 配合 |
| 微信小程序 wx.request | 否 | 走原生网络,只需在后台配置「服务器域名」白名单 |
| App / 小程序内嵌 WebView、H5 页面 | 是 | 由 WebView 内核执行同源策略,必须服务端返回 CORS 头 |
| PC / 移动端浏览器页面 | 是 | 与 WebView 同理 |
| 小程序 web-view 组件加载的 H5 | 是 | 页面内发起的 XHR / fetch 依然受限 |

所以,只有当请求由浏览器内核发出时,才需要服务端配置 CORS 。如果你只用 axios 在 H5 里调接口、或者用 web-view 嵌页面,Nginx 的 CORS 配置就是必需的;纯原生请求加 CORS 头没有坏处,但也不会解决任何问题------那种情况下的"请求失败"通常是 HTTPS 证书、域名白名单或网络权限问题。


二、CORS 的两种请求:简单请求与预检请求

浏览器把跨域请求分成两类,处理方式完全不同:

简单请求 (不触发预检),同时满足:

  • 方法为 GET、HEAD、POST 之一;
  • 请求头只包含 Accept、Accept-Language、Content-Language、Content-Type 等安全头;
  • Content-Type 仅为 text/plain、multipart/form-data、application/x-www-form-urlencoded。

预检请求 (Preflight):只要有一条不满足就触发。例如用 PUT / DELETE、Content-Type: application/json、或者带上 Authorization、自定义 X-Token 请求头------现代前后端分离项目几乎 100% 会触发预检 。

预检的工作流程是:

  1. 浏览器先发一个 OPTIONS 请求,带上 Origin、Access-Control-Request-Method、Access-Control-Request-Headers;
  2. 服务端必须返回 2xx 状态码 ,并携带 Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers;
  3. 全部校验通过,浏览器才发出真正的业务请求。

这就是为什么很多配置"看起来写全了却还是报错"------只配了响应头,没有处理 OPTIONS 请求 。OPTIONS 落到 location / 上,被后端或静态文件处理成 405 / 404,浏览器直接判定预检失败。


三、方案一:开发环境最小可用配置

先给出一个可以直接跑起来、并且已经修掉了常见坑的版本:

bash 复制代码
server {
    listen 443 ssl;                       # 注意 ssl 参数,443 端口必须显式声明
    server_name  api.example.com;

    ssl_certificate     /etc/nginx/ssl/example.com.pem;
    ssl_certificate_key /etc/nginx/ssl/example.com.key;

    root  /mnt/test;
    index index.html index.php;

    # 统一在 server 层声明 CORS 响应头
    add_header Access-Control-Allow-Origin  '*' always;
    add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS' always;
    add_header Access-Control-Allow-Headers 'Origin, X-Requested-With, Content-Type, Accept, Authorization' always;
    add_header Access-Control-Max-Age       86400 always;

    # 预检请求直接返回 204,不进入后端,也不读文件
    if ($request_method = OPTIONS) {
        return 204;
    }

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
}

改动看似很小,但每一条都有原因,下面逐行解释。


四、逐行讲解

1. listen 443 ssl;

原配置写作 listen 443;,这是不完整的。Nginx 1.25 之前,443 端口必须带 ssl 参数,否则 Nginx 不会以 TLS 模式监听,握手会失败;Nginx 1.25 之后可以省略(会自动启用),但显式写上更安全、兼容性更好。

另外,配置了 443 就必须配置 ssl_certificate 与 ssl_certificate_key,否则 nginx -t 直接报错。

*2. add_header Access-Control-Allow-Origin ;

表示允许任何源 发起跨域请求。浏览器收到后,会把响应交给页面脚本。

也可以把 * 换成具体域名,但这不是"多个域名"的写法 ------Access-Control-Allow-Origin 只接受「一个具体源」或「*」,用逗号写多个域名是无效的,浏览器会直接拒绝。需要支持多域名时,必须动态回写 Origin,见第六节。

注意:当请求携带 Cookie(withCredentials: true)时,*** 会被浏览器无条件拒绝** ,此时必须回写具体的 Origin,并额外返回 Access-Control-Allow-Credentials: true。

3. add_header Access-Control-Allow-Headers ...;

声明允许客户端携带的请求头。这一项与前端实际发送的头必须匹配:如果前端发了 X-Token 而这里没有,预检就会失败。常见遗漏是 X-Requested-With(jQuery / axios 老版本会带)和 Authorization(JWT 场景必需)。

不想逐个列举时,可以在开发环境 临时用 * 通配(生产环境不建议,且带凭证时 * 无效)。

4. add_header Access-Control-Allow-Methods ...;

声明允许的 HTTP 方法。务必把 OPTIONS 加进去 ------预检请求本身的方法就是 OPTIONS。按实际接口填写,只用到 GET/POST 就不要放开 DELETE/PUT,这是最小权限原则。

5. always 参数(原配置缺失,但很关键)

Nginx 的 add_header 默认只对 200、201、204、206、301、302、303、304、307、308 这些状态码生效 。也就是说,当接口返回 401(未登录)、403、500 时,CORS 头会全部消失。

结果就是:前端明明该看到"登录已过期",实际看到的却是 "No 'Access-Control-Allow-Origin' header is present" ,排查方向被彻底带偏。加上 always 后,无论什么状态码都会输出这些头,错误信息才能如实透传到前端。

加 always 是排查跨域问题的第一优先级动作。

6. if ($request_method = OPTIONS) { return 204; }

拦截预检请求,直接返回 204 No Content。这是官方文档认可的用法(if 里只放 return 是安全的,不存在"if is evil"的性能陷阱)。

return 在 server 层会先于 location 匹配执行,所以预检请求不会打到 PHP/Node 后端,也不会因为找不到文件而返回 404,既快又稳。


五、原配置的 5 个隐患

对照上面几节,原始配置存在这些问题,值得单独列一遍:

|-----------------------------------------|------------------------------|--------------------------------------------------|
| 隐患 | 现象 | 修复 |
| listen 443; 未带 ssl | 握手失败 / 证书不生效 | 改为 listen 443 ssl; 并配证书 |
| 没有处理 OPTIONS | 预检 405 / 404,报跨域 | 加 if ($request_method = OPTIONS) { return 204; } |
| add_header 缺 always | 4xx / 5xx 响应无 CORS 头,真实错误被掩盖 | 每条 add_header 末尾加 always |
| Access-Control-Allow-Methods 未含 OPTIONS | 部分浏览器/CDN 校验预检失败 | 补上 OPTIONS |
| 生产环境使用 * | 任意站点可调用你的接口 | 改为域名白名单 |

还有一条隐藏得更深的坑:add_header 的继承规则 。

如果 server 块里写了 add_header,而某个 location 块里也 写了任意一条 add_header,那么父级的全部 add_header 都会失效------不是合并,是整体覆盖。这类问题在"大配置里只加了一行缓存头"的场景中极难发现。规避方式有两条:要么把头都写在最内层,要么用 include 复用同一份片段。


六、方案二:生产环境配置(白名单 + 凭证)

生产环境的核心诉求是:只允许自家域名跨域,并且支持带 Cookie / Token 的请求 。

由于 Access-Control-Allow-Origin 不能写多个域名,标准做法是用 map 把请求头里的 Origin 映射成一个变量,命中白名单就原样回写,否则留空。

map 必须放在 http 上下文,建议单独建一个文件:

bash 复制代码
# /etc/nginx/conf.d/cors_map.conf
map $http_origin $cors_origin {
    default                              "";
    "~^https://(www|m)\.example\.com$"   $http_origin;
    "~^https://admin\.example\.com$"     $http_origin;
    "~^http://localhost:(3000|5173)$"    $http_origin;   # 本地调试
}

然后在业务 server 中使用:

javascript 复制代码
server {
    listen 443 ssl;
    server_name api.example.com;

    ssl_certificate     /etc/nginx/ssl/example.com.pem;
    ssl_certificate_key /etc/nginx/ssl/example.com.key;

    location /api/ {
        # 预检请求:一次性返回全部 CORS 头并结束
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin      $cors_origin always;
            add_header Access-Control-Allow-Methods     'GET, POST, PUT, DELETE, OPTIONS' always;
            add_header Access-Control-Allow-Headers     'Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Token' always;
            add_header Access-Control-Allow-Credentials true always;
            add_header Access-Control-Max-Age           86400 always;
            add_header Vary Origin always;
            add_header Content-Length 0;
            return 204;
        }

        # 正式请求
        add_header Access-Control-Allow-Origin      $cors_origin always;
        add_header Access-Control-Allow-Credentials true always;
        add_header Vary Origin always;

        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;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

几个要点:

  • $cors_origin 为空时,Nginx 不会发送这个头 。这是 map 方案天然的安全边界:不在白名单里的源,拿不到 Access-Control-Allow-Origin,浏览器自动拦截,不需要额外写 deny。
  • Access-Control-Allow-Credentials: true 与 * 互斥 ,必须回写具体源。同时前端要配套设置 withCredentials: true(axios)或 credentials: 'include'(fetch)。
  • Vary: Origin 必须加 。响应内容随 Origin 变化,如果不声明,CDN 或浏览器缓存可能把 A 站点的响应(含 A 的 CORS 头)返回给 B 站点,造成偶发的"时好时坏"。这个头同样会影响 CDN 缓存键,需要确认缓存策略。
  • 只在需要的 location 上开跨域 ,不要整站全开。静态资源、下载、管理后台这类路径通常不需要。
  • 不要前后端都加 CORS 头 。如果后端框架(Spring Boot、Django-cors-headers、Express cors 中间件)已经输出了 Access-Control-Allow-Origin,Nginx 再加一条,浏览器会看到重复值 https://a.com, https://a.com,直接报错。二选一,建议统一在网关层做 。

七、方案三:比 CORS 更彻底的做法------同源反向代理

CORS 本质上是"让浏览器放行一个本该被拦截的请求"。如果项目还在早期,更好的选择是从架构上消灭跨域 :把接口挂到与页面同一个域名下,用 Nginx 反向代理转发。

bash 复制代码
server {
    listen 443 ssl;
    server_name m.example.com;          # 前后端共用同一域名

    root /var/www/h5;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;   # SPA 路由回退
    }

    location /api/ {
        proxy_pass http://127.0.0.1:8080/;   # 末尾斜杠表示剥掉 /api 前缀
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

同源之后:不需要任何 CORS 头、没有预检往返(少一个 RTT)、Cookie 天然可用、CDN 和安全策略都更好统一。这是中大型项目最常见的落地方式,也是微信小程序 web-view 场景的推荐做法。


八、验证:别用浏览器猜,用 curl 看响应头

浏览器控制台只会告诉你"跨域失败",不会告诉你缺了哪个头。用 curl 直接模拟预检,一目了然:

bash 复制代码
curl -i -X OPTIONS 'https://api.example.com/api/user' \
  -H 'Origin: https://m.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type,authorization'

正确的响应应当类似:

bash 复制代码
HTTP/2 204
access-control-allow-origin: https://m.example.com
access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers: Origin, X-Requested-With, Content-Type, Accept, Authorization, X-Token
access-control-allow-credentials: true
access-control-max-age: 86400
vary: Origin

再验证一个会失败的业务请求(如故意传错 Token 触发 401),确认 4xx 响应里依然带着 Access-Control-Allow-Origin------这是检验 always 是否生效的关键一步:

bash 复制代码
curl -i 'https://api.example.com/api/user' -H 'Origin: https://m.example.com'

注意:curl 本身不执行同源策略,它只能验证"服务端有没有返回正确的头",浏览器是否放行要看这些头是否满足规则。两者要结合判断。

配置改完先检查语法,再平滑重载:

bash 复制代码
nginx -t && nginx -s reload

nginx -t 通不过就一定不要 reload,否则可能直接导致线上服务中断。


九、常见问题排查清单

|---------------------------------------------------------------------|----------------------------------------|--------------------------------------------------|
| 现象 | 大概率原因 | 处理 |
| 控制台报 No 'Access-Control-Allow-Origin' header | 命中 4xx/5xx,头被吞掉 | 所有 add_header 加 always |
| 只有 OPTIONS 请求 405 / 404 | 未拦截预检 | 加 if ($request_method = OPTIONS) { return 204; } |
| 报 The 'Access-Control-Allow-Origin' header contains multiple values | Nginx 与后端重复输出 | 只保留一处 |
| server 里配了头却不生效 | 某个 location 里也写了 add_header,覆盖了父级 | 用 include 统一复用 |
| 带 Cookie 时报错 | Allow-Origin 是 * 或缺少 Allow-Credentials | 回写具体 Origin + true |
| 改了配置没变化 | 没 reload / 浏览器缓存了预检结果 | nginx -s reload,调小 Max-Age 或清缓存重试 |
| 时好时坏、偶发失败 | 缺少 Vary: Origin,被 CDN 缓存串了 | 加 Vary: Origin,刷新 CDN |
| 前端读不到自定义响应头(如 X-Total-Count) | 未声明暴露头 | 加 Access-Control-Expose-Headers |


十、小结

  • 跨域只在浏览器环境 成立;原生 App 与小程序原生请求不需要 CORS 配置,需要的是域名白名单与证书。
  • Nginx 配 CORS 的三件套:响应头 + always + 预检短路 。少了任何一个都会出现"配了却没用"。
  • Access-Control-Allow-Origin 只能是一个源或 *,多域名必须用 map 回写 $http_origin。
  • 带凭证时不能用 *,且必须加 Vary: Origin 防止缓存串源。
  • 开发环境可以放开 ,生产环境必须收敛为白名单 ------ 等于把你的接口暴露给互联网上任意一个网页,配合 Cookie 场景更是直接的 CSRF 风险面。
  • 条件允许时,用同域反向代理 从架构上消除跨域,比维护一套 CORS 规则更省心也更安全。
相关推荐
小溪学编程1 小时前
Spring @Bean 与 @Component 注解的区别详解
java·后端·spring
粥里有勺糖1 小时前
视野修炼-技术周刊第137期 | 编程已经面目全非
前端·github·ai编程
IT_陈寒1 小时前
Python的列表推导式用不好真的会爆内存你知道吗
前端·人工智能·后端
Wang's Blog1 小时前
Java 项目实战: 交友项目 - 圈子互动章总结与自检清单
java·bootstrap·交友
moshuying2 小时前
块扩散 + GDN 改造:Clover 把「并行生成」塞进了 Agent 的长上下文里
前端·算法
kyriewen2 小时前
我算了笔账:AI 编程工具一年烧掉两台 MacBook
前端·人工智能·程序员
xyouyuweizhu2 小时前
字节流与字符流:Java IO 的两条主线该怎么选
java·开发语言·python
代码方舟2 小时前
零信任架构实战:基于天远人企关联构建自动化供应链核心企业关联穿透与利益冲突审查网关
java·人工智能·架构·自动化
moshuying2 小时前
扩散模型开始写代码、开网页了:350 亿参数,速度比 Jev 快一倍
前端·算法