CORS 下 Cookie 为什么收不到:从 withCredentials 到 SameSite

CORS 下 Cookie 能不能生效,不只取决于 withCredentials,还取决于服务端 CORS 头、Cookie Domain、SameSite、Secure 和浏览器第三方 Cookie 策略。

很多年前排查过一个问题:前端页面通过 CORS 请求后端,后端希望前端把响应里的某个字段写入 document.cookie,再在下一次请求里带回去。前端确实写了 Cookie,Chrome DevTools 里也能看到,但后端就是收不到。

当时的结论很简单:Cookie 是按域存储和发送的,页面脚本写下的 Cookie 属于当前页面所在的域,不会因为一次跨域请求就变成接口域名的 Cookie。后端应该用 Set-Cookie,而不是把 Cookie 值塞进 response body 让前端手工写。

English version: Why Cookies Fail in CORS: From withCredentials to SameSite

这个结论今天仍然成立,但已经不够完整。现代浏览器补上了 SameSite 默认值、SameSite=None 必须搭配 Secure、第三方 Cookie 限制、分区 Cookie 等机制。现在排查 CORS 下 Cookie 收不到,不能只盯着 withCredentials

先区分三个概念

讨论 CORS 和 Cookie 时,最容易混在一起的是这三个概念:

  • 同源:scheme、host、port 都相同。https://www.example.comhttps://api.example.com 不同源;http://localhost:3000http://localhost:63342 也不同源。
  • 同站:通常看 scheme 加可注册域名。https://www.example.comhttps://api.example.com 通常同站;https://example.comhttps://other.com 不同站。
  • Cookie 作用域:由设置 Cookie 的 host、DomainPath 等属性决定。端口不是 Cookie 作用域的一部分。

CORS 管的是“一个 origin 的脚本能不能读取另一个 origin 的响应”。Cookie 管的是“请求某个 host/path 时,哪些 Cookie 会自动带上”。SameSite 管的是“当前请求是不是跨站,跨站时 Cookie 还能不能带”。

这三个判断维度不同,所以会出现一些看起来反直觉的场景:

  • localhost:63342 请求 localhost:3000:不同源,需要 CORS;但 Cookie 的 host 都是 localhost,调试时容易在同一个 Cookie 面板里看到。
  • www.example.com 请求 api.example.com:不同源,需要 CORS;但通常同站,SameSite=Lax 不一定会拦住 Cookie。
  • app.example.com 请求 api.other.com:不同源且不同站,既要 CORS,也会受到 SameSite 和第三方 Cookie 策略影响。

一条能工作的 CORS Cookie 链路

前端要明确带凭据。Fetch 默认只在同源请求里带 Cookie,跨源请求需要 credentials: 'include'

await fetch('https://api.example.com/me', {
  method: 'GET',
  credentials: 'include'
});

如果使用 XHR 或 axios,对应的是:

xhr.withCredentials = true;
axios.get('https://api.example.com/me', {
  withCredentials: true
});

服务端也要明确允许带凭据。关键点是:

  • Access-Control-Allow-Origin 必须是明确的 origin,不能是 *
  • Access-Control-Allow-Credentials 必须是 true
  • 如果按请求的 Origin 动态返回 Access-Control-Allow-Origin,要加 Vary: Origin,避免缓存污染。
  • 预检请求 OPTIONS 不会带 Cookie,但预检响应仍要告诉浏览器后续真实请求是否允许带凭据。

一个 Express 示例:

const allowList = new Set([
  'https://www.example.com'
]);

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (allowList.has(origin)) {
    res.setHeader('Access-Control-Allow-Origin', origin);
    res.setHeader('Vary', '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();
});

最后,Cookie 应该由接口域名通过 Set-Cookie 设置。比如页面在 https://www.example.com,接口在 https://api.example.com,两者同站但不同源:

Set-Cookie: __Host-sid=...; Path=/; HttpOnly; Secure; SameSite=Lax

