浏览器跨域九种解决方案:CORS、预检请求与代理排查
发布时间:2026/10/1 3:54:35来源:尧图网络
跨域这个问题我在面试里问过不下五十个人能把 CORS 预检请求的触发条件讲清楚的不超过五个。更多人是在真实项目里被控制台那行has been blocked by CORS policy卡住半天然后去搜索引擎翻出九种所谓的解决办法挨个试一遍试到哪个能用算哪个。这种做法在项目赶工期的时候管用但下一次换个场景又得重新试一遍因为你没搞清楚每种方法背后的边界条件。我把这些年处理浏览器跨域问题的方法整理成九种从最正统的服务器响应头配置到开发阶段的代理转发再到几近淘汰的 iframe 黑科技以及最后那种只能用于本地调试、千万不能上线的极端手段。每种方法我会说清楚它解决的是哪一类跨域场景、适用条件是什么、坑在哪里。读完你应该能做到拿到一个跨域报错先判断它属于哪一类再直接选对应方案而不是靠试。1. 同源策略到底拦住了什么1.1 协议、域名、端口三个必须同时相同浏览器判断两个地址是否同源看的是协议、域名、端口这三个元素是否全部相同。任意一个不同就是跨域。https://shop.example.com与http://shop.example.com协议不同跨域https://api.example.com与https://shop.example.com域名不同跨域。注意子域也算不同http://localhost:5173与http://localhost:8080端口不同跨域这里有个特别容易踩的点localhost和127.0.0.1也不算同源。它们虽然指向同一台机器但对浏览器来说是两个不同的主机名。我见过一个团队因为前端跑在localhost、后端写在127.0.0.1导致带凭证的请求怎么都过不去查了一下午才发现是这个原因把两边统一成localhost就正常了。还有一层需要理解跨域拦截是浏览器单方面的行为不是服务器拒绝了你。请求其实已经发出去了服务器也正常处理并返回了只是浏览器在把响应交给你的 JS 代码之前检查了一下响应头里有没有允许的凭证没有就把响应内容扣下了。这个认知很重要因为它解释了后面第 1.3 节的现象。1.2 简单请求与预检请求的分水岭浏览器把跨域请求分成两档这是理解所有 CORS 问题的地基。简单请求同时满足以下全部条件才算请求方法是GET、HEAD、POST三者之一请求头只包含浏览器自动添加的安全头如Accept、Accept-Language、Content-Language如果手动设置了Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain这三种只要有一条不满足比如你把Content-Type设成了application/json或者加了个Authorization头浏览器就会先发一个OPTIONS请求去问路也就是预检请求preflight。预检请求的流程是这样的浏览器先发OPTIONS带上Access-Control-Request-Method和Access-Control-Request-Headers说明自己真正想干什么服务器如果同意返回一组Access-Control-Allow-*头浏览器收到后确认无误才发出真正的业务请求。整个过程每个跨域接口至少两次往返这也是为什么很多团队在性能优化时会考虑减少预检。检查项简单请求预检请求请求方法仅 GET / HEAD / POST任意方法Content-Type仅三种表单类型含 application/json 等自定义请求头不允许允许但需服务端声明网络往返1 次至少 2 次是否能带 Cookie视配置而定视配置而定1.3 为什么 Postman 和 curl 一点问题都没有这是新手最迷惑的地方同样的地址Postman 里返回得好好的浏览器里就报错。原因前面提过跨域是浏览器为了保护用户数据而施加的限制。浏览器里跑着来自各个网站的脚本如果没有同源策略你在 A 网站打开的页面里的脚本就能悄悄读走 B 网站的邮箱内容、转账记录。Postman 和 curl 是本地工具没有某个网站的脚本这个身份概念自然不受约束。所以排查跨域问题时第一步永远是确认用 curl 测通不通。如果 curl 也不通那是接口本身的问题跟跨域没关系别在 CORS 配置上浪费时间。curl -i -X OPTIONS https://api.example.com/user/list \ -H Origin: https://shop.example.com \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: content-type,authorization这条命令可以直接模拟浏览器发出的预检请求把返回的响应头贴出来看比在浏览器里翻 Network 面板高效得多。2. 服务端方案CORS 响应头与它的坑2.1 五个响应头逐项拆解CORS 是 W3C 标准方案现在新项目基本都该用它。核心就是服务端在响应里加几个头。Access-Control-Allow-Origin最关键的字段。填具体源如https://shop.example.com或者*。注意它只接受一个值你不能写https://a.com,https://b.com浏览器不认。Access-Control-Allow-Methods允许的方法列表如GET,POST,PUT,DELETE,OPTIONS。Access-Control-Allow-Headers允许的自定义请求头。你的前端如果用到了Authorization、X-Token、X-Requested-With都得在这里列出来。Access-Control-Allow-Credentials是否需要携带 Cookie、Authorization 这类凭证值为true或false。Access-Control-Max-Age预检结果缓存多少秒减少OPTIONS请求次数。Access-Control-Expose-Headers这个常被忽略。默认情况下前端 JS 只能读到几个基础响应头如果你想读Content-Disposition下载文件名或者自己定义的X-Total-Count分页总数必须在这里声明。举个 Node.jsExpress的完整配置app.use((req, res, next) { const origin req.headers.origin; const allowList [ https://shop.example.com, https://admin.example.com ]; if (allowList.includes(origin)) { res.setHeader(Access-Control-Allow-Origin, origin); res.setHeader(Access-Control-Allow-Credentials, true); } res.setHeader(Access-Control-Allow-Methods, GET,POST,PUT,DELETE,OPTIONS); res.setHeader(Access-Control-Allow-Headers, Content-Type,Authorization,X-Token); res.setHeader(Access-Control-Expose-Headers, X-Total-Count,Content-Disposition); res.setHeader(Access-Control-Max-Age, 7200); if (req.method OPTIONS) return res.sendStatus(204); next(); });注意这里做了白名单判断而不是无脑回显req.headers.origin。因为一旦开了Allow-Credentials: true回显任意来源等于把凭证接口对整个互联网敞开这是很危险的做法。2.2 缓存预检请求省掉一半的 OPTIONSAccess-Control-Max-Age设多长是有讲究的。各浏览器有各自的上限超过上限会被截断浏览器Max-Age 上限秒Chrome / Edge7200Firefox86400Safari600设 7200 是比较稳妥的值两小时内同一个接口的同一组请求头不会重复发预检。但要注意这个缓存是按「源 方法 请求头组合」分别缓存的前端不同请求头之间不共享。如果你的接口请求头多变比如每次都带不同的自定义头预检缓存的命中率会很低这时候更值得考虑的是代理方案而不是继续加大 Max-Age。2.3 带 Cookie 时的三个硬性条件带凭证的跨域是最容易出问题的一类。有个前端同事跟我说接口返回 200 了但用户信息为空查了半天是 Cookie 没带上。要成功携带 Cookie必须同时满足服务端返回Access-Control-Allow-Credentials: trueAccess-Control-Allow-Origin必须是具体的源绝对不能是*。这是硬性规定浏览器会直接拒绝前端请求需要设置withCredentials原生 XHR 是xhr.withCredentials trueaxios 是withCredentials: truefetch 是credentials: include还有 Cookie 自身的属性问题。如果后端设置 Cookie 时用了SameSiteLax或SameSiteStrict跨站请求根本不会带上这个 Cookie跟在跨域头里怎么配置都没用。跨站场景需要设成SameSiteNone; Secure而Secure又要求必须是 HTTPS。这三个条件是连环扣缺一环整条链路就断。提示OPTIONS预检请求本身不携带 Cookie所以如果你的鉴权中间件把OPTIONS也拦下来返回 401预检就会失败真正的业务请求根本发不出去。正确的做法是在鉴权逻辑之前放行OPTIONS请求。2.4 常见 CORS 报错对照表实际排查时浏览器控制台报的那句话已经把原因说得比较清楚了我整理了一份对照表控制台报错关键词真实原因处理方向No Access-Control-Allow-Origin header服务端完全没返回该头检查中间件顺序是否被异常分支跳过contains multiple values该头被设置了两次常见于 Nginx 和上游服务都加了一遍Credentials flag is true, but Access-Control-Allow-Origin is *带凭证却用了通配符改为回显具体源Request header field xxx is not allowedAllow-Headers 缺字段补上该请求头Method PUT is not allowedAllow-Methods 缺方法补上该方法Response to preflight request doesnt pass预检被鉴权拦截放行 OPTIONS情人节配置 CORS 时最常见的错误就是在 Nginx 和代码里各配了一份两边都对但合在一起重复了。排查方法很简单把响应头全部打印出来数一遍就行。3. 代理转发的三条路线3.1 开发阶段的 devServer 代理本地开发时前端跑在 5173后端跑在 8000跨域。最省事的办法是不让浏览器参与跨域判断——把请求交给一个同源的中间层去转发。Vite 的配置// vite.config.js export default { server: { proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true, rewrite: (path) path.replace(/^\/api/, ) } } } }Webpack DevServer 里是类似的devServer.proxy。原理是一样的浏览器请求http://localhost:5173/api/user这是同源的不跨域开发服务器收到后以服务器身份去请求http://127.0.0.1:8000/user服务端之间的请求不受同源策略约束。changeOrigin: true这个选项很有用它会改写请求头里的Host字段让后端以为请求是发给自己的。有些后端框架或网关会根据 Host 做路由或校验不开这个可能返回 404 或 403。这里的坑在于这套配置只对本地开发服务器生效npm run build出来的静态文件由 Nginx 或其他服务器托管时代理配置完全不生效。这就是为什么很多人抱怨开发环境好好的打包部署后全报跨域。3.2 Nginx 反向代理生产环境的常规解生产环境最常见的是前后端分离部署前端静态文件在https://www.example.com后端在https://api.example.com:8080。用 Nginx 把 API 路径代理到后端浏览器眼里所有请求都是同源的。server { listen 443 ssl; server_name www.example.com; location / { root /var/www/dist; try_files $uri $uri/ /index.html; } location /api/ { 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; } }proxy_pass末尾那个斜杠是关键。写成http://127.0.0.1:8080/会把/api/前缀去掉/api/user变成/user写成http://127.0.0.1:8080无斜杠则保留完整路径/api/user原样转发。这两种行为完全不同配错了要么 404 要么路由错乱是 Nginx 新手最常见的坑。后面三个X-Forwarded-*头是给后端用的。代理之后后端拿到的RemoteAddr全都变成了 Nginx 所在机器的地址真实的客户端 IP 丢失了。后端的日志、限流、风控都依赖真实 IP所以必须通过这几个头传递。后端读取时按X-Forwarded-For里的第一个非内网地址取。注意X-Forwarded-For是可以被客户端伪造的。如果 Nginx 前面还有一层 CDN 或负载均衡直接用$proxy_add_x_forwarded_for会把伪造值也拼进去。稳妥的做法是在最外层可信代理处覆盖这个头而不是追加。3.3 网关或 BFF 层统一收口如果后端服务很多用户服务、订单服务、支付服务各自一个域名给每个服务逐个配 CORS 是灾难。更好的做法是在最前面放一个统一出口所有前端请求都发给它由它按路径分发。这个出口可以是 Spring Cloud Gateway、Kong、APISIX 这类网关也可以是一个专门的 BFFBackend for Frontend服务。网关层统一处理 CORS 头、鉴权、限流业务服务内部只处理业务逻辑。这么做的好处很实在CORS 配置只有一份改一次全局生效预检请求在网关层就被消化掉不会打到后面的业务服务跨域报错也不用来回找各个服务团队排查。代价是网关本身需要维护团队要有人懂它的配置。小项目一两个后端服务的量级用 Nginx 就够了上网关属于过度设计。3.4 代理之后怎么拿到真实的请求地址前端配了代理之后经常有人困惑request.url拿到的是/api/user而不是http://api.example.com/user。这是正常的。代理改变的是浏览器发出的请求路径前端的代码里根本感知不到后端真实地址。如果业务逻辑需要真实地址比如生成回调 URL、拼接文件下载链接只能通过环境变量或后端返回的配置来获取不能指望从请求对象里读出来。后端那边获取真实客户端信息则靠X-Forwarded-For前面已经说过。如果用的是 Django需要额外配置# settings.py USE_X_FORWARDED_HOST True SECURE_PROXY_SSL_HEADER (HTTP_X_FORWARDED_PROTO, https)不配这个Django 生成的重定向链接会是http://开头的在 HTTPS 站点上会触发混合内容拦截。4. 老技术与边界场景方案4.1 JSONP只活了十几年的历史方案JSONP 的思路是利用script标签不受同源策略限制这一点。script src...可以加载任意域的脚本于是约定用callback参数让服务端把数据包在函数调用里返回。?php // 服务端 $callback $_GET[callback] ?? ; // 必须严格校验只允许字母数字下划线 if (!preg_match(/^[a-zA-Z0-9_]$/, $callback)) { http_response_code(400); exit(invalid callback); } header(Content-Type: application/javascript); $data [code 0, msg ok, data [id 1]]; echo $callback . ( . json_encode($data) . );;前端这样调用function handleData(res) { console.log(拿到数据, res); } const script document.createElement(script); script.src https://api.example.com/user?callbackhandleData; document.body.appendChild(script);JSONP 有几个致命限制只能发 GET 请求没法提交表单、上传文件没法自定义请求头所以带不了 Token出错时没有状态码只能靠约定字段判断还有 XSS 风险如果服务端不校验callback参数攻击者可以构造callbackalert(1)//这样的参数注入脚本。现在除非要对接一个很老的外部系统否则不该再选它。唯一还值得留意的场景是某些第三方接口老版本的地图、支付回调页面仍然只提供 JSONP 形式那就没办法只能用它。4.2 document.domain 加 iframe已经被现代浏览器淘汰这个方案针对的是主域相同、子域不同的场景比如a.example.com想读b.example.com里的内容。做法是两边页面都执行document.domain example.com把同源判断放宽到父域。// a.example.com 页面和 b.example.com 页面里都执行 document.domain example.com;它有两个硬性限制只能放宽到共同的父域example.com和example.org之间没法用而且document.domain不能设为公共后缀比如.com。更关键的是这个方案正在被淘汰。Chrome 已经对document.domain的使用发出弃用警告并且正在推动移除。原因是这个机制会破坏同源策略提供的隔离保证让本来就该分开的站点能互相访问。同样的功能现在应该用postMessage实现。如果你的老项目里还在用document.domain我的建议是把它列入技术债清单找机会替换掉别等浏览器彻底不支持的那天再被动处理。4.3 postMessage跨窗口通信的现代做法postMessage是目前跨窗口、跨 iframe 通信的标准方案。它不依赖任何放宽同源策略的手段而是双方显式地交换消息。发送方const iframe document.getElementById(childFrame); iframe.contentWindow.postMessage( { type: getUserInfo, payload: { uid: 123 } }, https://child.example.com );接收方window.addEventListener(message, (event) { // 必须校验来源 if (event.origin ! https://parent.example.com) return; if (!event.data || event.data.type ! getUserInfo) return; // 处理业务 event.source.postMessage({ type: userInfo, payload: {...} }, event.origin); });第二参数targetOrigin一定要写明确的域名不要图方便写*。写*意味着你发出的消息可以被任何加载了你的页面的窗口读取如果消息里包含用户信息或 Token直接就是数据泄露。接收端同样必须校验event.origin不能只看消息格式对不对。这是唯一能确认消息来源的方式event.data里的任何字段都不可信。5. 非 HTTP 通道与部署层面的方案5.1 WebSocket 与 SSE 为什么不受同源策略约束WebSocket 的握手虽然是用 HTTP 发起的但协议规定它不受同源策略限制浏览器不会为 WebSocket 连接做 CORS 检查。这意味着任何一个网页都可以连接到你的 WebSocket 服务。这反而是个安全问题。因为同源策略帮不上忙校验必须由服务端自己做——在握手时读取Origin请求头判断来源是否在白名单里不在就拒绝连接。// Node.js ws 服务端握手校验示例 wss.on(connection, (ws, req) { const origin req.headers.origin; const allowList [https://www.example.com]; if (!allowList.includes(origin)) { ws.close(1008, origin not allowed); return; } // 正常业务 });SSEServer-Sent Events用的EventSource则和普通请求一样受同源约束但它支持 CORS可以跨域使用前提是服务端返回正确的 CORS 头并且前端构造EventSource时开启withCredentials。通道是否受同源约束服务端需要做什么WebSocket否主动校验 OriginSSE是可使用 CORS返回 CORS 头普通 fetch/XHR是返回 CORS 头或用代理5.2 同源打包部署从源头消灭跨域最彻底的方法是不让跨域出现。把前端打包产物交给和后端同一个服务或同一个 Nginx 来托管路径上分成/和/api/浏览器眼里从头到尾只有一个源。这种方式的好处不只是省掉 CORS 配置。Cookie 的域变得简单不用再纠结SameSite怎么设CDN 缓存和跨域请求的缓存策略能统一也不用处理预检请求带来的额外往返。代价是前后端发布耦合了。前端改一行代码要重新部署整个包这在快速迭代的项目里不太可接受。折中的做法是走 Nginx 反代第 3.2 节既有独立发布的能力又能保持浏览器侧同源。5.3 用 hosts 映射统一本地域名本地开发还有一种办法改 hosts 文件把www.example.com和api.example.com都指向127.0.0.1然后本地服务分别监听 80 端口下的不同路径或者用 Nginx 本地做路径分发。127.0.0.1 www.example.com 127.0.0.1 api.example.com这么做的好处是本地环境和线上环境的域名结构完全一致Cookie 的域、SameSite行为、第三方 Cookie 的限制在本地都能真实复现。很多本地好好的上线就挂的问题根源就是本地用localhost跑线上用真实域名两者的 Cookie 行为根本不一样。缺点是配置麻烦需要本地装 Nginx、维护 hosts而且换台机器就得重来一遍。团队可以写个初始化脚本把 hosts 修改和 Nginx 模板生成自动化。5.4 浏览器禁用安全检查只能在调试时用Chrome 有个启动参数可以关掉同源策略检查chrome.exe --disable-web-security --user-data-dirD:/temp/chrome-debug加上这个参数浏览器里所有跨域限制都不存在了请求随便发。这个方法的问题不在技术在于绝对不能用来判断问题。有同事曾经用这种方式让本地页面跑通了就断定是浏览器的问题不是我们代码的问题结果上线后所有用户都报错。它唯一的用途是当你想快速验证某个跨域失败是不是由 CORS 引起的用它做一次对照实验。而且一定要配套--user-data-dir指定一个独立的用户目录不要用你日常使用的浏览器配置。否则那个禁用安全的配置会被带到你平时上网的窗口里风险很大。6. 九种方案的选型对照与实际排查6.1 一张表定方案九种方法按适用场景整理如下序号方案适用场景主要限制1服务端 CORS 头前后端分离、新项目带 Cookie 时配置条件多2JSONP对接只支持 JSONP 的老接口只能 GET有 XSS 风险3开发服务器代理本地开发阶段打包部署后失效4Nginx 反向代理生产环境前后端分离需要运维配合5网关 / BFF 统一出口多后端服务的微服务架构维护成本高6document.domain同主域子域的老项目已被浏览器弃用中7postMessageiframe 与父窗口通信需手动校验来源8WebSocket / SSE实时推送场景服务端需自行校验 Origin9同源打包部署 / hosts 映射追求零跨域配置发布耦合或配置繁琐选择逻辑其实很简单能改后端配置就用 CORS不想动后端就用代理实时通信场景直接上 WebSocket对接老系统才考虑 JSONP。6.2 一次完整的排查过程复盘说一个我印象比较深的案例。前端是 Vue 项目后端是 Django线上环境用户登录后调接口一直 401。第一步看控制台。报错是Response to preflight request doesnt pass access control check: It does not have HTTP ok status.。这句话说明预检请求OPTIONS被拒了返回的状态码不是 2xx。第二步用 curl 模拟预检请求curl -i -X OPTIONS https://api.example.com/user/profile \ -H Origin: https://www.example.com \ -H Access-Control-Request-Method: GET \ -H Access-Control-Request-Headers: authorization返回 401。确认是预检被鉴权中间件拦了。第三步看 Django 的中间件配置。发现有一个自定义的鉴权中间件排在 CORS 中间件之前它对所有非白名单路径都要求合法 Token而OPTIONS请求不带 Token直接被拦。第四步修中间件在鉴权逻辑最前面放行OPTIONSclass AuthMiddleware: def __call__(self, request): if request.method OPTIONS: return self.get_response(request) # 原有鉴权逻辑第五步重新用 curl 验证返回 200 并且带了正确的 CORS 头问题解决。这个案例的关键在于报错信息里说的预检失败不一定是 CORS 头配错了也可能是你的业务逻辑把 OPTIONS 请求拦了。很多人一看到 CORS 报错就去改响应头改了半天发现根本没用因为请求压根没走到加头的那一步。6.3 几个容易忽略的细节关于调试有件事值得专门说跨域错误在浏览器的 Network 面板里显示得不完整响应头可能被截断。想看完整的请求和响应头用 curl或者用抓包工具。我在排查疑难问题时第一步永远是 curl看原始报文不依赖浏览器的转述。关于路径匹配Access-Control-Allow-Origin的匹配是精确字符串比较没有通配符规则。https://example.com和https://example.com/末尾差一个斜杠都算不同带端口和不带端口也是两个值。所以做白名单匹配时建议统一去掉末尾斜杠再比。关于环境差异同一个项目在测试环境正常、生产环境报错八成是两个环境的域名结构不同。测试环境可能前后端在同一域名下不同路径天然同源生产环境拆成了两个域名跨域。这种情况不能靠临时加 CORS 头糊过去得先把环境差异梳理清楚。最后提一句名词混淆的事。跨域这个词在芯片设计领域指的是跨时钟域CDC跟浏览器完全是两回事讨论的是信号在不同时钟频率之间传递时的握手与同步问题跟本文没有任何交集。如果你在搜索跨域时看到这类内容那是搜错了方向。我个人在这个问题上的体会是跨域本身不难难的是报错信息不指向真正的根因。把同源策略的判定规则、预检请求的触发条件、代理的转发路径这三件事记牢九种方法里的任何一种你都能想明白它为什么有效、什么时候会失效。真遇到卡住的时候先 curl先看原始响应头比在浏览器里反复刷新有效得多。
网站建设高端定制企业官网