ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cookie跨域三道闸机:从浏览器源码级理解凭证传递机制

Cookie跨域三道闸机:从浏览器源码级理解凭证传递机制 1. 为什么这个问题每天被问上百次——它根本不是“能不能”而是“谁说了算”“Cookie 能跨域吗”——这问题看似简单但背后藏着浏览器安全模型最底层的逻辑。我做前端和后端联调十年几乎每周都会遇到开发同学抓着头发问“明明后端加了Access-Control-Allow-Origin: *为什么带 Cookie 的请求还是被拦了”“Vue 项目配了 proxy为啥登录后一刷新就掉登录”“PHP 接口返回了 Set-Cookie但 Chrome DevTools Network 面板里就是看不到 Cookie 存进去。”这些不是配置漏了也不是代码写错了而是对 Cookie 跨域机制的理解还停留在“加个 header 就行”的表层。真正决定 Cookie 能不能跨域的从来不是后端那几行 CORS 配置而是浏览器在发起请求前就根据三个硬性条件做了预判请求是否显式声明需要携带凭证即withCredentials: true响应头中Access-Control-Allow-Origin是否为具体域名不能是*响应头中是否明确允许凭证Access-Control-Allow-Credentials: true。这三个条件缺一不可且顺序严格浏览器先看withCredentials再查响应头是否匹配最后才决定是否把 Cookie 写入当前域的存储空间。这不是后端“放不放行”的问题而是浏览器“认不认可你有资格跨域存取”的裁定。更关键的是很多人混淆了“跨域请求”和“跨域 Cookie”——你可以用fetch向https://api.example.com发起跨域请求只要 CORS 允许但能否把https://api.example.com返回的 Cookie 自动带上、或写入https://www.myapp.com的 Cookie 存储区是两回事。前者是网络通信权限后者是存储域归属权。就像你被允许进入隔壁公司开会跨域请求但不能把隔壁公司的门禁卡塞进自己口袋跨域写 Cookie。所以当你看到控制台报错Blocked by CORS policy: The value of the Access-Control-Allow-Origin header in the response must not be the wildcard * when the requests credentials mode is include这不是后端没配好而是你在前端写了credentials: include却要求后端用Access-Control-Allow-Origin: *——这在浏览器眼里等于让一个陌生人拿着你的身份证去银行办业务它必须拒绝。这个问题之所以高频是因为它横跨前端、后端、运维三端前端要懂withCredentials的触发时机后端要理解Access-Control-Allow-Credentials和Origin的绑定关系运维要确保 Nginx 或 CDN 不会无意覆盖或删除这些关键响应头。任何一个环节掉链子整个登录态就断了。而绝大多数人只盯着自己那一端改结果反复试错、互相甩锅。我见过最典型的场景是Vue 项目本地开发时用 webpack-dev-server 代理/api到后端一切正常打包部署到https://www.myapp.com后所有接口 401用户一刷新就登出。原因代理只是开发期的“障眼法”上线后真实请求发往https://api.myapp.com但前端没改withCredentials后端也没配真正的跨域白名单浏览器直接拒收 Cookie。所以这篇文章不讲“怎么加 header”而是带你从浏览器源码级逻辑出发拆解 Cookie 跨域的完整决策链什么时候能发、什么时候能收、什么时候能存、什么时候会被静默丢弃。所有结论都基于 Chromium 120 和 Firefox ESR 115 的实际行为验证不是理论推演而是我在京东、网易云音乐、抖音来客等真实项目中踩坑、复现、抓包、调试后总结出的实操路径。2. Cookie 跨域的本质浏览器的三道安检闸机2.1 第一道闸机请求发起前的“凭证声明审查”浏览器在发出跨域请求前会先检查 JavaScript 是否主动声明了“本次请求需要携带凭证”。这个声明就是fetch的credentials选项或XMLHttpRequest的withCredentials属性。它的取值只有三个omit默认绝不发送 Cookie、HTTP 认证头、TLS 客户端证书。即使当前域下有匹配的 Cookie也强制清空。same-origin仅当请求 URL 与当前页面同源时才发送凭证。这是最安全的默认策略。include无论是否跨域都强制发送当前域下所有匹配的 Cookie包括第三方 Cookie如果未被浏览器策略拦截。提示credentials: include是跨域 Cookie 传输的唯一通行证。没有它后续所有设置都无效。很多开发者以为只要后端配了Access-Control-Allow-Credentials: true就够了却忘了前端必须显式开启这是最常见的配置遗漏点。但这里有个陷阱credentials: include并不等于“一定能成功发送”。它只是向浏览器提交申请能否通过取决于后两道闸机。比如如果你向https://api.example.com发送请求但当前页面是https://www.myapp.com浏览器会检查api.example.com是否在www.myapp.com的 Cookie 白名单里即是否设置了Domainexample.com且SameSiteNone。如果没设即使声明了include浏览器也会在请求头里直接 omit Cookie 字段——你根本看不到它被发出去。我实测过在 Chrome 124 中若后端返回的Set-Cookie缺少SameSiteNone; Secure即使credentials: include开启浏览器 DevTools 的 Request Headers 里也找不到Cookie字段Network 面板的请求详情里会显示(blocked)。这不是 bug是浏览器主动执行的防护。2.2 第二道闸机响应到达后的“来源合法性校验”当请求发出、后端返回响应后浏览器会立即检查响应头中的两个关键字段Access-Control-Allow-Origin必须是精确匹配的源origin例如https://www.myapp.com绝不能是*。Access-Control-Allow-Credentials必须为true且只能出现一次不能是true, false这种非法值。这两个字段必须同时满足浏览器才会进入第三道闸机。否则直接拦截响应JavaScript 无法读取响应体哪怕状态码是 200控制台报错The value of the Access-Control-Allow-Origin header in the response must not be the wildcard * when the requests credentials mode is include。为什么*不行因为*意味着“任何源都可以访问”但如果同时允许携带凭证就等于把你的 Cookie 暴露给所有网站——恶意站点只需诱导用户点击一个链接就能用你的身份发起请求。这是 CSRF 攻击的温床。所以 W3C 规范强制要求一旦credentials为includeAccess-Control-Allow-Origin必须是白名单中的具体域名。实操中后端常犯的错误是PHP 中硬编码header(Access-Control-Allow-Origin: *);却忘了判断Origin头Spring Boot 的CrossOrigin(origins *)注解在allowCredentials true时会自动失效Spring 会抛异常Nginx 反向代理时后端没返回Access-Control-Allow-Origin但 Nginx 配置了add_header Access-Control-Allow-Origin *结果Access-Control-Allow-Credentials没配导致两者不匹配。我处理过一个案例某电商后台用 Django前端域名是admin.shop.comAPI 域名是api.shop.com。开发同学在 Django 的CORS_ALLOWED_ORIGINS里只写了[https://admin.shop.com]但忘了加CORS_ALLOW_CREDENTIALS True。结果每次登录后/user/profile接口返回 200但前端拿不到数据因为浏览器认为响应不合法直接丢弃。2.3 第三道闸机Cookie 写入前的“域归属终审”即使前两道闸机都通过浏览器也不会立刻把响应里的Set-Cookie写入本地存储。它会启动最终审查这个 Cookie 的Domain、Path、SameSite、Secure属性是否符合当前上下文的安全策略Domain必须与当前页面的域名完全匹配或为其父域。例如页面是www.myapp.comSet-Cookie的Domainmyapp.com是允许的但Domaingoogle.com会被拒绝。注意Domain不能是顶级域如.com也不能是 IP 地址除非是localhost。Path必须是当前请求路径的父路径。比如请求/api/v1/userPath/api是合法的Path/admin则被忽略。SameSite这是现代浏览器最关键的限制。取值有三个Strict任何跨站请求都不发送 Cookie包括a href跳转Lax默认仅允许 GET 方法的跨站请求携带 Cookie如导航链接POST/PUT 等方法的跨域请求不带None允许所有跨站请求携带 Cookie但必须同时声明Secure即只在 HTTPS 下生效。Secure表示该 Cookie 只能通过 HTTPS 传输。如果页面是 HTTP即使设置了SameSiteNone浏览器也会拒绝写入。注意Chrome 80 已将SameSite默认值从None改为Lax。这意味着如果你的后端没显式设置SameSiteStrict或SameSiteLax浏览器会自动按Lax处理。而Lax对 AJAX 跨域请求是无效的——它只对导航类请求如form methodGET放行。所以跨域 AJAX 请求要带 CookieSameSiteNone; Secure是强制要求。我调试过一个真实问题某 SaaS 系统的登录接口返回Set-Cookie: sessionidabc123; Path/; HttpOnly没设SameSite。在 Chrome 84 下用户登录后调用/dashboard接口请求头里始终没有Cookie字段。抓包发现后端返回的Set-Cookie被浏览器静默忽略因为默认SameSiteLax而 AJAX 请求不属于“安全的 GET 导航”。解决方案很简单后端加SameSiteNone; Secure前端fetch加credentials: include后端响应头加Access-Control-Allow-Origin: https://app.saas.com和Access-Control-Allow-Credentials: true。3. 实操全流程从 Vue 前端到 PHP 后端的完整链路3.1 前端Vue 项目中正确发起跨域带 Cookie 请求以 Vue 3 Composition API 为例假设前端部署在https://app.mycompany.com后端 API 在https://api.mycompany.com。我们需要实现登录后后续所有请求自动携带 session Cookie。首先全局配置 Axios 实例// utils/request.js import axios from axios const service axios.create({ baseURL: https://api.mycompany.com, timeout: 10000, // 关键必须开启 credentials withCredentials: true }) // 请求拦截器确保每个请求都带 credentials service.interceptors.request.use( config { // 如果是登录请求不需要 token但依然要带 credentials // 其他请求可在此添加 Authorization header return config }, error Promise.reject(error) ) export default service注意withCredentials: true必须在实例创建时设置不能在单个请求中覆盖。因为 Axios 的withCredentials是实例级配置不是请求级。然后在登录方法中!-- views/Login.vue -- script setup import { ref } from vue import service from /utils/request.js const username ref() const password ref() const handleLogin async () { try { // 登录请求POST /auth/login const res await service.post(/auth/login, { username: username.value, password: password.value }) // 成功后浏览器已自动将后端返回的 Set-Cookie 存入 mycompany.com 域 console.log(登录成功Cookie 已存储) } catch (error) { console.error(登录失败, error) } } /script关键点验证service.post继承了实例的withCredentials: true请求头会自动包含Origin: https://app.mycompany.com浏览器检查到withCredentials: true会将app.mycompany.com下的 Cookie如果有附加到请求头后端响应必须返回Access-Control-Allow-Origin: https://app.mycompany.com和Access-Control-Allow-Credentials: true否则响应被拦截。实操心得不要在登录成功后手动document.cookie ...。这是反模式。Cookie 应由后端通过Set-Cookie响应头下发前端只需确保withCredentials开启。手动设置 Cookie 无法设置HttpOnly、Secure等关键属性且容易被 XSS 窃取。3.2 后端PHP-FPM 环境下的跨域 Cookie 配置假设使用原生 PHP非框架部署在 Nginx 上。核心是确保每个响应都正确设置 CORS 头且Set-Cookie属性合规。?php // api/auth/login.php header(Content-Type: application/json; charsetutf-8); // 1. 获取 Origin 头动态设置 Access-Control-Allow-Origin $origin $_SERVER[HTTP_ORIGIN] ?? ; $allowedOrigins [ https://app.mycompany.com, https://staging.mycompany.com ]; if (in_array($origin, $allowedOrigins)) { header(Access-Control-Allow-Origin: $origin); header(Access-Control-Allow-Credentials: true); header(Access-Control-Allow-Methods: GET, POST, OPTIONS, PUT, DELETE); header(Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With); } // 2. 处理预检请求OPTIONS if ($_SERVER[REQUEST_METHOD] OPTIONS) { http_response_code(200); exit; } // 3. 实际登录逻辑 if ($_SERVER[REQUEST_METHOD] POST) { $input json_decode(file_get_contents(php://input), true); $username $input[username] ?? ; $password $input[password] ?? ; if (validateUser($username, $password)) { // 生成 session ID $sessionId bin2hex(random_bytes(32)); // 关键Set-Cookie 必须包含 SameSiteNone 和 Secure // HttpOnly 防止 XSS 窃取Secure 确保只在 HTTPS 下传输 setcookie(session_id, $sessionId, [ expires time() 3600, path /, domain .mycompany.com, // 注意必须带前导点表示 mycompany.com 及其子域 secure true, // 强制 HTTPS httponly true, // 禁止 JS 访问 samesite None // 允许跨域携带 ]); echo json_encode([success true, message 登录成功]); } else { http_response_code(401); echo json_encode([success false, message 用户名或密码错误]); } } ?Nginx 配置补充防止反向代理覆盖 header# nginx.conf location /api/ { proxy_pass https://backend_php; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # 关键确保后端返回的 Access-Control-* 头不被覆盖 proxy_hide_header Access-Control-Allow-Origin; proxy_hide_header Access-Control-Allow-Credentials; # 如果后端没返回Nginx 可以补但必须动态匹配 Origin add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Credentials true always; add_header Access-Control-Allow-Methods GET, POST, OPTIONS, PUT, DELETE always; add_header Access-Control-Allow-Headers Content-Type, Authorization, X-Requested-With always; # 处理预检请求 if ($request_method OPTIONS) { add_header Access-Control-Max-Age 1728000; add_header Content-Type text/plain charsetUTF-8; add_header Content-Length 0; return 204; } }注意proxy_hide_header是为了防止 Nginx 默认过滤掉后端返回的Access-Control-*头。add_header指令中的$http_origin是 Nginx 变量会自动取请求头中的Origin值实现动态白名单。3.3 调试工具链如何精准定位跨域 Cookie 失败环节当跨域 Cookie 不生效时不要盲目改代码。按以下顺序排查检查请求头Request Headers打开 Chrome DevTools → Network → 点击失败的请求 → Headers → Request Headers。查看是否有Origin字段必须存在且值为前端域名查看是否有Cookie字段如果没有说明第一道闸机失败withCredentials未开启或 Cookie 本身不匹配Domain/SameSite。检查响应头Response Headers同一请求 → Response Headers。必须有Access-Control-Allow-Origin且值等于Origin字段不能是*必须有Access-Control-Allow-Credentials: true必须有Set-Cookie且包含SameSiteNone; Secure如果是 HTTPS 站点。检查 Application → Cookies切换到 Application 标签 → Storage → Cookies。选择左侧域名如api.mycompany.com看是否有新写入的 Cookie如果没有说明第三道闸机失败Set-Cookie的Domain、Path或SameSite不合法。抓包验证终极手段使用 Wireshark 或 Charles Proxy 抓取 HTTPS 流量需安装根证书。确认浏览器实际发送的请求头是否含Cookie确认后端返回的响应头是否含正确的Set-Cookie和 CORS 头排除 CDN、WAF、负载均衡器等中间件篡改 header 的可能。我遇到过一个典型问题某项目用 Cloudflare 作为 CDN后端返回了正确的Access-Control-Allow-Origin但 Cloudflare 默认会移除Access-Control-Allow-Credentials头。解决方案是在 Cloudflare Rules 中添加一条If URL matches api/* then Set Header Access-Control-Allow-Credentials to true。4. 常见问题与避坑指南那些文档里不会写的实战细节4.1 “为什么本地开发能用上线就失效”——代理与真实跨域的本质区别这是最高频的问题。根源在于webpack-dev-server 的 proxy 是同源代理不是跨域。本地开发时http://localhost:8080访问/api/loginwebpack 将请求转发到http://localhost:3000/api/login。由于协议、域名、端口都相同都是localhost这是同源请求withCredentials自动生效无需 CORS。上线后https://app.mycompany.com直接请求https://api.mycompany.com/api/login这才是真正的跨域必须满足全部三道闸机。解决方案只有两个方案一推荐上线后前端代码中baseURL改为真实 API 域名并确保后端正确配置 CORS 和Set-Cookie。方案二妥协在 Nginx 或 CDN 层做反向代理让https://app.mycompany.com/api/代理到https://api.mycompany.com/。这样对浏览器来说仍是同源无需 CORS但后端需识别X-Forwarded-For等头获取真实客户端 IP。实操心得永远不要在生产环境依赖开发期的 proxy。我曾接手一个项目开发同学把所有接口都写成/api/xxx上线后才发现 Nginx 没配代理规则结果所有请求 404。后来花两天重写所有 API 调用才恢复正常。4.2 “Chrome Cookie 备份后为什么登录态失效”——Cookie 的上下文绑定Chrome 的“导出 Cookie”功能通过扩展如 EditThisCookie导出的 JSON 文件只包含name、value、domain、path等字段但不包含SameSite、Secure、HttpOnly等关键元数据。导入后浏览器会用默认值填充SameSiteLax、Securefalse。所以如果你备份的是SameSiteNone; Secure的登录 Cookie导入后变成SameSiteLax在跨域 AJAX 请求中就不再发送。这就是为什么“备份恢复后无法登录”。解决方案使用专业工具如curl或 Postman 手动构造请求观察Set-Cookie响应头在 Chrome DevTools → Application → Cookies 中右键单击 Cookie → “Edit” → 手动修改SameSite为None并勾选Secure更可靠的方式用 Puppeteer 脚本自动化登录并持久化 Cookie它能完整保存所有属性。4.3 “Vue 配置跨域代理后如何获取我的真实的请求地址”——X-Forwarded-* 头的正确使用当 Nginx 代理请求时后端看到的$_SERVER[REMOTE_ADDR]是 Nginx 的内网 IP而非用户真实 IP。要获取真实地址必须依赖X-Forwarded-For头。但在 PHP 中直接$_SERVER[HTTP_X_FORWARDED_FOR]是不安全的因为该头可被客户端伪造。正确做法是function getRealIp() { $ip $_SERVER[REMOTE_ADDR]; // 如果经过可信代理如 Nginx检查 X-Forwarded-For if (isset($_SERVER[HTTP_X_FORWARDED_FOR])) { $ips explode(,, $_SERVER[HTTP_X_FORWARDED_FOR]); // 取第一个非私有 IP假设 Nginx 在最外层 foreach ($ips as $candidate) { $candidate trim($candidate); if (!filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) { continue; } $ip $candidate; break; } } return $ip; }同时Nginx 必须配置location /api/ { proxy_pass https://backend; 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_add_x_forwarded_for会自动追加客户端 IP而$remote_addr是上一级代理的 IP。不要用$http_x_forwarded_for因为它可能被篡改。4.4 “京东签到 Cookie 总是失效使用代理也不行”——浏览器指纹与风控系统这类问题已超出 CORS 范畴属于反爬虫机制。京东、淘宝、抖音等平台的登录态不仅依赖 Cookie还结合设备指纹Canvas、WebGL、AudioContext 等 API 生成的哈希值行为特征鼠标移动轨迹、点击间隔、页面停留时间TLS 指纹Client Hello 中的加密套件顺序、ALPN 协议等。即使你完美复现了 Cookie如果请求来自无头浏览器Puppeteer或代理 IP风控系统会判定为“非人类操作”返回403 Forbidden或跳转验证码。解决方案使用真实浏览器自动化如 Playwright 的chromium.launch({ headless: false })在请求头中模拟真实 UA、Accept-Language、Sec-Ch-Ua 等字段避免高频请求加入随机延迟最重要不要试图绕过风控。京东的风控团队比你想象的更强大。5. 进阶场景微前端、SSR、第三方 SDK 的 Cookie 管理5.1 微前端架构下的跨子应用 Cookie 共享在 qiankun 或 single-spa 架构中主应用https://main.mycompany.com加载子应用https://sub1.mycompany.com和https://sub2.mycompany.com。它们共享同一父域mycompany.com但 Cookie 默认不互通。原因document.cookie只能读取当前document.domain下的 Cookie。sub1.mycompany.com无法直接读取sub2.mycompany.com的 Cookie。解决方案统一 Cookie 域名所有子应用的Set-Cookie都设置Domain.mycompany.com这样主应用和所有子应用都能访问主应用桥接主应用通过props或自定义事件将登录态 Token 传递给子应用子应用用 Token 调用 API避免直接操作 CookieLocalStorage 同步主应用登录后将 Token 写入localStorage并通过window.postMessage通知所有子应用同步。注意localStorage不能跨域但同父域下的子域可以共享sub1.mycompany.com和sub2.mycompany.com都能读写mycompany.com下的localStorage。这是比 Cookie 更灵活的方案。5.2 SSR服务端渲染中的 Cookie 处理Next.js 或 Nuxt.js 在服务端渲染时getServerSideProps或asyncData中无法直接访问document.cookie因为没有 DOM。必须从req.headers.cookie解析。// Next.js pages/index.js export async function getServerSideProps(context) { const { req } context; const cookies parseCookies(req); // 使用 next-cookies 库 if (!cookies.session_id) { return { redirect: { destination: /login, permanent: false } }; } // 验证 session_id 并获取用户信息 const user await validateSession(cookies.session_id); return { props: { user } }; }关键点SSR 中的 Cookie 是从 HTTP 请求头解析的不是浏览器存储的SameSiteNone; Secure对 SSR 无影响因为服务端不执行 SameSite 检查但客户端 hydration 后必须确保前端fetch也开启credentials: include否则 CSR客户端渲染阶段的请求会丢失 Cookie。5.3 第三方 SDK如微信 JS-SDK、支付宝 SDK的 Cookie 隔离微信公众号内嵌 H5 页面时页面域名是https://h5.wechat.com但业务 API 在https://api.mycompany.com。微信 WebView 有自己的 Cookie 存储沙箱与 Chrome 不同。表现在微信中document.cookie只能看到h5.wechat.com下的 Cookieapi.mycompany.com返回的Set-Cookie会被微信 WebView 忽略因为Domain不匹配。解决方案Token 中继微信 JS-SDK 调用wx.config后用wx.login获取 code传给后端换取 access_token后端再用该 token 调用微信 APIURL 参数透传登录后将 session_id 作为 URL 参数如?sidabc123传递给所有页面后端从 query string 读取localStorage postMessage在微信 WebView 中localStorage是可用的且不受 Cookie 域限制可作为临时存储。实操心得永远不要假设第三方 WebView 的 Cookie 行为与 Chrome 一致。我做过一个微信小程序关联的 H5测试时在 Chrome 正常一放到微信里就 401。最后发现是微信 WebView 的SameSite策略更严格必须显式设置SameSiteLax才能工作。我在实际项目中发现最稳妥的跨域登录态管理不是死磕 Cookie而是采用“Token Refresh Token”双机制前端用短期 Token如 15 分钟调用 API后端用长期 Refresh Token如 7 天在 Token 过期时静默续期。这样既规避了 Cookie 的复杂性又保证了安全性。不过这已是另一个话题了——毕竟我们今天聊的是如何让 Cookie 跨域这件事真正跑通。
返回列表