这里的 Cookie 是 host-only Cookie,只会发给 api.example.comHttpOnly 让前端脚本无法读取它,适合会话 Cookie;Secure 要求 HTTPS;SameSite=Lax 在同站请求中通常足够。

如果页面和接口是不同站,比如 https://app.example.com 请求 https://api.other.com,想让 Cookie 参与跨站请求,Cookie 至少要这样:

Set-Cookie: sid=...; Path=/; HttpOnly; Secure; SameSite=None

但这只表示 Cookie 具备跨站发送的属性,不代表一定能用。浏览器或用户设置仍可能阻止第三方 Cookie。

为什么前端写 Cookie 后后端收不到

document.cookie = 'sid=123' 写的是当前页面所在 host 的 Cookie。页面在 www.example.com,脚本就不能给 api.other.com 写 Cookie。

即使用 Domain,也只能设置当前 host 或它的父域,不能设置任意外部域。比如从 api.example.com 可以设置 Domain=example.com,让 Cookie 覆盖同一可注册域名下的子域;但不能设置 Domain=other.com

这也是为什么“后端把 Cookie 值放在 JSON 里,让前端写到 document.cookie”通常是错误方案:

  • 写出来的是前端页面域名的 Cookie,不是接口域名的 Cookie。
  • 如果会话 Cookie 需要 HttpOnly,前端脚本本来就不应该能写。
  • Set-Cookie 是浏览器特殊处理的响应头,前端 JavaScript 不能读取它;即使服务端加 Access-Control-Expose-Headers: Set-Cookie 也没用。

正确链路应该是:接口响应里返回 Set-Cookie,浏览器在符合 CORS、credentials、Cookie 属性和浏览器策略的前提下自动保存;后续请求再由浏览器自动带上。

SameSite 改变了很多旧经验

早期很多文章会说:CORS 配好 withCredentialsAccess-Control-Allow-Credentials,跨域 Cookie 就能正常用。今天这句话少了 SameSite。

现代浏览器通常把未声明 SameSite 的 Cookie 当成 LaxLax 会在同站请求中发送,也会在用户进行顶层导航的部分跨站场景中发送,但不会为了普通跨站 fetch、XHR、iframe 子资源请求随便发送。

因此,跨站接口请求如果依赖 Cookie,一般需要:

Set-Cookie: sid=...; Path=/; HttpOnly; Secure; SameSite=None

注意两个细节:

  • SameSite=None 必须搭配 Secure
  • Secure 意味着生产环境必须使用 HTTPS;localhost 是调试例外,但不要把本地表现直接当成线上表现。

如果前端和 API 只是不同子域,优先把它们放在同一个站点下,例如:

  • https://www.example.com
  • https://api.example.com

这种架构仍然需要 CORS,因为它们不同源;但 SameSite 压力会小很多,因为它们通常是同站请求。

第三方 Cookie 策略不能靠 CORS 绕过

CORS 头、credentials: 'include'SameSite=None; Secure 都配对了,也仍然可能收不到 Cookie。原因是浏览器的第三方 Cookie 策略还在更外层。

MDN 在 CORS 文档里也明确提醒:带凭据的跨域请求仍然受第三方 Cookie 策略约束,服务端和前端配置无法绕过用户代理的策略。

今天至少要按浏览器分别理解:

  • Safari/WebKit 很早就默认限制并阻止大量第三方 Cookie,2020 年已经进入完整第三方 Cookie 阻止阶段。
  • Firefox 的增强跟踪保护会阻止一部分跟踪类第三方 Cookie。
  • Chrome 在 2025 年宣布继续保留用户对第三方 Cookie 的选择,不再推出新的独立提示;但无痕模式默认阻止第三方 Cookie,用户也可以在隐私设置里关闭第三方 Cookie。

所以,不应再把第三方 Cookie 当成稳定的登录基础设施。对普通业务系统,最稳妥的是尽量避免“前端站点和登录 Cookie 所在接口站点完全不同站”的设计。

