☰
跨域请求Cookie丢失?一文说透withCredentials与CORS配置
2026/10/15 14:11:42 网站建设 项目流程

这周又被跨域问题卡了一下午。具体现象说起来挺简单:前端页面部署在 A 域名,后端接口在 B 域名,接口能通、数据能正常返回,但我在 Network 面板里看到响应头明明有Set-Cookie,浏览器却死活不种 Cookie,后续所有需要登录态的请求都带不上会话信息。排查到最后,问题出在 XHR 的withCredentials这个属性上。

这类问题在前后端分离的项目里实在太常见了,尤其是涉及单点登录、用户会话、埋点上报、开放平台接口这些场景,几乎每个开发者早晚都会撞上一次。今天就把我从现象到配置、从理论到调试验证的全过程拆开写一遍,顺便把踩过的坑和排查思路一起整理出来。如果你是正在被跨域折腾的前端同学,或者刚接触前后端联调的新人,这篇应该能帮你少走不少弯路。

1. 事故现场:跨域请求里“消失”的 Cookie

1.1 一个典型到不能再典型的报错场景

先说当时的现象。

我在某跨域系统的联调环境里调试一个登录功能,前端跑在http://frontend.example.test:8080,后端接口在http://api.example.test:9000。登录接口调用成功后,我在控制台确认了响应内容是正常的,再去 Application 面板看 Cookies,发现里面什么都没有。

翻了 Network 面板的响应头,Set-Cookie分明就在那里,类似:

set-cookie: sessionId=abc123; Path=/; HttpOnly; SameSite=Lax

但浏览器就是不把它存进 Cookie 存储里。更奇怪的是,紧接着下一个请求需要携带这个sessionId,我检查请求头发现 Cookie 字段依然是空的。

这就是一个非常典型的“跨域请求里,Cookie 没有按预期传递”的问题。

很多同学第一反应是去后端加 CORS 配置,后端也确实加了Access-Control-Allow-Origin,接口也能通,但 Cookie 这块就是纹丝不动。原因在于:CORS 允许跨域读取接口,和允许跨域传递 Cookie,是两码事。前者由 CORS 响应头控制,后者必须显式地在请求端打开一个开关,这个开关就是withCredentials。

1.2 先捋清楚同源策略到底“同”的是什么

要说清楚withCredentials,就得先回顾一个基础知识:浏览器的同源策略。

所谓“同源”,指的是协议、域名、端口三样完全一致。http://frontend.example.test:8080和http://api.example.test:9000之间,域名不同、端口不同,哪怕协议都是 http,依然属于跨域。

同源策略的约束之一,就是限制了页面里的脚本去读取另一个源的响应数据。在跨域的情况下,浏览器默认不会把目标源的 Cookie 自动附加到请求头上,这是出于安全的考虑——如果不加限制,任何一个恶意站点都能替用户向银行、邮箱发请求,用户的会话状态就会被悄悄利用。

但业务上又确实存在合法的跨域凭证传递需求:主站域名和 API 域名不一样,但用户在主站登录后,API 域名的接口也希望能识别这个用户。这就要靠 CORS 机制中的凭证模式来放行。

这里有一个很关键的点:跨域请求按“是否涉及凭证”分成两类。不涉及凭证时,只要服务端返回Access-Control-Allow-Origin就能拿到数据;一旦涉及凭证,整个条件就变得严格得多,响应头不能再用通配符,请求端也必须显式声明自己要携带凭证。

1.3 withCredentials 到底是干什么的

withCredentials是 XHR 对象上的一个布尔属性,把它设为true后,浏览器在发起跨域 XHR 请求时就会携带目标域下的 Cookie,同时也会处理响应头里的Set-Cookie。

fetch 里对应的配置是credentials: 'include',含义完全一致。

听起来很简单,但实际叠加 CORS 规则后就没有那么直接了。你需要满足三件事,缺一不可:

  1. 前端请求显式开启凭证模式;
  2. 服务端返回Access-Control-Allow-Credentials: true;
  3. 服务端返回的Access-Control-Allow-Origin不能是*,必须是具体的前端来源,并且要与Access-Control-Allow-Credentials: true同时存在。

当时我犯的第一个错误,就是只改了前端代码,开了withCredentials,但后端 CORS 配置里的Access-Control-Allow-Origin仍然用的是*。浏览器检查到凭证模式下不允许通配符来源,直接在响应拦截阶段报错,接口数据都拿不到了。

