一次 withCredentials: true 引发的 CORS 跨域问题排查

·34 分钟阅读

前言

最近在开发环境中遇到一个比较典型、但排查过程有点绕的 CORS 跨域问题。

现象是:同一份前端代码、同一个接口、同样配置了 withCredentials: true,我这边请求被 Chrome 拦截,而同事电脑上却可以正常请求。

一开始很容易怀疑是浏览器差异、请求头差异,或者服务端 CORS 配置问题。但最终定位下来,真正原因是:同一个接口域名在不同网络环境下命中了不同服务入口,不同入口的 CORS 配置不一致

这篇文章会从这次真实问题出发,梳理:

  • withCredentials: true 是什么
  • 它和 CORS 跨域有什么关系
  • 为什么 Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: true 不能同时使用
  • OPTIONS 预检请求什么时候会出现
  • 为什么在 Chrome DevTools 里有时候看不到 OPTIONS
  • 如何通过 Remote Address、响应头和网络环境定位问题

一、问题背景

开发环境中,前端项目运行在:

http://localhost:5173

接口请求地址为:

https://api-server-stage.mofaxiao.com/challenge/h5/sendSms

前端使用 axios 发起请求,并配置了 withCredentials: true

const service = axios.create({
  baseURL: import.meta.env.VITE_BASE_URL,
  withCredentials: true,
})

在我的 Chrome 浏览器中,请求被 CORS 拦截,控制台出现类似错误:

PreflightWildcardOriginNotAllowed

PreflightWildcardOriginNotAllowed

但是同事使用同一份代码、同一个接口,在他的电脑上可以正常请求。

这就带来了几个疑问:

  1. withCredentials: true 到底做了什么?
  2. 为什么 Access-Control-Allow-Origin: * 会有问题?
  3. 这个请求到底有没有真正发到服务端?
  4. 为什么同一份代码,我被拦截,同事却正常?
  5. 为什么同样是 POST 请求,有时能看到 OPTIONS,有时看不到?

二、withCredentials: true 是什么?

withCredentials 是 XMLHttpRequest 中的配置项,axios 也沿用了这个配置。它的作用是:控制跨域请求时,是否携带用户凭证

这里的“用户凭证”通常包括:

  • Cookie
  • HTTP Authentication 认证信息
  • TLS 客户端证书

在普通前端业务里,最常见的就是:跨域请求时是否携带 Cookie

假设前端页面在:https://www.a.com,接口在 https://api.b.com 这就是跨域请求。

如果你直接发请求:

axios.get('https://api.b.com/user')

浏览器默认不会把 api.b.com 下面的 Cookie 带上。

如果接口依赖 Cookie 判断登录态,那么后端就会认为你没有登录。

2.2.1 axios 中的写法

axios.get('https://api.b.com/user', {
  withCredentials: true
})

也可以全局配置:

axios.defaults.withCredentials = true

配置之后,浏览器在发起跨域请求时,会尝试携带目标域名下的 Cookie。

2.2.2 XMLHttpRequest 写法

const xhr = new XMLHttpRequest()

xhr.open('GET', 'https://api.b.com/user')
xhr.withCredentials = true
xhr.send()

2.2.3 fetch 中的对应写法

fetch 中没有 withCredentials,对应配置是 credentials

fetch('https://api.b.com/user', {
  credentials: 'include',
})

常见值:

fetch(url, {
  credentials: 'omit'        // 不携带 Cookie
})

fetch(url, {
  credentials: 'same-origin' // 默认值,同源请求携带,跨域不携带
})

fetch(url, {
  credentials: 'include'     // 同源和跨域都携带
})

可以简单理解为:

axios withCredentials: true
fetch credentials: 'include'

2.3 只设置前端还不够,后端也要配 CORS

跨域携带 Cookie 时,后端必须返回正确的 CORS 响应头。后端需要配置:

Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: https://www.a.com

注意: 这时 Access-Control-Allow-Origin 不能是 *。因为浏览器会拦截。

即使前端设置了 withCredentials: true,后端也设置了 CORS,如果 Cookie 属性不对,浏览器仍然可能不带 Cookie。

跨站 Cookie 通常需要:

Set-Cookie: sessionid=1234567890; SameSite=None; Secure

含义是:

SameSite=None  允许跨站发送 Cookie
Secure         只能在 HTTPS 下发送

