1. 跨域问题不是 Nginx 独有,但解决入口多半在它这
“我在浏览器里访问接口,报错说没有 Access-Control-Allow-Origin,可我用 Postman 测明明好的,后端也说接口没问题。”这句话我在不同团队听了不下十遍。做 Web 开发的人迟早会被跨域问题绊一跤。你需要知道的第一件事是:跨域不是服务器拒绝了你,而是浏览器在按同源策略行使“检查权”。
什么是同源?一个完整的地址由协议、域名、端口三部分组成,这三要素只要有一个不同,浏览器就判定这是跨域请求。比如前端页面放在 http://www.example.com:8080,后端接口在 http://api.example.com,协议一样,但域名从 www 换成了 api,端口也从 8080 变成了默认的 80,两边不同,浏览器就会警惕起来。同源策略是浏览器的一种安全机制,它默认认为跨域访问有可能导致数据泄露,所以不允许页面随意读取另一个源返回的内容。
那为什么 Postman 没问题?因为 Postman 是独立客户端工具,它没有实现浏览器的同源策略,它只负责发请求、收响应,不会去检查响应头里有没有 Access-Control-Allow-Origin。curl、Python 的 requests、Node 脚本也一样。所以“接口能通但浏览器报错”不代表后端没返回数据,而是浏览器拿到了数据,但因为缺少跨域授权头,把数据拦下来不交给页面。
Nginx 之所以能解决这个问题,是因为它在请求链路里扮演了“出口代理”或“静态资源服务器”的角色,可以在响应阶段统一往响应头里追加 CORS 相关的字段。只要理解了浏览器到底在检查什么,Nginx 的配置就好写了。
浏览器的跨域检查分两种:简单请求和预检请求。标准定义里,满足以下条件才算简单请求:请求方法是 GET、HEAD、POST 之一,且 Content-Type 只允许 text/plain、multipart/form-data 或 application/x-www-form-urlencoded,且没有自定义请求头。在这种场景下,浏览器会直接发送真实请求,然后在响应阶段检查响应头。如果响应头里没有 Access-Control-Allow-Origin,前端页面就读取不到响应内容,控制台会报 CORS 错误。
但很多接口并不是简单请求。比如前端用 axios 默认发送 application/json,或者带上了 Authorization 自定义头,又或者是 PUT/DELETE 方法,这些都会被浏览器判定为“非简单请求”。浏览器会先发送一个 OPTIONS 方法的预检请求,问服务器:“我要用这个 Origin、这个请求方法、这些请求头去访问你,你允许吗?”服务器必须在 OPTIONS 请求的响应里明确返回 Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers 这些字段,浏览器核对通过后,才会继续发送真正的业务请求。
我见过大量 Nginx 配置只给统一的位置块加了 Access-Control-Allow-Origin,却没处理 OPTIONS 请求。简单请求能过,POST 带 JSON 的就挂在预检阶段。这种现象在浏览器开发者工具的 Network 面板里看会非常清楚:实际的业务请求根本没发出去,浏览器在提示“预检请求已失败”。所以,配置跨域时必须把 OPTIONS 当做一个独立的请求分支去专门处理,不能指望后端接口顺手解决,因为预检请求很可能到不了后端代码里,或者到了后端也被各种框架拦截器处理得乱七八糟。
从这一节开始,后续所有配置示例均以 Nginx 1.18 及以上版本为例。Nginx 的 add_header、if、map 等指令在 1.11.7 之后语法基本稳定,老版本差异不会太大,但建议至少用 1.16 以上,处理预检和映射时能少踩坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 最容易被忽略的 Nginx 指令:add_header 不是在哪写都生效
先把最基本的配置摆出来。要给一个接口路径添加跨域响应头,大部分人第一反应是这样:
nginx复制location /api/ {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
proxy_pass http://127.0.0.1:8080;
}
这个配置对于简单请求是有效的。浏览器收到响应后,看到 Access-Control-Allow-Origin 是 *,知道任意源都可以访问,于是放行。但你很快会发现两个问题:第一,带自定义头的 POST 请求偶尔失败;第二,如果你在 server 块外层也写了 add_header,里面 location 又写了 add_header,某些响应头莫名其妙不见了。
第一个问题是预检没处理。OPTIONS 请求进来后,Nginx 会把它代理到后端 8080,而很多后端框架不处理 OPTIONS,返回一个 302 或 404,CORS 响应头没跟上,浏览器自然判定失败。所以要在 location 里拦截 OPTIONS,直接返回 204,不往后端转发。这是所有生产级配置里必须有的环节。
nginx复制location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Max-Age 86400;
return 204;
}
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
proxy_pass http://127.0.0.1:8080;
}
这里的第一个坑是“继承规则”。Nginx 的 add_header 并不是全局想加就加,它遵循一个容易被忽略的逻辑:如果当前配置块里没有 add_header,它会继承上一级块中的 add_header;但只要当前块写了至少一个 add_header,上一级里的所有 add_header 都不会再生效。也就是说,不要试图把跨域头放到 server 块,然后到 location 里再加别的头,指望两层能叠加。加了也是白加,外层的头全被丢弃。我在实际项目里看到过很多次这个错误,尤其当 location 里为了安全加了一个 X-Frame-Options,结果整个 server 层的跨域头全部失效。
第二个坑是 if 块里的 add_header 与 return 的配合。在 Nginx 的 if 指令里,add_header 和 return 可以共存,因为 add_header 是在 return 之后仍然执行的模块机制。实测在 Nginx 1.18 下,用 if + return 204 + add_header 是稳定的。但要注意,Nginx 的 add_header 默认只添加到特定响应码的响应头里,官方文档列了 200、201、204、206、301、302、303、304、307、308 等。204 在默认集合内,所以这里没问题。如果你把预检改成 return 200,也可以,但 204 语义更正确,毕竟预检请求没有响应体。
第三个坑是“变量还是星号”。写 Access-Control-Allow-Origin * 固然省事,但一旦接口需要携带 Cookie,前端脚本里设置了 withCredentials: true,浏览器会坚决拒绝 *。规范要求,Access-Control-Allow-Credentials: true 时,Access-Control-Allow-Origin 必须是一个明确的源,不能是 *。所以生产环境我建议直接使用 $http_origin 变量,它代表请求头中的 Origin 值。但直接用 $http_origin 有个副作用:如果请求没有带 Origin(例如 curl 直接访问),响应头会变成 Access-Control-Allow-Origin: 空值。虽然不影响大多数场景,但会污染响应头,更严谨的做法是用 map 做白名单映射,或者用 if 判断,这在第四节展开。
需要说明的是,$http_origin 这个变量是 Nginx 中通用的“请求头映射变量”,任何请求头 Xxx-Name 都会变成 $http_xxx_name,所以 $http_origin 就是 Origin 请求头的值。同理,$http_access_control_request_method 是 Access-Control-Request-Method 请求头的值,在调试预检请求时会经常用到。
一旦把 OPTIONS 拦截、返回头补全,跨域配置的基本骨架就通了。接下来要做的,是根据真实业务场景决定细节。
3. 不同站点形态下,跨域配置怎么落
很多教程只给一个万能模板,但实际部署中,Nginx 有的是纯静态服务器,有的是反向代理,有的是多个前端项目共用一个网关。形态不同,配置位置和优先级完全不同。
3.1 纯静态资源站
前端打包后的 JS、CSS、图片放在 /usr/share/nginx/html 下,Nginx 不代理后端。这时只要给静态资源加跨域头,其他前端站点就能引用你的文件,常见的场景有字体文件、图表组件、地图瓦片。配置很简单:
nginx复制location /static/ {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Max-Age 86400;
}
字体文件加载跨域报错在控制台很常见,很多人以为是字体文件损坏,其实只是缺了 Access-Control-Allow-Origin。CSS 里的 @font-face 跨域加载字体时,浏览器会严格检查字体文件的 CORS 响应头。给静态资源加 * 是合理的,因为字体和图片不涉及用户隐私数据。如果你做的是 CDN 源站,建议同时加上 Access-Control-Allow-Methods 和 Access-Control-Allow-Headers,因为某些浏览器对字体文件的预检也是存在的,即使只是 GET 请求,浏览器也会因为自定义的 CORS 策略而发一次预检。
3.2 反向代理接口
前后端分离是最常见的情况。前端部署在 80 端口,后端服务跑在 127.0.0.1:8080,Nginx 负责把所有 /api/ 前缀的请求代理给后端。这种场景下,跨域头可以在 Nginx 上加,也可以在后端框架里加。我建议统一在 Nginx 加,理由是后端业务代码不用关心 HTTP 层策略,省得每个服务重复实现一遍;而且如果后端团队用的是多种语言,Nginx 一处配置可以覆盖所有上游服务。
代理场景的配置要注意一个隐藏问题:后端如果自己也返回了 Access-Control-Allow-Origin 头,Nginx 默认会原样透传,这时你再 add_header 一个,响应里就会出现两个同名头。某些浏览器会取第一个,某些取最后一个,表现很不稳定。稳妥的做法是在反向代理 location 里用 proxy_hide_header 把后端的 CORS 头隐藏掉,再由 Nginx 统一添加:
nginx复制location /api/ {
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;
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin;
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
add_header Access-Control-Max-Age 86400;
return 204;
}
proxy_pass http://127.0.0.1:8080;
}
这里有个细节:proxy_pass 后面的 URL 如果带了路径,比如 http://127.0.0.1:8080/,Nginx 会把 /api/ 前缀替换掉;如果没带路径,比如 http://127.0.0.1:8080,Nginx 会把整个原始 URI 传给后端。很多微服务网关本身有 context-path 配置,你需要根据后端实际的路由来决定是否带斜杠,否则跨域配置正确了,接口路径反而 404。
3.3 多前端域名共用一个后端
有些项目由主站、移动端页面、管理后台共用一套后端 API,三个前端域名的 Origin 各不相同。这时如果写死某个域名,另外两个站点就跨域失败;写成 *,又没法处理带 Cookie 的登录态。解决办法是把允许的域做成一个映射表,动态返回 Origin。Nginx 的 map 指令很适合这个用途:
nginx复制map $http_origin $cors_origin {
default "";
"~^https?://(www\.)?main\.example\.com$" $http_origin;
"~^https?://m\.example\.com$" $http_origin;
"~^https?://admin\.example\.com$" $http_origin;
}
然后在 location 里使用 $cors_origin 作为 Access-Control-Allow-Origin 的值。白名单之外的 Origin 会得到空字符串,Nginx 会输出一个值为空的同名头,浏览器会解析失败并拦截。为了更干净,可以在 map 里用 default "",然后在 location 里用 if ($cors_origin = "") { return 403; } 或者干脆不返回该头。不过返回 403 会暴露“不允许跨域”,对业务友好性差一些,我习惯直接返回正常响应但不加 CORS 头,浏览器自然会拦截,不影响同源访问。
这里有个正则转义的问题:map 后面的 key 可以用正则表达式,但要用 ~ 开头,且整体加引号。域名里的点号要写成 \.,否则它会匹配任意字符。我在排障时见过有人写成 "~^https?://(www\.)?main\.example\.com$" 漏了转义,结果连 evil-example.com 都能匹配,跨域等于全开放。
4. 配置写对了但还是报错?完整的排障链路
跨域配置的坑不在配置本身,而在“你以为生效了,其实没有”。我按实际踩坑顺序,把最有效的排查链路整理一遍。
第一步,先读浏览器报错关键字。控制台报错分两类:一类是 “No 'Access-Control-Allow-Origin' header is present on the requested resource”,说明 Nginx 确实没返回这个头;另一类是 “The 'Access-Control-Allow-Origin' header contains multiple values”,说明响应里出现了两个甚至多个同名头,多数是后端也加了,Nginx 也加了。把 Network 面板里那条请求(尤其是 OPTIONS 请求)的 Response Headers 展开,一目了然。
第二步,用 curl 模拟预检请求。浏览器里的 OPTIONS 请求在 Network 面板里不太直观,但 curl 可以直接复现:
bash复制curl -i -X OPTIONS \
-H "Origin: http://main.example.com" \
-H "Access-Control-Request-Method: GET" \
-H "Access-Control-Request-Headers: Authorization" \
https://api.example.com/api/user/info
重点看响应头里这几个字段是否齐全、值是否符合预期,以及状态码是不是 204。如果 curl 看到头全了,但浏览器还报错,那问题大概率在证书、代理、浏览器缓存或 HTTPS 混合内容上。比如页面是 HTTPS,接口是 HTTP,即使加了 CORS 头,浏览器也认为混合内容不安全,会直接不发送请求。
第三步,检查 Nginx 的实际生效配置。经常有人改了 /etc/nginx/conf.d/xxx.conf,但机器上启用的其实是另一个配置文件。执行:
bash复制nginx -T
把 Nginx 当前完整配置打出来,搜索你的 location,看 add_header 是否真的存在,以及外层是否还有干扰项。如果配置了多级 include,nginx -T 是唯一靠谱的确认方式。我在某个项目里就遇到过,运维同学把跨域配置写进了默认的 nginx.conf,但站点实际加载的是 sites-enabled 下另一个文件,等于改了十几行代码完全没生效。
第四步,确认 add_header 的作用位置。前面说过,当前块写了 add_header 就不会继承父级。如果你在 server 块写了跨域 header,但 location 里也写了一个 add_header(比如为了加 X-Frame-Options),那么 server 块里的跨域 header 全部失效。Nginx 不会报错,浏览器也不知道你“本想继承”。这个坑最隐蔽,因为同一个 location 有时候有头、有时候没头,完全看 add_header 有没有被踢掉。
第五步,验证 Nginx 是否加载了新配置。很多线上事故都是改了 nginx.conf 没有 reload,或者 reload 失败仍用旧进程在服务。改完配置务必执行:
bash复制nginx -t
nginx -s reload
如果 nginx -t 只有 warning,也要重视。比如 “server name has no dots”,这种不是致命错误但说明配置书写不规范。推荐在 CI 流水线里单独跑一次 nginx -t 做配置校验,避免手滑把语法错误推到生产环境。
第六步,观察后端返回是否覆盖。如果后端接口自己返回了 Access-Control-Allow-Origin,且没有被 proxy_hide_header 隐藏,加上 Nginx 的 add_header,就会出现重复头。这时候在后端代码里把 CORS 响应去掉,或者像上一节那样在 Nginx 用 proxy_hide_header 屏蔽,二选一,不能同时保留。Java 的 Spring、Python 的 Django、Node 的 Express 都有 CORS 中间件,不少框架默认就是开启的,你加了一层配置后,等于所有跨域请求头上都叠了双重保障,反而变成事故。
5. 边界情况和容易搞砸的细节
跨域配置里,真正需要你警惕的不是“不会写”,而是“写得太宽松”。
Access-Control-Allow-Origin 用 *,最大的问题是带不上 Cookie。假设你的接口需要登录态,前端 axios 请求里加了 withCredentials: true,此时浏览器要求 Access-Control-Allow-Origin 必须等于具体源,且 Access-Control-Allow-Credentials 必须为 true。你把 Origin 写成 ,浏览器直接拒绝。所以一旦涉及用户登录态, 就是不合格配置。
Access-Control-Allow-Headers 也不能无脑 。虽然在较新版的 Chrome、Firefox 里 * 已经支持,但在旧版 Safari、部分移动端 WebView 里, 并不被识别,导致预检失败。生产环境建议把实际用到的自定义请求头都列出来,比如 Content-Type、Authorization、X-Requested-With。多列几个不会有什么性能损失,少列一个就会挂。
Access-Control-Max-Age 这个字段值得单独说。它告诉浏览器:这个预检结果可以缓存多少秒。设置了之后,浏览器在缓存期内不会再发 OPTIONS,直接发真实请求。这对性能是很大的提升,尤其在高频接口上。默认不设置时,浏览器各有各的阈值,一般从几秒到几分钟不等。我一般设成 86400 秒(24 小时),但要注意:如果后端的 CORS 规则经常调整,预检缓存时间太长会导致旧规则在用户端持续生效,排查问题时会误导你。开发期设 300 秒比较合适,上线稳定后可以设 86400 甚至更大。
预检请求的响应状态码也有讲究。返回 204 无疑是最合适的,没有响应体,语义清晰。但有些老版本浏览器对 204 的预检响应兼容性有细微问题,如果遇到,改成 return 200 也行。实际的测试中,现在的 Chrome、Firefox、Safari 对 204 都没有问题,不用太担心。
还有一类场景容易忽略:Nginx 同时托管前端页面和 API,但 API 的跨域需求只在特定路径下存在。如果给整个 server 块加了跨域头,等于把所有静态页面也标记为可跨域访问,虽然不一定造成事故,但攻击面大了。我见过有的站点整个 server 块全是 add_header Access-Control-Allow-Origin *,连登录页 HTML 都允许任意源读取,这就不是配置问题,而是安全风险了。建议只在 /api/、/static/ 这类具体 location 上添加跨域头,别图省事。
带凭据的请求还有一层坑:Access-Control-Allow-Origin 使用 $http_origin 后,如果恶意网站伪造一个允许的 Origin 请求头,服务端会直接把它的 Origin 原样返回,进而被浏览器判定为跨域成功。所以前面说的 map 白名单是必要的,不能直接无脑传 $http_origin。尤其你这个接口涉及用户数据时,白名单能挡住绝大多数跨域滥用场景。
最后说一个 Nginx 内部跳转的坑。如果 API 配置里用了 rewrite 或者 try_files,请求被内部重定向到另一个 location,内层 location 的 add_header 可能会覆盖外层,或者因为内部跳转导致 OPTIONS 没有走预期分支。遇到“明明配置了,就是没头”的情况,检查是否有 rewrite 跳过了 location。我在一个老项目里见过,/api/ 前缀被 rewrite 成 /index.php,结果 CORS 配置写在 location /api/ 下,实际响应由内层 location 发出,头全部没带。
6. 可直接上线的完整配置模板
写到最后,给出一套我长期在用的通用模板,覆盖了预检拦截、动态 Origin、凭据、超时缓存、后端重复头屏蔽,基本可以直接用到生产环境,再根据业务微调即可。
nginx复制map $http_origin $cors_origin {
default "";
"~^https?://main\.example\.com$" $http_origin;
"~^https?://m\.example\.com$" $http_origin;
"~^https?://admin\.example\.com$" $http_origin;
}
server {
listen 80;
server_name api.example.com;
location /api/ {
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-Max-Age;
set $cors_methods "GET, POST, PUT, DELETE, OPTIONS";
set $cors_headers "Content-Type, Authorization, X-Requested-With";
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin;
add_header Access-Control-Allow-Methods $cors_methods;
add_header Access-Control-Allow-Headers $cors_headers;
add_header Access-Control-Allow-Credentials true;
add_header Access-Control-Max-Age 86400;
return 204;
}
if ($cors_origin != "") {
add_header Access-Control-Allow-Origin $cors_origin;
add_header Access-Control-Allow-Methods $cors_methods;
add_header Access-Control-Allow-Headers $cors_headers;
add_header Access-Control-Allow-Credentials true;
}
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;
}
}
这里用 if ($cors_origin != "") 作为条件:允许的 Origin 会得到原值并添加 CORS 头,白名单外的请求不添加头,浏览器自然拦截,后端也能正常收到请求。如果后端不需要处理白名单外请求,可以改成 return 403,省得业务代码去兜底。
还有个细节:proxy_hide_header 只对后端返回头生效,不影响 Nginx 自己 add_header 的内容。所以在同一 location 里 proxy_hide_header 和 add_header 写同名字段不会互相抵消,这是正常的,不要觉得自己写错了。
如果前端全部通过同域访问,也就是前端静态资源和 API 都在一个域名下,其实根本不需要跨域配置。很多人因为 Nginx 配置了 80 端口页面、8080 端口后端,就直接写跨域头,其实更优雅的方案是用 Nginx 把前后端统一到同一个 location 体系下,或者用当前域名的 /api/ 前缀代理到后端,这样从源头上避免跨域。跨域配置再熟练,都不如架构上规避它来得干净。
我往期项目里踩过最大的坑,还是 add_header 不继承那一条。当时排查了大半天,因为 server 层定义了跨域头,location 里为了加安全头写了 add_header X-Frame-Options,结果跨域头全部消失了,前端一调用接口就报 CORS。后来用 nginx -T 看完整配置才意识到,Nginx 的 add_header 不继承机制和 CSS 里的样式继承完全是两码事。你现在如果遇到了“配置看起来没问题,就是不生效”,优先查这一条,十次有七次是它。
