1. 一个响应头为什么装不下三个域名
Access-Control-Allow-Origin这个响应头,我在过去几年里被问到的次数大概仅次于 404。问法几乎一模一样:我们前端有www、app、m三个站点,还有几个合作方的页面要调我们的接口,为什么这个头不能直接写三个域名,用逗号隔开行不行?这个问题本身没错,错的是提问的默认前提——它假设Access-Control-Allow-Origin是一个"列表型"的头,而它从设计之初就是个"单值"的头。这篇就围绕 CORS 跨域里 Access-Control-Allow-Origin 面对多域名需求时的处理方式,把协议层面的约束、常见的错误写法、动态反射的正确姿势、以及各个技术栈的落地代码一次讲透。适合已经跑通过最简单的跨域配置、但一遇到多域名和带 Cookie 就抓瞎的同学。
1.1 先把"跨域"这个词的歧义说清楚
搜索"跨域"的时候,你会看到一堆看起来毫不相干的结果:BGP 跨域对接、跨时钟域处理、PCIe 弹性缓存怎么搞定跨时钟域、跨时钟域握手。这些和浏览器安全模型里的跨域完全是两码事。硬件和网络工程里的"跨域"指的是跨越两个时钟域或两个自治系统,解决的是信号同步和路由可达性;而前端和后端天天吵的跨域,指的是同源策略下的资源访问限制,解决的是"浏览器凭什么允许 A 站点的脚本读取 B 站点的响应"。
之所以要先花一段把这个歧义点破,是因为我见过不止一个团队在搜索资料时被带偏,把时钟域同步的方案当成了 CORS 的解决方案。你只需要记住一句话:本文讨论的跨域,全程是浏览器同源策略语境下的 CORS,跟证书、跟路由协议、跟时钟频率都没关系。同源策略判定的三要素是协议、域名、端口,三者任意一个不同,就算跨域。https://www.example.com和https://api.example.com不同源,http和https不同源,8080和8000不同源。
1.2 Access-Control-Allow-Origin 的取值只有一个位置
按照 Fetch 标准的定义,Access-Control-Allow-Origin的值只能是下面三种之一:一个单独的*,或者一个单独的 origin(形如https://www.example.com,注意不带路径、不带结尾斜杠),或者字面量null。标准里没有任何"列表"的语法。你写https://a.com, https://b.com,浏览器解析这个值的时候会把它当成一个完整的字符串去和当前页面的 origin 做逐字符比对,比对结果必然是不相等,于是判定为不允许。
这里有个细节很多人没意识到:浏览器不是"取第一个"或者"取最后一个",它是整串比较。所以逗号拼接的写法在某些老版本浏览器里可能"碰巧"能用,纯粹是因为解析实现的历史差异,不是规范允许的行为,换一个浏览器就崩。
有一类特殊的场景是响应头重复出现多次,比如:
Access-Control-Allow-Origin: https://a.example.com Access-Control-Allow-Origin: https://b.example.com用 curl 看确实能看到两行,但浏览器在做 CORS 校验时,对于单值头,会把重复的值按逗号合并成一个字符串,结果还是https://a.example.com, https://b.example.com,回到上面那个逐字符比对的死路。所以这条路也是走不通的。
1.3 浏览器收到不匹配的值之后做了什么
理解浏览器的处理顺序,对排查问题非常关键。一次跨域请求大致是这样走的:浏览器发现请求跨域,先判断是不是简单请求,如果是简单请求就直接发出去,拿到响应后检查Access-Control-Allow-Origin是否和当前 origin 精确匹配;不匹配的话,响应体虽然已经到了浏览器,但不会交给 JS,控制台报错,fetch直接 reject。
如果是非简单请求(比如带Content-Type: application/json、带自定义头、或者用了 PUT/DELETE),浏览器会先发一个OPTIONS预检请求。这个预检请求本身也要通过 CORS 校验,校验不通过的话,真正的业务请求压根就不会发出去。这就解释了为什么有些同学在服务端日志里只看到 OPTIONS 记录,看不到 POST 记录——请求被浏览器拦在了预检阶段。
我在实际排查时常用的判断方法是看服务端访问日志。如果日志里有 OPTIONS 但没有后续的真实请求,问题一定在预检响应头;如果真实请求发出了、响应也 200 了,但前端还是报错,问题就在实际响应的头或者中间的缓存层。
2. 四种"看起来能行"的写法,实测全都不行
每次有人问多域名怎么办,我通常会先让他把已经试过的写法讲一遍。有意思的是,绝大多数人试过的坑是高度重合的,基本就那么四种。把它们逐一拆开讲清楚为什么不成立,比直接甩一个正确方案有用得多,因为你知道边界在哪里,下次就不会再绕回去。
2.1 逗号拼接:最像答案的错误答案
Access-Control-Allow-Origin: https://a.com,https://b.com这种写法之所以流行,是因为 HTTP 里确实有大量头是允许逗号分隔列表的,比如Accept、Cache-Control、Access-Control-Allow-Headers、Access-Control-Allow-Methods。很多人凭直觉认为Allow-Origin也一样。
但规范明确把它定义成单值。我实测过的表现是:Chrome 和 Firefox 都会直接拒绝,控制台给出的提示是The 'Access-Control-Allow-Origin' header contains multiple values 'https://a.com, https://b.com', but only one is allowed.。注意这句话的措辞——"contains multiple values",浏览器已经明确告诉你它把这串东西当成了多个值,而多个值是不允许的。
有意思的是,同样的问题在Access-Control-Expose-Headers上就是合法的,因为那个头本来就支持列表。这种"同一组头里有的支持列表有的不支持"的不一致,是 CORS 规范里最容易让人翻车的地方之一。
2.2 重复设置响应头:curl 能看到,浏览器不认
这个坑的迷惑性更强,因为你在本地用 curl 调试的时候,确实能看到两行Access-Control-Allow-Origin,看起来"配置生效了"。于是以为是浏览器缓存问题,清了缓存重试,还是不行。
原因就是上面说的合并规则。另外,很多 Web 服务器和框架在处理重复头时的行为并不一致:Nginx 的add_header如果写在嵌套的location里,可能只在某一层生效;某些中间件在设置已经存在的头时会追加而不是覆盖。这就导致你本地测出来和线上表现不一样。
我一般建议:在排查阶段用curl -i看原始响应,一旦发现有重复的Access-Control-Allow-Origin,先别管值对不对,先把重复的去掉,确保只有一个来源在设置这个头。多来源叠加设置同一个 CORS 头,是多域名场景下极常见的隐性故障源——比如网关设了一遍、应用里 Spring 的@CrossOrigin又设了一遍。
2.3 无脑返回*:简单但会丢掉 Cookies
Access-Control-Allow-Origin: *是所有方案里最省事的,一行搞定所有域名。如果你们的接口是纯公开的、不需要携带用户身份,那这就是最优解,没必要搞复杂。
但只要涉及登录态,这条路立刻断掉。当请求带了credentials(fetch的credentials: 'include'、XHR 的withCredentials = true),浏览器会强制要求Access-Control-Allow-Origin不能是*,而且Access-Control-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'。
这个限制的动机很直白:*表示"任何站点都可以读这个响应"。如果不加限制,你带着用户 Cookie 请求银行接口,攻击者的页面也能读到响应内容,那就是 CSRF 的加强版了。所以规范把*和credentials设计成互斥,逼你显式列白名单。
顺带说一个安全提醒:搜索里那条"cors 配置错误(反射 origin + credentials=true)"说的就是这个场景。如果你的服务端把请求里的Origin原样反射回去,不做任何校验,同时credentials又是true,那效果等同于把用户登录态暴露给任意站点。这个配置错误在扫描器眼里是一个非常明确的高危信号。白名单校验不是可选项。
2.4 前端一个域名一个域名地改
还有一种"方案"是每次有新前端域名,就去后端加一个常量,重新发版。单域名或者域名很少且极少变更的团队,这么干也不是不行,至少没有安全风险。
但它的问题在于组织成本。域名一多,谁来维护这个列表?测试环境、预发环境、生产环境各有一批域名,本地开发还有http://localhost:5173这种随机的端口。每次加域名都要走一遍发版流程,时间长了列表必然会乱,出现"这个域名删了但没人知道它在用"的情况。一旦误删,线上就是整个站点跨域失败。
我的经验是:把允许的来源做成可配置的白名单(配置中心、环境变量、配置文件都行),代码里的匹配逻辑保持固定,加域名变成改配置而不是改代码,这样维护成本会低很多。
3. 白名单 + 动态反射:多域名场景的标准答案
绕了一圈,多域名场景下的正确做法其实只有一个词:反射。服务端读取请求头里的Origin,拿它去和你维护的白名单做比对,比对通过就把这个 origin 原样写回Access-Control-Allow-Origin,比对不通过就不写这个头(或者返回 403)。这样每次响应里始终只有一个值,符合规范,同时支持任意多个域名。
3.1 反射的完整逻辑
完整流程拆成四步:第一步取Origin请求头,注意它可能是空的(同源请求、服务端到服务端调用、部分特殊场景下会是null);第二步做归一化处理,比如统一转小写、去掉末尾斜杠,避免因为大小写差异导致误判;第三步用白名单做精确匹配;第四步匹配成功后设置三个头——Access-Control-Allow-Origin写成该 origin、Access-Control-Allow-Credentials: true、Vary: Origin。
关键点在于"精确匹配"。我见过太多人出于方便写成origin.includes('example.com'),这一行代码就足以让白名单形同虚设,因为https://evil-example.com和https://example.com.attacker.net都能通过。正确做法是把白名单里的每个域名做一次标准化,然后用全等比较,或者如果确实需要通配子域名,用严格锚定的正则,比如^https://[a-z0-9-]+\.example\.com$,确保^和$都在。
还有个小细节:如果你要支持本地开发,白名单里通常会加http://localhost:5173这类条目。建议把这类条目和环境绑定,只在开发/测试环境的配置里出现,生产环境的白名单保持干净。我见过生产配置里躺着http://localhost:3000的,虽然不是致命问题,但审代码的人看到会问一句。
3.2 Vary: Origin 为什么不能省
这是整个多域名方案里最容易被漏掉、又最容易在生产环境炸掉的一行。Vary: Origin的作用是告诉所有的中间缓存(CDN、反向代理、Nginx 的 proxy_cache)——这份响应内容会随请求头Origin的不同而不同,缓存时要把 Origin 也算进缓存键。
如果你不写这一行会发生什么?设想https://a.example.com第一个发请求,服务端反射回来Access-Control-Allow-Origin: https://a.example.com,CDN 把这份完整响应缓存下来。紧接着https://b.example.com发同样的请求,CDN 直接命中缓存,把带着https://a.example.com的响应吐给它。B 站点的浏览器一看,Allow-Origin不匹配,跨域失败。
这种故障最坑的地方是它是间歇性的、跟访问顺序相关的。本地开发永远复现不出来,因为本地没有缓存层。上线之后偶发,而且换个顺序访问又好了,排查起来能耗掉一整天。我第一次遇到这个问题的时候,硬是先把 CDN 缓存清了一遍,问题消失,以为解决了,过了半天又出现,才想起来查响应头里有没有Vary。
3.3 校验代码放在哪一层
放在哪一层取决于你们的架构。如果有统一的 API 网关,放网关层最省事,一次配置管住所有后端服务,而且后端服务不用关心 CORS。如果没有网关,就用各框架自带的 CORS 中间件,放在所有业务路由之前。
最不建议的做法是在每个业务 Controller 或者每个处理函数里手写header('Access-Control-Allow-Origin: ...')。这样做的后果是响应头散落在几十个地方,改白名单要全局搜索,而且很容易漏掉某个分支路径(比如异常处理路径忘了加,导致错误响应没有 CORS 头,前端看到的是一个没有 CORS 头的 500,误以为是跨域问题)。
另外要注意顺序问题:CORS 中间件必须在鉴权中间件之前执行。因为预检请求OPTIONS通常是不带 Cookie 的,如果鉴权中间件拦在 CORS 前面,预检请求会先被 401 掉,浏览器拿不到 CORS 头,业务请求根本发不出去。这个顺序问题在 FastAPI、Express、Spring 里都踩过,表现形式完全一样:所有需要预检的接口全部跨域失败,但 GET 简单请求正常。
4. 带上 Cookie 之后规则会更严
带凭证的跨域请求是另一个量级的复杂度。前面提到*和credentials互斥,但实际开发中还有几个细节值得单独拎出来讲,因为它们在文档里往往一笔带过,在实践里却经常咬人。
4.1 前端也要配对设置
服务端配了Access-Control-Allow-Credentials: true只是完成了一半,前端必须同步声明要带凭证。用fetch是credentials: 'include',用 axios 是withCredentials: true,用原生 XHR 是xhr.withCredentials = true。如果前端没设,Cookie 不会跟着发,服务端拿不到会话,表现为"跨域通过了但用户一直是未登录状态"。
跨域场景下 Cookie 还有额外的限制,这个限制比 CORS 本身更容易让人困惑:即使请求成功,响应里的Set-Cookie也可能被浏览器丢弃。原因是现在主流浏览器要求SameSite=None的 Cookie 必须同时带Secure,而SameSite=None是跨站发送 Cookie 的前提。如果服务端下发的会话 Cookie 是默认的SameSite=Lax,跨站请求就不会带上它。我见过不少团队在这个点上耗了很久,最后发现是 Cookie 属性没配对,跟 CORS 配置一点关系都没有。
这里给出一个可以照抄的排查清单,遇到"跨域带了 Cookie 但登录态丢了"的时候按顺序过一遍:
| 检查项 | 期望值 | 常见错误 |
|---|---|---|
| 响应头 Allow-Origin | 具体域名,不能是* | 用了通配符 |
| 响应头 Allow-Credentials | true | 忘了设或拼写错误 |
| 请求是否带 Cookie | 请求头里有Cookie | 前端没开 credentials |
| Cookie 的 SameSite | None | 默认的Lax |
| Cookie 的 Secure | 有 | 只在 https 下需要 |
| Cookie 的 Domain | 与请求域名匹配 | 写成了错误的顶级域名 |
4.2 白名单匹配的两个经典漏洞
第一个是后缀匹配。origin.endsWith('example.com')这种写法,会被https://notexample.com命中。一定要加上分隔符判断,或者干脆用全等。
第二个是正则没锚定。[a-z]+\.example\.com这种模式,如果不加^和$,https://evil.com/?x=a.example.com这类构造就可能绕过。写白名单正则的时候,我的习惯是先写测试用例,把明显应该拒绝的字符串列出来跑一遍,通过了再上线。
还有一类边界情况值得提一句:Origin: null。这个值会出现在file://页面、沙箱化的 iframe、以及某些重定向场景里。如果你的白名单里有null这一项,等于对所有来源开放——任何攻击者都能用一个沙箱 iframe 伪造出null的 Origin。所以除非你非常清楚自己在做什么,白名单里不要出现null。
4.3 OPTIONS 预检的短路处理
预检请求要尽可能早地返回,不要让它穿透到业务逻辑层。理想的处理方式是:CORS 中间件识别到OPTIONS且带Access-Control-Request-Method头,直接返回 204,带上完整的 CORS 响应头,不进入业务路由。
这么做有两个好处。一是性能,预检请求量不小,每个真实请求前面都可能跟一个 OPTIONS,让它走完整条链路(数据库连接、鉴权、日志)纯属浪费。二是避免误报——如果 OPTIONS 打到了业务代码,很可能因为参数校验失败返回 400 或者 500,这个响应如果没有 CORS 头,前端会报跨域错误,把真正的错误信息掩盖掉,排查方向直接跑偏。
预检结果的缓存由Access-Control-Max-Age控制,单位是秒。设大一点(比如 600 到 3600)能显著减少预检请求数量。但注意 Chrome 对这个值有上限(目前是 2 小时),设再大也没用。另外,如果白名单是动态变化的,缓存时间设太长会导致新加的域名要等缓存过期才生效,这个权衡要根据实际情况定。
5. 五个技术栈的落地写法
前面讲的都是原理,这一段给可以直接抄的代码。每个框架内部对"多域名"的处理机制不太一样,有的直接支持传数组,有的需要自己写中间件,还有的需要借助服务器层的能力。
5.1 Nginx 网关层:用 map 做白名单
在网关层统一处理的好处是后端服务完全不用关心 CORS。Nginx 的map指令可以在配置阶段就把白名单映射好:
map $http_origin $cors_origin { default ""; "~^https://(www|app|m)\.example\.com$" $http_origin; } server { listen 443 ssl; server_name api.example.com; location /api/ { 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, OPTIONS" always; add_header Access-Control-Allow-Headers "Content-Type, Authorization, X-Requested-With" always; add_header Access-Control-Max-Age "600" always; add_header Vary "Origin" always; if ($request_method = OPTIONS) { return 204; } proxy_pass http://backend; } }这里有几个坑必须说。第一,add_header默认只对 2xx 和 3xx 生效,错误响应(4xx、5xx)不会带上这些头,一定要加always参数。这个坑的典型表现是接口正常时跨域没问题,一旦后端报错,前端看到的就是一个没有 CORS 头的错误响应,控制台提示跨域失败,实际是业务报错。第二,map里的正则如果匹配不上,返回空字符串,此时add_header Access-Control-Allow-Origin ""会设置一个空值的头,规范上这是不合法的。严格来说应该用if判断空值时不加这个头,但 Nginx 的if有"邪恶"之名,能不用就不用。实际工程中空值头的表现通常是浏览器判定不匹配然后拒绝,效果和没设一样,可以接受。
5.2 Spring Boot:直接传数组
Spring 的 CORS 支持是内建白名单机制的,allowedOrigins传一个数组,它内部会自动读请求的 Origin 做匹配然后反射,不需要你手写。
@Configuration public class CorsConfig implements WebMvcConfigurer { private static final List<String> ALLOWED = List.of( "https://www.example.com", "https://app.example.com", "https://m.example.com" ); @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins(ALLOWED.toArray(new String[0])) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }要注意allowedOrigins和allowCredentials(true)的组合。如果两个都用通配符,Spring 会在启动时直接抛异常——这是它在帮你避开那个安全陷阱。另外,如果你用了 Spring Security,还需要在 Security 的过滤链里显式开启 CORS(http.cors()),否则请求会在 Security 层被拦掉,Spring MVC 的配置根本执行不到。这是 Spring 项目里"配置写了但不生效"的最常见原因。
5.3 FastAPI:Starlette 中间件自带反射
FastAPI 的 CORS 是 Starlette 提供的,allow_origins传列表,内部会做精确匹配然后反射,行为符合预期。
from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware app = FastAPI() app.add_middleware( CORSMiddleware, allow_origins=[ "https://www.example.com", "https://app.example.com", ], allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["Content-Type", "Authorization"], max_age=600, )有个细节要注意:allow_origins和allow_origin_regex是两个独立参数,配了 regex 之后能满足更复杂的匹配需求,但正则写错的风险也随之增加。另外 FastAPI 里中间件的注册顺序是后注册的先执行,如果你的鉴权中间件写在 CORS 中间件后面注册,它会先跑,预检请求就会被拦。这个顺序反直觉,我在项目里踩过一次,表现就是所有 POST 跨域失败、GET 正常。
5.4 Express:手写中间件更可控
Node 生态里cors这个包用得最多,它的origin参数支持传数组、传函数、传正则。传数组时它内部做的就是白名单匹配加反射。如果你想完全掌控逻辑,手写一个也不复杂:
const ALLOWED = new Set([ 'https://www.example.com', 'https://app.example.com', ]); app.use((req, res, next) => { const origin = req.headers.origin; if (origin && ALLOWED.has(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'); res.setHeader('Access-Control-Max-Age', '600'); } res.setHeader('Vary', 'Origin'); if (req.method === 'OPTIONS') { return res.sendStatus(204); } next(); });用Set做精确匹配,天然避免了后缀匹配的漏洞。Vary头无条件设置(不管是否命中白名单),这样缓存层的隔离更彻底。
5.5 PHP 与 JSONP 的遗留场景
老项目里还有 PHP 直接用header()输出的情况,逻辑和上面一样:
$allowed = ['https://www.example.com', 'https://app.example.com']; $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; if ($origin !== '' && in_array($origin, $allowed, true)) { header('Access-Control-Allow-Origin: ' . $origin); header('Access-Control-Allow-Credentials: true'); } header('Vary: Origin'); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization'); header('Access-Control-Max-Age: 600'); http_response_code(204); exit; }至于 JSONP,它和 CORS 是两套并行的机制。JSONP 靠<script>标签不受同源策略限制这一点来绕过跨域,只支持 GET,而且需要服务端配合返回可执行的 JS 回调。现在还有少量存量接口在用,但新项目没有任何理由再用它。注意 JSONP 和 CORS 不能混着配,如果同一个接口既处理 JSONP 回调又设置 CORS 头,很容易出现响应体格式和响应头不匹配的诡异问题。
6. 从浏览器报错倒推配置错误
CORS 的报错信息其实写得挺清楚,只是很多人不去读,直接搜索报错全文然后抄一段配置,抄完发现不对再搜。我整理一下几种常见报错对应的真实病因。
6.1 三句报错文本分别是什么意思
第一句:has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource。字面意思就是响应里完全没有这个头。可能原因:服务端没配 CORS、CORS 配置没生效(中间件顺序问题)、或者响应是 4xx/5xx 而 Nginx 的add_header没加always。
第二句:The 'Access-Control-Allow-Origin' header contains multiple values ... but only one is allowed。响应头里出现了多个值,多半是重复设置或者逗号拼接。
第三句: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'。返回了*但请求带了凭证。改成反射具体 origin。
还有一个容易混淆的情况是响应本身是 502 或 504,网关直接把错误页返回了,压根没到后端,这种情况报错文本同样是没有 Allow-Origin 头,但根因是后端服务挂了,跟跨域配置无关。所以看到跨域报错的第一反应不要是改 CORS 配置,先看 Network 面板里这个请求的 status code 和完整响应头。
6.2 用 curl 复现预检请求
浏览器里能看到的调试信息有限,用 curl 模拟预检请求能拿到最原始的响应:
curl -i -X OPTIONS 'https://api.example.com/user/1' \ -H 'Origin: https://www.example.com' \ -H 'Access-Control-Request-Method: PUT' \ -H 'Access-Control-Request-Headers: content-type,authorization' curl -i 'https://api.example.com/user/1' \ -H 'Origin: https://www.example.com'第一条发预检,第二条发实际请求。对比两次响应的头,如果预检有Allow-Origin而实际请求没有,那说明你的 CORS 逻辑只处理了 OPTIONS 分支,漏了正常响应分支(Nginx 里的if写法很容易出这个问题)。如果两次都没有,说明 CORS 配置根本没生效。
6.3 打包部署后突然不通的典型原因
搜索里那条 "vue+django 打包部署后无法跨域" 是很典型的场景。本地开发时 Vue 通过 vite 或 webpack 的 devServer 代理转发请求,浏览器看到的是同源请求,压根不触发 CORS,所以一切正常。打包部署之后,前端静态文件在 Nginx 上,后端 Django 在另一个端口或域名,变成真跨域了,才发现后端从来没配过 CORS。
对应的另一条热搜 "vue 配置跨域代理后,如何获取我的真实的请求地址",讲的是代理带来的另一个副作用:代理层把请求转发给后端之后,后端通过request.get_host()拿到的是代理的地址,不是用户实际访问的域名。如果后端有基于 Host 的逻辑(比如生成回调地址、拼绝对 URL),需要代理层透传原始 Host(Nginx 的proxy_set_header Host $host;),后端也要相应地从X-Forwarded-Host之类的头里读取。
这两个问题的共同点是:开发环境的便利配置掩盖了生产环境的问题。我的建议是本地开发也尽量模拟真实拓扑,或者至少在部署到测试环境时完整走一遍真实域名的跨域流程,别等到上线才发现。
7. 几个容易被忽略的边界
7.1 CDN 缓存污染
前面提过Vary: Origin,这里再强调一下它在 CDN 场景下的具体表现。有些 CDN 默认不把Origin纳入缓存键,即使你设置了Vary,也可能需要在 CDN 控制台额外配置。判断方法很简单:用两个不同的 Origin 依次请求同一个 URL,看两次返回的Access-Control-Allow-Origin是否不同。如果第二次拿到的是第一次的值,缓存就污染了。
另外,如果接口响应本身是可以公开缓存的静态数据,还有一种思路是干脆不带凭证、返回*,把 CORS 问题降到最低。前提是业务允许。
7.2 本地代理绕行了跨域,但没绕掉线上
代理是开发期的好帮手,但要知道它做了什么:浏览器请求http://localhost:5173/api/xxx(同源),代理服务器把这个请求转发到http://127.0.0.1:8000/api/xxx。整个过程里浏览器看到的都是同源,所以 CORS 从未被触发。这意味着你本地测出来的"跨域没问题"是个假象,真正的跨域校验从来没发生过。
我见过有人因此误判:本地代理配好后,把所有跨域问题都归结为"部署环境的问题",结果查了半天发现是后端 CORS 从来没配。判断方法是在浏览器 Network 面板里看请求的 URL,如果显示的是本地地址而不是后端地址,那就是被代理了。
7.3 多域名证书和跨域没有关系
热搜里出现了"多域名 SSL 证书生成",这跟 CORS 是两个完全不同的层面。证书解决的是 HTTPS 握手时的身份验证问题,跨域解决的是同源策略下的响应读取权限问题。证书配错的表现是浏览器直接报证书错误、连页面都打不开;跨域配错的表现是页面正常、接口报错。
不过两者有一个交叉点值得提:如果同一个后端要在多个域名下提供服务,你说的可能是证书需要支持多域名(SAN 证书或多张证书),也可能说的是 CORS 白名单要支持多域名,还可能是反向代理要根据Host分发。搞清楚自己到底在解决哪一层的问题,能省掉大量无效搜索。
8. 我在多域名跨域上踩出来的几条经验
最后分享几条没什么技术含量、但确实能省时间的经验。第一条,把Vary: Origin当成和Allow-Origin同等重要的配置项写进代码模板,不要靠记忆。我因为这个头排查过两次线上间歇性故障,第二次之后我就把它写进了团队的项目脚手架里。第二条,白名单不要写在多个地方,网关一层就够,应用层不要再配一遍,两处配置打架比不配更难查。第三条,预检请求单独打日志,标记出来,出问题的时候一眼就能看出是预检没过还是实际请求没过。第四条,加域名的流程做成改配置加重启或热加载,不要改代码,改代码就会有人图省事顺手把匹配逻辑改成includes。
还有个小技巧:写 CORS 配置的时候,用一张便利贴记住三个必须同时满足的条件——Allow-Origin必须是具体域名、Allow-Credentials必须是true、前端必须开 credentials。三个里缺一个,带登录态的跨域就一定不通过。这个组合我写在过好几个项目的 README 里,新人上手时省了很多来回。