2. 前后端联动:配置链路里的每一个关键环节

2.1 前端侧的两个入口写法

先说 XHR 的写法:

const xhr = new XMLHttpRequest() xhr.withCredentials = true xhr.open('GET', 'http://api.example.test:9000/user/info', true) xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { console.log(xhr.responseText) } } xhr.send()

如果用 fetch,写法是这样的:

fetch('http://api.example.test:9000/user/info', { method: 'GET', credentials: 'include' }) .then(response => response.json()) .then(data => console.log(data))

两种方式的作用是一致的。需要注意的是,fetch 的默认行为是credentials: 'same-origin',也就是说同源请求默认带 Cookie,跨域不带。如果你忘了写'include',跨域 Cookie 永远不会被发送,这算是 fetch 场景下最高频的坑之一。

另外,axios 在处理跨域时比较特殊,它底层封装的是 XHR,默认的withCredentials是false。你在 axios 里需要单独配置:

axios.defaults.withCredentials = true

或者对单个请求:

axios.get('http://api.example.test:9000/user/info', { withCredentials: true })

很多项目里 axios 的封装文件是统一的,在request.js里全局设置一次即可,但要注意别把全局配置影响到那些本来就不需要凭证的公共请求,比如验证码、地图服务、消息推送这类,避免无谓地扩大凭证暴露面。

2.2 服务端侧必须返回的三个响应头

服务端在做 CORS 配置时,常规认知是返回Access-Control-Allow-Origin就够了。但要让凭证模式真正工作,响应头里至少要包含下面三样。

第一是Access-Control-Allow-Origin。如果你用 Node.js 的 Express 写一个简单的接口,核心代码大致是:

res.header('Access-Control-Allow-Origin', 'http://frontend.example.test:8080') res.header('Access-Control-Allow-Credentials', 'true')

当Access-Control-Allow-Credentials为true时,Access-Control-Allow-Origin就必须填写具体来源。

第二是Access-Control-Allow-Credentials,它告诉浏览器“我允许你携带凭证”。很多后端框架会帮你自动处理 CORS 响应头,但要注意框架的默认逻辑里,这一项未必会被设置为true。

第三是针对非简单请求的Access-Control-Allow-Methods和Access-Control-Allow-Headers。如果前端的请求使用了自定义头部,比如Authorization、X-Requested-With,浏览器会先发一个 OPTIONS 预检请求,服务端必须在这个预检响应里明确允许这些方法、这些头,否则真实请求根本不会发出。

我当时在联调时遇到的一个问题是,后端同学只在普通接口的响应里加了 CORS 头,但没有处理 OPTIONS 预检请求。前端代码里设置了Content-Type: application/json,浏览器认为这不是简单请求,先发 OPTIONS,后端直接返回了 404,于是真实请求连发出去的机会都没有。这个在本地开发时经常被忽略,因为有些代理工具会自动过滤 OPTIONS,到了直连环境就会立刻暴露。

2.3 为什么 Access-Control-Allow-Origin 不能用*

这里值得展开聊一下。

浏览器在凭证模式下收到Access-Control-Allow-Origin: *,会直接拒绝把这个响应暴露给前端 JS。你可以理解为:服务端说“谁来都行”,但涉及 Cookie 凭证的时候,浏览器需要知道凭证到底该给谁,通配符会让浏览器没办法判断这个响应是不是真的属于当前请求的那个来源,从安全角度就必须阻止。

所以正确做法是维护一个允许来源的白名单,在后端配置里根据请求头的Origin做动态判断。比如:

const allowedOrigins = ['http://frontend.example.test:8080', 'http://admin.example.test:8081'] app.use(function (req, res, next) { const origin = req.headers.origin if (allowedOrigins.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') } if (req.method === 'OPTIONS') { return res.sendStatus(204) } next() })

这种动态回显Origin的配置,我后来一直建议团队统一维护。要特别留意一个细节,判断时要精确匹配完整来源,包括端口,不要用includes这种宽松判断,否则别人构造一个http://frontend.example.test.evil.com也能混进来,这就是妥妥的安全漏洞。

另外,不是所有后端框架都会让你方便地加响应头。比如有些框架里已经配置了cors()中间件,你需要在中间件选项里显式开启credentials: true,同时把origin设成白名单回调函数,而不是字符串数组。这块不同框架差异很大,建议查对应中间件的文档,但核心思路是一致的。

3. 从控制台报错到请求成功:完整调试实录

3.1 读取报错信息:一条一条拆开看

我的开发环境是 Chrome,开启withCredentials之后,控制台里出现的第一条报错是:

Access to XMLHttpRequest at 'http://api.example.test:9000/user/info' from origin 'http://frontend.example.test:8080' has been blocked by CORS policy: 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'.

这段英文信息一开始很容易让人误以为只是后端配置问题,但细看就会发现,它明确指出了请求的 credentials mode 已经是 include。也就是说前端带凭证的意图已经被浏览器识别了,后端的响应条件不符合要求。

我先去后端的 CORS 配置文件里查了一下,发现Access-Control-Allow-Origin确实写成了*。这其实不算个例——很多项目为了图省事,直接把所有跨域响应的来源都设成通配符,不做任何白名单控制,平时接口能通,一旦凭证模式打开就立刻翻车。

我把*改成了具体的前端来源后,保存重启服务,再刷新页面,控制台安静了,接口也正常返回了。但下一个问题马上出现:Cookie 依然没有种上。

这次的报错变成了:

This set-cookie didn't specify a "SameSite" attribute, was defaulted to "Lax", and was blocked because it came from a cross-site response...

这个就是我在开头提到的SameSite坑。具体来说,Chrome 从 80 版本开始,默认把没有显式声明SameSite属性的 Cookie 当成SameSite=Lax处理。Lax 模式允许顶级导航携带 Cookie,但对于通过 fetch 或 XHR 发起的跨站子资源请求,这个 Cookie 不会写入,这就是及时 CORS 配置正确、withCredentials也开了,Cookie 仍然不生效的根本原因。

3.2 联调时 Cookie 的种与取:Domain 与 SameSite 的联动

遇到这种情况,首先要区分“跨域”和“跨站”。前面讲的 frontend.example.test 与 api.example.test,虽然域名不同,但都属于同一个可注册域.example.test,所以是跨域,但不跨站。如果两个地址分别是example.com和example.net,那就是跨站了。

跨站场景下,第三方浏览器对 Cookie 的限制会更多。但在这个案例里我们是跨域不跨站,有一个很实用的解决办法,就是让 Cookie 的Domain覆盖到整个.example.test。假设后端登录接口返回:

set-cookie: sessionId=abc123; Domain=.example.test; Path=/; HttpOnly; SameSite=Lax

这样的话,frontend.example.test:8080和api.example.test:9000都能访问到同一个 Cookie。这种配置在单点登录场景里非常常用——多个子域之间共享登录态,靠的就是 Domain 里带上前导点,让 Cookie 对子域名生效。

但这里有一个前置条件:后端语言对应的 HttpOnly Cookie 配置要支持自定 Domain。一些后端框架默认把 cookie 的 domain 绑定到当前请求的 host,如果代理层做了转发,Host 头被改了,Cookie 的 Domain 可能不会写到你期望的值,这也是联调时一个比较隐蔽的问题。

如果你确认自己的场景确实是跨站,那么SameSite就不能用 Lax 了,需要设置成None; Secure,同时保证页面是通过 HTTPS 访问。跨站配置最完整的形式大致是:

set-cookie: sessionId=abc123; Path=/; HttpOnly; Secure; SameSite=None; Domain=.parent.test

Secure是强制要求的,在跨站 +SameSite=None的组合下,如果缺少Secure属性,浏览器同样会拒绝写入。

我当时采用的是 Domain + SameSite=Lax 的方案,毕竟联调环境是 HTTP,且前后端同属一个主域,改动量最小,也没有引入额外安全姿态。

3.3 完整调整清单:从请求到响应的每一步

为了让读者能照着检查,我把整个链路里的关键点整理成一份操作清单。

第一步,打开浏览器的开发者工具,切到 Network 面板,点击请求,查看 Request Headers 里的Cookie字段。如果请求头里没有 Cookie,说明问题在请求发送阶段。

第二步,查看 Response Headers 里的Set-Cookie字段。如果响应头有Set-Cookie,但 Application 面板里没有出现对应的 Cookie,说明响应接收阶段出了问题。

第三步,确认前端代码里确实设置了withCredentials = true。这一步看似废话,但实际很多框架会覆盖默认值,尤其是在封装了请求库的项目里,老代码可能在某处以withCredentials = false强制覆盖了,这个要全局搜索确认。

第四步,确认服务端响应头里同时有Access-Control-Allow-Origin和Access-Control-Allow-Credentials,并且Access-Control-Allow-Origin是具体源。

第五步,检查Set-Cookie的SameSite属性和Domain属性是否匹配当前场景。

这套检查顺序我用了很多次,基本能把 99% 的跨域 Cookie 问题定位到具体环节。

4. 常见问题速查与避坑清单

4.1 五个高频问题及解决方案

问题现象根因解决办法
接口能通,但跨域请求头里没有 Cookie前端未设置withCredentials或 fetch 未设credentials: 'include'在前端请求中显式开启凭证模式
设置凭证模式后控制台报通配符错误服务端Access-Control-Allow-Origin仍为*改为具体来源,并返回Access-Control-Allow-Credentials: true
CORS 配置看起来都对,Cookie 还是不种Cookie 的SameSite被浏览器默认设为 Lax,跨站请求被拦截设置SameSite=None; Secure,或让 Cookie Domain 覆盖到子域
非简单请求被浏览器拦截,控制台大量 OPTIONS 报错服务端没有处理预检请求,或缺少Allow-Methods/Allow-Headers后端增加对 OPTIONS 的处理,并返回允许的方法和头部
请求带上了 Cookie,但后端不认,登录态时有时无Cookie 的 Domain 与接口域名不一致,比如接口域名下加分号写错了 Domain确认Domain是否覆盖前后端域名,同时检查代改 Host 的场景

这张表几乎概括了我这几年在跨域凭证方向上遇到的全部典型问题,每次联调排查的时候我都是先对着这张表过一遍,比盲目翻代码快得多。

4.2 生产环境容易忽略的隐藏细节

有几个经验性的细节,我觉得值得单独拿出来说。

第一个是开发环境与生产环境的不一致。本地联调时用的是localhost和后端内网地址,可能不涉及跨站,配置随便做做就能通过。但到了生产环境,前端是https://www.example.com,接口是https://api.example.com,浏览器的限制并不会因为是 HTTPS 就放宽,反而对跨站 Cookie 限制得更严格。我建议从第一天联调开始,前端就统一走实际部署时的域名,通过改 hosts 的方式映射到本地开发机,这样把问题提前暴露在开发阶段。

第二个是反向代理的存在感。很多项目在生产环境通过 Nginx 把/api路径反代到后端服务,这样前端页面和接口的域名最终是同一个源,理论上根本不触发跨域。但如果代理配置里私自改了响应头,比如在反向代理层重新覆写了Access-Control-Allow-Origin,反而可能把本来能行的链路搞挂。遇到这种场景,要优先确认代理配置没有画蛇添足。

第三个是 SSO 登录场景下,如果认证服务器返回的 Cookie 与业务 API 域名不一致,即使 CORS 全部配置正确,Cookie 也不会被发送。更合适的做法是把登录态的凭证做成短期 Token 放在请求头里,而不是完全依赖 Cookie。我之前在实际项目里遇到过好几次,排查到最后发现是架构设计上寄希望于跨域 Cookie 携带会话,浏览器安全策略一升级就全线崩溃。这个思路上的警示比任何具体配置都要重要。

4.3 我的配置建议

最后说几句我现在的默认做法。

前端侧,我把 axios 的默认实例统一设置成withCredentials: true,但只给确实需要凭证的请求使用。对不要凭证的接口,比如网站首页的商品列表,单独创建一个不含凭证配置的 axios 实例,避免所有请求都背上凭证负担。

后端侧,跨域配置不要手写在业务代码里,而是统一放到中间件或网关层。白名单用配置文件维护,线上环境只允许明确的线上域名,绝不放开到*。OPTIONS 预检请求要统一返回 204,并且保证配置对 GET、POST、PUT、DELETE 以及自定义 Headers 都开放。

Cookie 侧,优先考虑让前端和后端处在同一主域下,利用Domain=.example.com实现会话共享。如果实在做不到跨站,那就接受SameSite=None; Secure,并确保整个页面访问链路都走了 HTTPS。无论如何,都要避免在生产环境里用SameSite=Lax去硬扛跨站请求,那只是开发时侥幸可用,浏览器一升级就可能出问题。

从那次踩坑之后,我再遇到跨域 Cookie 的联调问题,第一件事已经不是翻代码,而是打开 Network 面板先看请求头、响应头、Cookie 三个方面,按 section 章节里那张表一步步对照。这种排查方式让我把原本可能要折腾一两天的问题压缩到了半小时内解决。说白了,跨域凭证传递的坑就那么几个,只要把链路里的每一环看明白,就不会再被它反复折磨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询