如果 SameSiteLaxStrict,浏览器就不会发送跨站 Cookie。


三、withCredentials 和 CORS 的关系

3.1 withCredentials: true 不等于“允许跨域”

这是一个非常容易误解的点。

withCredentials: true 只是告诉浏览器:

这次跨域请求,我希望携带 Cookie 等用户凭证。

但它并不能决定跨域请求是否能成功。

真正决定跨域请求能不能被浏览器放行的是服务端返回的 CORS 响应头。

3.2 携带 credentials 时,服务端不能返回 *

如果前端请求配置了:

withCredentials: true

服务端不能返回下面这种 CORS 响应头:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

这是一个非法组合。

正确写法应该是返回明确的 Origin(这里是本地开发环境,Origin 就是 http://localhost:5173):

Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true

也就是说:携带 credentials 的跨域请求,Access-Control-Allow-Origin 不能是 *,必须是具体的 Origin。

3.3 为什么浏览器要限制 * + credentials

如果浏览器允许下面这种配置:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

就相当于服务端允许任意网站带着用户 Cookie 去请求接口。

这会带来非常大的安全风险。例如,用户已经登录了某个业务系统,如果任意第三方网站都能携带用户 Cookie 请求这个系统的接口,那么就可能造成跨站数据泄露。

所以浏览器会强制限制:

当请求携带 credentials 时,服务端必须明确声明允许哪个 Origin。

不能用通配符 *


四、为什么会出现 OPTIONS 预检请求?

CORS 请求分为两类:

  • 简单请求
  • 非简单请求

如果是简单请求,浏览器会直接发送真实请求。

如果是非简单请求,浏览器会先发送一个 OPTIONS 预检请求,确认服务端是否允许这次跨域请求。

4.1 withCredentials 本身不会触发 OPTIONS

一个容易误解的点是:

withCredentials: true 本身不会导致浏览器发送 OPTIONS 预检请求。

是否触发预检,主要取决于请求是否属于 CORS 简单请求。

4.2 什么情况下会触发 OPTIONS?

常见触发条件包括:

  1. 请求方法不是 GETHEADPOST
  2. POST 请求的 Content-Type 不是简单类型
  3. 请求带了自定义 Header

简单请求允许的 Content-Type 只有下面几类:

application/x-www-form-urlencoded
multipart/form-data
text/plain

如果请求头是:

Content-Type: application/json

那么这个 POST 请求就是非简单请求,会触发 OPTIONS 预检。

另外,如果请求带了下面这些自定义请求头,也会触发预检:

Authorization: Bearer xxx
X-Token: xxx
X-Requested-With: XMLHttpRequest
X-Brizoo-Token: xxx
X-Brizoo-Request: xxx

4.3 本次问题为什么会触发预检?

本次请求是 POST,并且请求头中有:

Content-Type: application/json

所以浏览器会先发送 OPTIONS 预检请求。

预检请求大致如下:

OPTIONS /challenge/h5/sendSms
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type

服务端需要对这个 OPTIONS 请求返回正确的 CORS 响应头,浏览器才会继续发送真实的 POST 请求。


五、为什么同样是 POST,有时能看到 OPTIONS,有时看不到?

这个问题在本次排查中非常关键。

一开始我在 Chrome DevTools 的 Network 面板里只看到了一个失败的 POST 请求,没有看到 OPTIONS,于是误以为浏览器没有发起预检请求。

POST 请求失败

后来才发现:请求确实触发了 OPTIONS,只是 DevTools 的筛选条件导致一开始没有看到。

5.1 POST 不一定都会触发 OPTIONS

POST 是否触发 OPTIONS,不取决于“是不是 POST”,而取决于它是否属于 CORS 简单请求。

下面这种 POST 通常不会触发 OPTIONS:

Content-Type: application/x-www-form-urlencoded

或者:

Content-Type: multipart/form-data

或者:

Content-Type: text/plain

并且不能带自定义请求头。

下面这种 POST 通常会触发 OPTIONS:

Content-Type: application/json

或者带有:

Authorization
X-Token
X-Requested-With
X-Brizoo-Token
X-Brizoo-Request

所以,同样是 POST,请求头不同,表现可能不同。

5.2 withCredentials 不会直接触发 OPTIONS

即使设置了:

withCredentials: true

也不代表一定会发 OPTIONS。

它只影响:

跨域请求是否携带 Cookie 等凭证。

真正决定是否发 OPTIONS 的是:

请求方法、Content-Type、是否存在自定义请求头。

本次问题中,触发预检的直接原因是:

Content-Type: application/json

5.3 为什么触发了预检,但 Network 里一开始只看到 POST?

本次排查中还有一个容易误导人的点:

请求确实触发了 OPTIONS 预检;
但是 Chrome DevTools 中如果只筛选 Fetch/XHR,看不到 OPTIONS;
需要切换到 All,才能看到 OPTIONS 预检请求。

也就是说,在 Network 面板中:

Fetch/XHR 视图:只看到失败的 POST
All 视图:才能看到真正的 OPTIONS 预检请求

本次一开始看到的是:

POST 请求标红
Status 显示 CORS error
Size 为 0.0 kB
Headers 里提示 Provisional headers are shown

这很容易让人误以为:

浏览器只发了 POST,没有发 OPTIONS。

但切换到 All 后,才找到了真正的 OPTIONS 请求。

5.4 Provisional headers are shown 说明什么?

当 POST 请求详情里出现:

Provisional headers are shown

通常说明这条真实业务请求还没有完整发出去,Chrome 只是展示了“原本准备发送的请求头”。

结合 CORS 预检失败的场景,可以理解为:

浏览器准备发送 POST;
由于 POST 是非简单请求,先发送 OPTIONS 预检;
OPTIONS 预检没有通过;
真实 POST 被浏览器拦截,没有继续发送。

所以,Network 里看到 POST 标红,并不代表 POST 已经真正到达业务服务。

5.5 如何确认 OPTIONS 是否存在?

可以按下面方式排查:

  1. 打开 Chrome DevTools → Network
  2. 勾选 Preserve log
  3. 勾选 Disable cache
  4. 切换到 All,不要只看 Fetch/XHR
  5. 右键请求列表表头,勾选 MethodStatusRemote Address
  6. 重新发起请求
  7. 查看是否存在 MethodOPTIONS 的请求

也可以直接在过滤框里输入:

method:OPTIONS

如果在 Fetch/XHR 下看不到,就切到 All 再查。


六、请求到底有没有到服务端?

这个问题要分情况看。

6.1 简单请求

如果是简单请求,浏览器通常会直接发送真实请求。

如果服务端返回的 CORS 响应头不合法,表现会是:

请求已经到达服务端;
服务端也返回了响应;
但是浏览器不允许前端 JS 读取这个响应。

也就是说,服务端可能已经处理了请求,但前端拿不到结果。

6.2 非简单请求

如果是非简单请求,浏览器会先发送 OPTIONS 预检请求。

如果预检失败,表现会是:

OPTIONS 可能已经到达服务端或网关;
但是真实 POST 请求不会继续发送。

本次问题就是这种情况。

由于 OPTIONS 响应头中返回了非法组合:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

浏览器在预检阶段直接拦截,真实 POST 请求没有继续发出。


七、为什么同事可以正常请求?

经过对比,我和同事的关键差异不在前端代码,而在请求链路。

7.1 表面差异:OPTIONS 响应头不同

我这边 OPTIONS 响应头是:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

所以浏览器报错:

PreflightWildcardOriginNotAllowed

同事那边 OPTIONS 响应头是:

Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true

所以他的预检请求可以通过,后续 POST 可以继续发送。

7.2 关键差异:Remote Address 不一样

进一步对比 Chrome DevTools 中的 Remote Address,发现:

我这边:59.110.244.186
同事那边:30.19.0.4

这说明虽然我们访问的是同一个域名:

api-server-stage.mofaxiao.com

但两个人实际命中的服务入口不一样。

7.3 根因:网络环境不同

同事连接了集团的未来盾并连上了内网,因此这个域名被解析或路由到了内网 IP:30.19.0.4

而我没有连接集团内网软件,请求命中了公网入口:59.110.244.186

两个入口的 CORS 配置不一致:

内网入口:返回具体 Origin,配置正确
公网入口:返回 * + credentials,配置错误

这就是为什么同一份代码、同一个接口、同一个浏览器环境,表现却不一样。


八、排查过程总结

这次问题的排查路径可以总结为下面几步。

8.1 第一步:看 Console 报错

Chrome 控制台中的错误信息是:

PreflightWildcardOriginNotAllowed

这个错误基本可以判断:

预检请求返回了 Access-Control-Allow-Origin: *;
同时当前请求又是 credentials 模式;
所以浏览器拒绝继续发送真实请求。

8.2 第二步:看 Network 中的 Method

在 Chrome DevTools 的 Network 面板中,右键请求列表表头,勾选:

Method
Status
Remote Address

重点区分:

OPTIONS:预检请求
POST:真实业务请求

本次排查中还要注意:

不要只停留在 Fetch/XHR 筛选条件下;
需要切换到 All,才能更稳定地看到 OPTIONS 请求。

8.3 第三步:查看 OPTIONS 响应头

重点查看下面几个响应头:

Access-Control-Allow-Origin
Access-Control-Allow-Credentials
Access-Control-Allow-Headers
Access-Control-Allow-Methods

失败请求中,OPTIONS 返回:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

这就是直接原因。

8.4 第四步:对比同事电脑上的响应头

同事电脑上,OPTIONS 返回:

Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true

说明服务端某个入口的 CORS 配置是正确的。

8.5 第五步:对比 Remote Address

最终发现,我和同事命中的远端地址不同:

我:59.110.244.186
同事:30.19.0.4

这说明问题不是前端代码差异,而是网络入口差异。

8.6 第六步:确认网络环境

同事连接了集团内网软件,我没有连接。

因此同一个域名在不同网络环境下命中了不同 IP / 网关,导致 CORS 配置表现不一致。


九、正确的服务端配置方式

9.1 不要使用 * + credentials

错误配置:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

只要前端请求是 credentials 模式,这个组合就会被浏览器拦截。

9.2 使用 Origin 白名单

服务端应该根据请求头中的 Origin 做白名单判断。

例如请求头是:

Origin: http://localhost:5173

如果该 Origin 在白名单中,服务端应该返回:

Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true
Vary: Origin

这里建议加上:

Vary: Origin

因为如果服务端根据不同 Origin 动态返回不同的 Access-Control-Allow-Origin,那么加上 Vary: Origin 可以避免缓存层错误复用 CORS 响应头。

9.3 OPTIONS 也要正确配置

不仅真实业务接口要配置 CORS,OPTIONS 预检请求也要配置正确。

正确示例:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, POST, OPTIONS, DELETE, PUT
Access-Control-Allow-Headers: Content-Type, X-Token, Authorization, X-Brizoo-Token, X-Brizoo-Request
Vary: Origin

错误示例:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

9.4 多入口要保持配置一致

本次问题的根因之一就是:

内网入口和公网入口的 CORS 配置不一致。

所以如果一个域名在不同网络环境下可能命中不同入口,例如:

  • 内网网关
  • 公网网关
  • 测试环境网关
  • 灰度入口
  • CDN / WAF
  • 本地代理

那么这些入口的 CORS 配置需要保持一致,尤其是 OPTIONS 预检请求的处理逻辑。


十、开发环境中的排查建议

10.1 确认网络环境一致

如果团队使用内网软件、VPN、代理或特殊网关,排查 CORS 问题时一定要确认大家的网络环境是否一致。

重点对比:

Remote Address
DNS 解析结果
hosts 配置
代理设置
VPN / 内网软件状态

10.2 使用命令验证域名解析

可以使用:

nslookup api-server-stage.xxx.com

或者:

dig api-server-stage.xxx.com

如果两个人解析结果不同,那么即使访问的是同一个域名,也可能命中不同网关。

10.3 检查 hosts

在 macOS / Linux 中可以检查:

cat /etc/hosts | grep xxx.com

如果本地 hosts 配置不一致,也可能导致命中的服务入口不同。

10.4 检查代理

如果 DevTools 中看到 Remote Address 是类似下面这种:

127.0.0.1:1082

说明请求可能走了本机代理。

这时需要检查:

  • 系统代理
  • Chrome 代理插件
  • VPN 软件
  • Clash / V2Ray / Charles / Whistle / Proxyman / Fiddler 等代理工具

10.5 使用 curl 验证 OPTIONS 响应

可以用 curl 主动模拟预检请求:

curl -i -X OPTIONS 'https://api-server-stage.xxx.com/challenge/h5/sendSms' \
  -H 'Origin: http://localhost:5173' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type'

重点看:

Access-Control-Allow-Origin
Access-Control-Allow-Credentials
Access-Control-Allow-Headers
Access-Control-Allow-Methods

10.6 对比是否连接内网软件

如果公司内部域名或测试环境依赖内网软件访问,那么需要确认:

是否连接内网软件
连接前后 DNS 解析是否变化
连接前后 Remote Address 是否变化
连接前后 OPTIONS 响应头是否变化

本次问题中,连接集团内网软件后,域名会命中内网 IP,CORS 配置就是正确的。


十一、这次问题的最终结论

本次问题不是前端代码问题,也不是 Chrome 和同事浏览器行为不一致。

真正原因是:

同一个域名在不同网络环境下命中了不同服务入口;
不同入口的 CORS 配置不一致;
我命中的公网入口返回了非法的 * + credentials;
同事命中的内网入口返回了正确的具体 Origin。

具体表现是:

我:Remote Address = 59.110.244.186
同事:Remote Address = 30.19.0.4

我这边 OPTIONS 响应头:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

同事那边 OPTIONS 响应头:

Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true

因此,我这边被浏览器拦截,同事那边可以正常请求。


十二、经验总结

这次问题可以总结为几条经验。

12.1 withCredentials: true 的核心作用

withCredentials: true 用于跨域请求时携带 Cookie 等用户凭证。

它本身不代表允许跨域,也不会直接触发 OPTIONS。

12.2 携带 credentials 时不能使用 *

如果请求携带 credentials,服务端不能返回:

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

必须返回具体 Origin:

Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Credentials: true

12.3 application/json 的 POST 通常会触发预检

POST 请求如果使用:

Content-Type: application/json

通常会触发 OPTIONS 预检。

12.4 预检失败时,真实请求不会继续发送

如果 OPTIONS 预检失败,真实 POST 请求通常不会继续发送。

Network 中看到 POST 标红,不代表 POST 已经真正到达业务服务。

12.5 DevTools 要切换到 All 才更容易看到 OPTIONS

本次问题中,在 Network 面板只筛选 Fetch/XHR 时,没有看到 OPTIONS。

切换到 All 后,才找到了 OPTIONS 预检请求。

所以排查 CORS 时建议:

Network → All → 勾选 Method / Status / Remote Address

12.6 同一份代码表现不同,要重点看 Remote Address

如果你和同事同一份代码、同一个接口,但表现不同,除了看请求头和响应头,还要重点看:

Remote Address
DNS 解析
hosts
代理
VPN / 内网软件

本次问题最终就是通过 Remote Address 定位到的。


附录:CORS 问题排查清单

遇到类似问题时,可以按下面清单排查。

前端侧

  1. 是否设置了 withCredentials: true
  2. 是否跨域?
  3. 请求方法是什么?
  4. Content-Type 是什么?
  5. 是否有自定义请求头?
  6. 是否触发了 OPTIONS?
  7. Network 是否切到了 All
  8. 是否勾选了 MethodStatusRemote Address

服务端 / 网关侧

  1. OPTIONS 是否返回了 CORS 响应头?
  2. Access-Control-Allow-Origin 是具体 Origin 还是 *
  3. 是否返回了 Access-Control-Allow-Credentials: true
  4. 是否出现了 * + credentials 的非法组合?
  5. Access-Control-Allow-Headers 是否包含实际请求头?
  6. Access-Control-Allow-Methods 是否包含实际请求方法?
  7. 是否加了 Vary: Origin
  8. POST 和 OPTIONS 的 CORS 配置是否一致?

网络环境侧

  1. 自己和同事的 Remote Address 是否一致?
  2. DNS 解析结果是否一致?
  3. hosts 是否一致?
  4. 是否连接 VPN / 内网软件?
  5. 是否使用系统代理或浏览器代理插件?
  6. 是否命中了不同网关、不同节点或不同环境?

结语

CORS 问题表面看是“浏览器拦截”,但背后可能涉及前端请求配置、浏览器安全策略、服务端响应头、OPTIONS 预检、网关配置、DNS、代理和内网环境。

这次问题最大的启发是:

当同一份代码在不同机器上表现不一致时,不要只盯着代码;
一定要对比真实请求链路,尤其是 Remote Address 和 OPTIONS 响应头。

很多时候,真正的差异并不在代码里,而在请求最终命中的那一层入口上。