前后端分离做久了,跨域这问题就像夏天的蚊子,不咬人但烦人。尤其项目一上线,浏览器控制台那一排红字,一看又是Access-Control-Allow-Origin,前端和后端互相甩锅的戏码天天上演。我自己的习惯是,能用Nginx解决就不去改业务代码,毕竟换了个环境跑起来还得重新调,太浪费时间。这篇文章就把我用Nginx处理前端跨域的思路、配置和踩坑记录整理出来,适合正在做前后端分离项目、被跨域问题卡住的朋友参考。
跨域的根子不在前端也不在后端,而在浏览器的同源策略。简单说,浏览器把不同源(协议、域名、端口任一不同)的请求默认当成“外人”,为了安全不让前端JS直接读取响应内容。而Nginx作为反向代理服务器,正好卡在浏览器和后端服务之间,既可以伪装成“同源”转发请求,也能在响应头上手动放行跨域权限,两大路线捣鼓明白了,跨域基本就解决了。
1. 跨域问题的本质:浏览器安全模型的边界
1.1 同源策略到底拦截了什么
很多刚接触跨域的同事,第一反应是“后端接口明明能访问,为什么浏览器里就报错”。这里要理清楚:后端其实收到了请求,也正常返回了数据,但浏览器把响应扣下了,不让JS拿到。同源策略管的是——A页面里的JS只能读取A源(协议+域名+端口)的响应内容。比如前端跑在https://www.example.com:8080,后端API跑在https://api.example.com,端口不同就是跨域,浏览器默认不放行。
我把同源策略类比成小区门禁:你住A栋,门禁卡只能开A栋的门。你跑去B栋串门,B栋保安(后端)其实让你进去了,也给了你东西,但你回了A栋,A栋门卫(浏览器)看你手里拿的是B栋的东西,直接没收。所以问题不在B栋给不给,而在A栋门卫认不认。
浏览器拦截的方式有两种:一种是简单请求,比如GET、POST(Content-Type为text/plain等),浏览器直接发出去,但响应返回后JS拿不到;另一种是非简单请求(比如带Authorization头、Content-Type为application/json),浏览器会先发一个OPTIONS预检请求探路,后端不返回正确的跨域头,真实请求根本不会发出去。后者是实际项目中最常见的坑,后面专门讲。
1.2 前端跨域的主流解法与Nginx的定位
前端解决跨域的方式其实不少,各有各的适用场景,但大部分都有明显的“妥协”成分:
- JSONP:只能处理GET请求,需要后端配合返回
callback包裹的脚本,现在前后端分离项目里基本被淘汰; - 开发环境代理(Vite/webpack devServer proxy):只在本地开发时有效,上线后就没用了;
- 后端改响应头:每个接口都要处理CORS头,遇到多个前端域名还要动态判断,逻辑容易绕进去,而且改后端代码往往要排期、走流程;
- 浏览器关安全策略:本地自测还行,总不能要求用户也这么干。
Nginx的优势在于它处在流量入口这一层,所有外部请求都先经过它。在这里统一处理跨域,意味着后端代码不用改、前端代码不用改、上线环境不用动,只改Nginx配置就能把问题挡住。而且Nginx作为反向代理,还能顺便把静态资源托管、域名转发、HTTPS证书、负载均衡这些活一起干了,一个入口解决多件事,运维和开发都省心。
2. Nginx解决跨域的两条路线:改响应头与反向代理
2.1 路线一:用add_header在响应头里加CORS字段
Nginx最直接的做法是用add_header指令,给后端返回的响应补上浏览器需要的跨域头:
add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; add_header Access-Control-Allow-Headers "Content-Type, Authorization";这三行的意思很明确:允许任意源访问(*),允许哪些HTTP方法,允许前端带哪些自定义请求头。浏览器拿到这些响应头之后,发现“A栋门卫”认了,就放行了。
但这种方式的缺点也很明显:跨域头是“附加”在响应上的,请求本身还是要先打到后端。如果后端接口本身有鉴权拦截(比如没登录就返回401),那预检请求(OPTIONS)到不了后端,同样会卡住。更麻烦的是,Access-Control-Allow-Origin: *不能配合Access-Control-Allow-Credentials: true一起用(携带Cookie的场景,浏览器要求Origin必须是具体的域名,不能用通配符),所以很多生产环境还得动态设置Origin,后面细说。
2.2 路线二:用反向代理把“跨域请求”变成“同源请求”
这是我实际项目中用得最多、也最推荐的方式。思路是让前端只跟Nginx通信,Nginx再去跟真正的后端通信。比如前端域名是www.example.com,后端是api.internal.com(甚至局域网IP),我在Nginx里配置一个/api/路径,所有前端发到/api/xxx的请求,Nginx都转发到http://api.internal.com/xxx。对浏览器来说,它访问的始终是同源的www.example.com/api/xxx,根本没“跨域”这回事,自然也就不存在跨域限制。
location /api/ { proxy_pass http://api.internal.com/; 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; }这里有个关键点:proxy_pass结尾带不带/,路径拼接结果完全不同。比如前端请求www.example.com/api/user/list,配置proxy_pass http://api.internal.com/;时,Nginx会去掉/api前缀,把请求转发到http://api.internal.com/user/list;如果proxy_pass http://api.internal.com;不带斜杠,则会把完整路径/api/user/list直接拼上去。这个细节经常导致404,排查半天才发现是斜杠的问题。
反向代理还有个额外好处:浏览器根本不知道后端真实地址,后端也不直接暴露在公网,安全性顺带提升了。而且以后后端拆分成多个微服务,Nginx这边按路径分流就行,前端完全无感。
2.3 两条路线的选型建议
我自己遇到跨域需求时,一般遵循这样的判断:
| 场景 | 推荐路线 | 原因 |
|---|---|---|
| 后端接口已有大量调用方,不方便改代理路径 | 响应头方案 | 最小侵入,后端无感知 |
| 前后端都是自己掌控,可以约好API前缀 | 反向代理方案 | 彻底消除跨域,附带隐藏后端 |
| 需要携带Cookie跨域 | 反向代理方案优先 | 同源请求天然支持Cookie,绕开跨域限制 |
| 临时解决某个外部CDN或第三方API的跨域 | 响应头方案(视第三方是否支持配置) | 无法控制第三方时只能看对方配置 |
需要说明的是,如果后端本身已经正确配置了CORS,Nginx这边就不需要再做任何处理;但反过来,后端没配或者配错,Nginx在边缘兜底也能解决问题。这就是Nginx作为网关层最大的价值。
3. 几种主流场景的Nginx配置实操
3.1 场景一:静态页面跨域调用后端API
这是最简单的场景,前端是纯静态文件(比如一个Vue或React项目打包后的dist目录),后端是独立的API服务。直接让Nginx托管静态文件,同时用location匹配API请求并代理过去:
server { listen 80; server_name www.example.com; root /var/www/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; } }这样前端页面和后端API都在www.example.com下,从浏览器视角完全是同源请求。location /里写try_files是为了支持前端路由(刷新页面时不至于404),Vue Router和React Router的history模式都需要这个配置。很多人配完之后发现,点击链接跳转正常、一按F5就404,基本就是少了这一行。
3.2 场景二:响应头方案处理跨域API
如果后端服务不方便用反向代理(比如API域名是固定的、前后端契约已定死),那就用响应头方案:
server { listen 80; server_name api.example.com; location / { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With, Accept"; if ($request_method = 'OPTIONS') { add_header Access-Control-Max-Age 86400; add_header Access-Control-Allow-Credentials true; 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, X-Requested-With, Accept"; return 204; } proxy_pass http://127.0.0.1:8080; } }这里有个我踩过的坑:add_header一旦写了Access-Control-Allow-Origin: *,浏览器遇到携带Cookie的请求就直接报错,因为规范要求*不能和Allow-Credentials: true共存。所以带Cookie的跨域场景,我改用$http_origin动态回显请求来源,相当于告诉浏览器“我确实认你Origin,但我没有用通配符”,两边都满足。
3.3 场景三:携带Cookie的跨域请求
跨域带着Cookie操作是前后端分离项目最棘手的情况。反向代理方案天然规避了这个问题,但如果只能用响应头方案,配置就讲究一些:
location / { # 动态反射Origin,不能用* add_header Access-Control-Allow-Origin $http_origin always; 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') { return 204; } proxy_pass http://127.0.0.1:8080; proxy_cookie_path / /; # 可选,调整Cookie路径 }注意几个容易翻车的地方:
- 前端XHR/Fetch请求必须设置
withCredentials: true; - 后端响应里也要明确
Access-Control-Allow-Credentials: true; - Nginx这层如果用了
add_header Access-Control-Allow-Origin *,一定会炸,必须换成$http_origin; - 如果后端设置了
Set-Cookie,Nginx代理时可能会因为路径或域名不匹配导致前端存不下Cookie,需要视情况用proxy_cookie_path或proxy_cookie_domain做修正。
3.4 场景四:本地开发环境多站点多端口配置
团队开发时经常遇到这种情况:每个人本机起了一套完整环境,前端一个端口、后端一个端口、可能还有Mock服务、管理后台,多个服务端口不一。这时候与其让前端代码里写死多个环境地址,不如在本地统一搞一个Nginx入口,用不同域名区分项目,内部转发到不同端口:
server { listen 80; server_name dev.example.com; location / { proxy_pass http://127.0.0.1:3000; # 前端开发服务器 } location /api/ { proxy_pass http://127.0.0.1:8080/; # 后端服务 } } server { listen 80; server_name admin.example.com; location / { proxy_pass http://127.0.0.1:3001; # 管理后台 } }然后在本地hosts文件加上:
127.0.0.1 dev.example.com 127.0.0.1 admin.example.com这样每个人访问的域名都统一了,前端代码里只需要一个API地址,不会再出现“我本机是8080,你本机是8081”这种混乱。Windows上改hosts文件要用管理员权限,macOS/Linux直接改/etc/hosts就行,改完如果没生效,可能是DNS缓存的原因,用ipconfig /flushdns(Windows)或dscacheutil -flushcache(macOS)刷新一下。这个方案配合压缩头部gzip和静态资源缓存,本地开发体验能提升不少。
4. 预检请求OPTIONS:跨域的隐形杀手
4.1 为什么浏览器总是先发OPTIONS
只要前端用自定义请求头(比如Authorization: Bearer xxx)、非简单Content-Type(比如application/json)、或者请求方法是PUT/DELETE/PATCH,浏览器就会自动先把OPTIONS请求发出去,试探后端是否允许跨域。很多后端框架默认不处理OPTIONS,导致预检请求直接404,浏览器看到没放行,真实请求就压根不发了。
我之前排查过一个“偶现跨域报错”的问题,现象特别迷惑:同一个接口,有时候通有时候不通。后来抓包发现,前端某些请求带上了自定义头,触发了预检;而另一些请求恰好是简单请求,不需要预检就通了。所以不是接口不稳定,而是预检这关没过。
4.2 拦截OPTIONS请求并直接返回结果
在处理跨域的Nginx配置里,最省事也最稳妥的做法是把OPTIONS请求“就地正法”,根本不转发给后端:
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, X-Requested-With"; add_header Access-Control-Max-Age 86400; add_header Access-Control-Allow-Credentials true; return 204; }return 204表示成功但没有返回体,浏览器拿到这个响应,再看响应头里的Allow-*字段,就知道后续真实请求可以发了。Access-Control-Max-Age 86400的作用是告诉浏览器,这次预检的结果可以缓存一天,这一天内同样的跨域请求不再发OPTIONS,能明显减少请求次数。
这里有个Nginx的语法坑:if指令里不能用常规的proxy_pass,如果预检请求被转发到后端,后端多半不会返回跨域头。所以我一般把OPTIONS的处理和实际请求的转发分开写:
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; add_header Access-Control-Allow-Credentials true; return 204; } proxy_pass http://127.0.0.1:8080/; }4.3 是否需要保留OPTIONS的转发
有些后端框架(比如Spring Boot通过拦截器加CORS、Node.js的cors中间件)自己会处理OPTIONS,这种情况下Nginx里再拦截一次也没问题,反正返回204和跨域头就行。但如果后端逻辑确实依赖OPTIONS请求(极少见,基本是自定义框架自己写的),那就别拦截,只加响应头。我的经验是:优先Nginx拦截,简单直接,少一个请求打到后端就少一分性能压力。
5. 实战中高频踩坑与排查技巧
5.1 重复的Access-Control-Allow-Origin头
有一次配置好后,浏览器报错说“Origin多个值,只允许一个”。排查了半天,发现后端框架自己已经加了CORS头(比如Spring的CorsFilter),Nginx又加了一遍,两个Origin头同时出现在响应里,浏览器直接拒绝。
解决方案是让后端统一处理,或者让Nginx统一处理,二选一。如果决定Nginx来管,后端就把CORS逻辑关掉;反之Nginx里只保留代理转发,不要加add_header。用curl验证最直观:
curl -i -H "Origin: http://www.example.com" http://api.example.com/user看返回的响应头里是否有多个Access-Control-Allow-Origin,一眼就能看出来。
5.2 SameSite与Cookie跨域
前端在www.example.com,Nginx代理到后端127.0.0.1:8080,虽然浏览器看前端和后端同源了,但Cookie实则还是按127.0.0.1:8080域下发的。如果后端设置了Set-Cookie,浏览器存储时可能带不上,或者下次请求时不会自动带上。
这种情况要把Nginx代理时的Cookie域和路径调整一下:
proxy_cookie_path / /; # 把路径重写为根路径 proxy_cookie_domain 127.0.0.1 $host; # 把域改为当前访问域名不过更推荐的做法是后端设置Cookie时不绑定特定域,SameSite=None; Secure搭配,因为现在浏览器新版本对跨域Cookie限制越来越严,不处理清楚就是连环坑。
5.3 HTTPS反向代理的证书与Host透传
Nginx挂HTTPS证书反向代理到内部HTTP服务时,有个常见的“内网通、外网不通”的问题:
server { listen 443 ssl; server_name www.example.com; ssl_certificate /etc/nginx/ssl/cert.pem; ssl_certificate_key /etc/nginx/ssl/key.pem; location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }X-Forwarded-Proto $scheme这一行别省。后端如果想要判断请求是HTTPS还是HTTP(比如生成回调地址、校验安全来源),依赖这个头才能知道外部实际是HTTPS请求,否则后端一看http://,回调地址拼出来就是错的,前端拿到的数据里URL全不对。
5.4 解决刷新页面404与强制刷新缓存
处理完跨域之后,前端经常会遇到两个“次生问题”:一个是前面提过的history模式刷新404,用try_files解决;另一个是改了前端代码之后,用户浏览器缓存了旧版静态资源,点一遍刷新还是旧界面。我的做法是在Nginx层配合前端打包的版本号机制:
location /assets/ { add_header Cache-Control "no-cache, no-store, must-revalidate"; }前端打包时给JS/CSS文件名加上内容哈希(Vite和Webpack默认行为),每次发布后文件名变了,自然就不会命中旧缓存。这和跨域本身没直接关系,但既然都动Nginx配置了,顺手把缓存策略也理一遍,省得后面被用户吐槽。
5.5 定位跨域问题的三板斧
遇到跨域报错,我推荐按这个顺序排查,别一上来就盲目改配置:
- 看浏览器Network面板:先确认有没有发出请求、发出的是OPTIONS还是真实请求、响应头里有哪些CORS字段、响应状态码是多少;
- 用curl模拟请求:加
-H "Origin: http://www.example.com"直连Nginx,看返回的响应头,排除浏览器干扰; - 确认配置加载:改完Nginx配置后,用
nginx -t先测语法,再nginx -s reload平滑加载,避免配错了半天下发不生效。
nginx -t nginx -s reload很多“改了配置没反应”的情况,其实是配置文件语法就错了,Nginx根本没能加载成功,reload之后还是旧配置。先跑一遍nginx -t是最起码的自检。
6. Nginx在跨域之外还该注意的安全细节点
6.1 别为了跨域把接口完全裸奔
用响应头方案时,有人图省事直接写Access-Control-Allow-Origin *,等于告诉任何网站都能跨域读取你的接口数据。如果接口本身有鉴权还好,一旦哪个接口漏了鉴权,别人网站里的恶意脚本就能把你的接口当免费数据源用。
更稳妥的做法是用$http_origin做白名单判断。Nginx可以通过map指令实现:
map $http_origin $cors_origin { default ""; "~^https://(www\.)?example\.com$" $http_origin; "~^https://admin\.example\.com$" $http_origin; } server { location /api/ { add_header Access-Control-Allow-Origin $cors_origin always; add_header Access-Control-Allow-Credentials true; if ($request_method = 'OPTIONS') { return 204; } } }这样只有example.com和admin.example.com的页面能跨域获取数据,其他来源拿不到跨域头。
6.2 隐藏Nginx版本与关闭无用模块信息
很多安全扫描工具会通过HTTP头里的Server: nginx/1.24.0识别版本号,再针对性找漏洞。虽然不是跨域问题,但和跨域配置一起做正合适,一条指令搞定:
server_tokens off;另外,跨域放行了并不意味着后端该暴露的接口都暴露了。比如服务器上的文件访问如果走Nginx代理,location里要小心alias路径拼接错误,否则别人可能通过构造路径读到不该读的文件。我的经验是文件下载类的代理尽量用固定的文件目录,避免用户可控制的参数拼路径。
6.3 禁止爬虫与防止源码被直接查看
前端项目如果不想被爬虫收录或通过源码分析接口结构,Nginx层面也能做一定拦截,比如禁止非浏览器UA访问敏感路径:
location ~* \.(git|svn|env|log)$ { deny all; } if ($http_user_agent ~* (python-requests|curl|wget|scrapy) ) { return 403; }不过这东西防君子不防小人,真正的爬虫会伪装UA,属于基础加固,不能依赖它达到绝对安全。核心接口该做的鉴权和限流还是要后端好好做。
7. 补充几个Nginx跨域配置的实用细节
7.1 add_header与继承关系的理解
Nginx里的add_header有个容易忽略的规则:如果在某个location里写了自己的add_header,那它不会继承上一级server里的add_header(除非加了always且处理好继承细节)。所以我在实际配置时,要么把所有跨域头都在最顶层写统一,要么在每个location里写全,避免“明明配了但没生效”的困惑。
7.2 用include拆分配置文件
跨域配置如果散落在多个server块里,又长又乱。我会把公共的CORS配置提出来,单独存为一个文件,然后在需要的地方include进来:
# /etc/nginx/conf.d/cors.inc add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With"; add_header Access-Control-Allow-Credentials true;使用时:
server { location /api/ { include cors.inc; proxy_pass http://127.0.0.1:8080/; } }这样一行include就能把通用跨域头带进来,维护起来轻松多了。要注意的是include的文件路径必须放在Nginx能读到的地方,权限也别搞得太开放,毕竟这算网站入口配置的一部分。
7.3 动态Origin与静态Origin的选择
如果在公司内网、控制严格的环境里,所有前端域名都是已知的,直接写死多个允许的Origin也行,性能最好。但如果前端域名经常动(比如多级测试环境、动态域名),务必用$http_origin+map方式动态反射,这样能避免开发环境频繁改配置。
我在外网项目上踩过一次教训:认为前端域名固定,在配置里写死了Access-Control-Allow-Origin: https://fixed.example.com,结果市场部临时做了个落地页域名,接口全部跨域失败,大晚上的还得爬起来改Nginx。从那以后只要是给外部用的接口,一律$http_origin+ 白名单,再也没出过类似的紧急事故。
8. 结束语:Nginx跨域的经验之谈
跨域的坑我在不同项目里踩了不止一遍,但用Nginx处理得越久,越觉得它不只是“前端问题的土办法”,而是整个前后端协作架构里很重要的一层。改业务代码解决跨域,往往只能管一个项目;在Nginx层面解决,整个环境的所有项目都能受益,后端不用动,前端也不用为不同环境写一堆判断逻辑。
我个人现在处理跨域问题的习惯是:先用curl确认后端到底有没有正常返回数据,确认是跨域拦截后,优先走反向代理,其次是响应头方案;配置改完必须nginx -t验证,再reload,最后在浏览器里看Network确认预检请求和请求头都符合预期。这套流程虽然简单,但几乎覆盖了90%以上的跨域问题。
最后送一个实用小技巧:配置里给location加上expires、gzip等静态优化项,配合跨域配置一起管理,会让整个网站在交付后跑得更稳,这些问题虽然看起来和跨域无关,但所有Nginx配置放在一起统一治理,才是真正省心的长期方案。