如果确实是在做第三方嵌入组件,比如 iframe 小组件、地图、客服、支付或跨站嵌入应用,可以再评估 Storage Access API、CHIPS/Partitioned Cookie 等方案。但这些方案有明确场景边界,不适合作为普通前后端分离登录的默认解法。

方案选择

按稳定性排序,我会这样选:

  1. 同源部署:前端和 API 放在同一个 origin,或者用 Nginx/BFF 把 /api 代理到后端。Cookie 最简单,CORS 问题也最少。
  2. 同站不同源:例如 www.example.com + api.example.com。需要 CORS 和 credentials: 'include',但 Cookie 仍在同站语义内。
  3. 不同站但不用 Cookie 做接口身份:开放平台、跨组织 API、移动端 API 更适合用 OAuth、短期 token、Authorization header 等方式。
  4. 不同站且必须用 Cookie:只有在明确知道浏览器兼容性、用户设置和嵌入场景的情况下再做,并准备好第三方 Cookie 被禁用时的降级方案。

反向代理不是“土办法”。对自己控制的 Web 应用来说,把浏览器看到的前端和 API 收敛到同一个站点下,通常比和浏览器隐私策略对抗更稳。

安全边界

Cookie 会被浏览器自动带上,这也是 CSRF 的基础。CORS 不是 CSRF 防护。一个跨站表单提交或简单请求可以发出去,只是攻击页面不一定能读到响应。

如果接口使用 Cookie 做登录态,至少要考虑:

  • 会话 Cookie 使用 HttpOnly; Secure
  • 能用 SameSite=Lax 就不要用 SameSite=None
  • 对会改变状态的请求校验 CSRF token,或校验 Origin/Sec-Fetch-Site 等请求来源信号。
  • 不要把 Access-Control-Allow-Origin 无脑反射所有 Origin
  • 不要在带凭据的 CORS 响应里使用 Access-Control-Allow-Origin: *

Cookie 解决的是身份自动携带,不等于请求就是可信的。

调试清单

排查 CORS 下 Cookie 收不到时,可以按这个顺序看:

  1. 请求是不是跨源:scheme、host、port 是否完全一致。
  2. 前端是否设置了 fetch(..., { credentials: 'include' })withCredentials = true
  3. 响应是否有明确的 Access-Control-Allow-Origin,且不是 *
  4. 响应是否有 Access-Control-Allow-Credentials: true
  5. 动态 origin 是否加了 Vary: Origin
  6. Set-Cookie 是否来自接口域名,而不是 response body。
  7. Cookie 的 DomainPath 是否覆盖了下一次请求的 URL。
  8. 跨站请求是否设置 SameSite=None; Secure
  9. 生产环境是否是 HTTPS。
  10. 浏览器是否阻止了第三方 Cookie。
  11. DevTools 的 Network 请求里是否显示 Cookie 被 blocked,以及 blocked reason。
  12. Application 面板里 Cookie 所属站点是否符合预期。

Chrome DevTools 里,Network 面板点开具体请求,看 Cookies 子面板通常比只看 Headers 更清楚。被 SameSite、Secure、Domain、第三方 Cookie 策略拦掉的 Cookie,往往会在这里或 Issues 面板里给出原因。

总结

CORS 下 Cookie 能不能生效,取决于一整条链路:

  • 前端要允许带凭据。
  • 服务端要明确允许对应 origin 携带凭据。
  • Cookie 要由目标域名通过 Set-Cookie 设置。
  • Cookie 的 Domain、Path、SameSite、Secure 要匹配请求场景。
  • 浏览器第三方 Cookie 策略不能把它拦掉。

2017 年那次问题的根因是“前端不能替后端域名写 Cookie”。今天再补一句:即使 Cookie 是后端正确设置的,也要把 SameSite、Secure 和第三方 Cookie 限制一起纳入设计。

扩展阅读