☰
Cookie 机制全解:Set-Cookie、跨域携带与登录态排查
2026/10/26 5:34:30 网站建设 项目流程

1. 一次“登录明明成功、刷新又变回未登录”的排查,先把 Cookie 这层说话逻辑理清

刚接触 Web 开发那阵子,我遇到过一个典型的怪现象:登录接口返回 200,响应头里明明有Set-Cookie返回,可页面一刷新,用户信息接口照样返回未登录。当时的想法很自然地跑偏了:是不是后端 session 没存上?是不是数据库写错了?结果排查一整天,问题出在一个谁都没想到的地方——Cookie 的Path写成了/api/login,于是这条 Cookie 只在登录接口那个路径下被浏览器带回来,用户信息接口在/api/user下,浏览器直接不给带。这就是 Cookie 最容易被忽略的本质:它不是一块随便取用的存储空间,它是一张“门禁条”,浏览器每次出门都会替你交给对应的门卫,门卫认不认,取决于 Cookie 上写的那几个地址条件。

所以我一直建议,学 Cookie 不要一上来就看 API 文档,先建立一个正确的心理模型:Cookie 是服务端写给浏览器的一小段文本,浏览器存下来之后,在以后每一次符合条件(同域名、同路径、协议匹配、没过期)的请求里,自动把它塞进请求头Cookie字段里送回去。注意“每一次”这三个字,它既是 Cookie 最有价值的地方——让无状态的 HTTP 协议拥有了“记忆”,也是它最麻烦的地方——一张 500 字节的 Cookie,会让这个域名下所有静态资源请求都多背 500 字节。

围绕这个模型,本文要解决的事情很具体:第一,一条Set-Cookie里每个字段到底在管什么事,为什么少写一个Path就能让你排查一整天;第二,Cookie 从签发、携带、覆盖到过期,浏览器实际做了哪些动作,哪些行为会让人“反直觉”;第三,前端用document.cookie读写时有哪些怪脾气,怎么封装才不出错;第四,前后端分离和跨域场景下 Cookie 失效的那条完整排查链路;第五,Cookie、Session、Token、本地存储放在一起,到底什么该放哪里;第六,上线前必须检查的那几个安全配置。适合刚入行的前端、后端同学,也适合做了几年但对 Cookie 一直“知其然不知其所以然”的老手做一次系统性校准。

2. 一条 Set-Cookie 的完整解剖:每个字段都是为某个具体问题准备的

2.1 必填的 Name 和 Value,以及那条几乎所有人都会踩的编码规则

Cookie 的最小形态就是名字=值,这是唯一必填的部分。剩下的所有属性纯粹是为了控制浏览器如何“保管”和“交还”这条 Cookie。名字不能包含分号、逗号、空格和控制字符,值在理论上限制更少一些,但同样不建议直接塞特殊字符。

这里就有一个真实的坑:如果你把一段未经编码的 JSON 直接当作 Cookie 的值,里面一旦出现分号,浏览器在拼接Cookie请求头时会直接截断,后端拿到的数据会莫名其妙地少一大截,而且不会报任何错。我见过最典型的场景是把中文用户名直接写进 Cookie,前端读出来是乱码,后端读出来更乱。规范的做事情就是两侧都编码解码:写入侧用encodeURIComponent,读取侧用decodeURIComponent,这样分号、逗号、等号、空格、中文全部安全。

还有一个隐性的限制:单个 Cookie 的名字+值总量,主流实现控制在 4KB 左右(多个实现都以 4096 字节为界,超过的部分可能被静默截断或整条丢弃)。这意味着不要试图在 Cookie 里存一个完整的用户对象或一段 JWT 全量声明。我一般遵守一条自我约束:Cookie 里只放标识符,长度控制在几十字节;任何超过一二百字节的内容,都说明放错地方了。

2.2 Domain 和 Path:决定“带不带”的两道门

Domain和Path是 Cookie 的可见范围控制,也是最容易出错的地方。默认行为是这样的:服务端不写Domain时,浏览器把这条 Cookie 标记为“仅当前域名”(host-only),并且不发给任何子域名。写了Domain=.foo.com这种形式,则foo.com及其所有子域名都能收到它。这里有个只进不出的规则——你不可以给一个 Cookie 设置一个不属于当前域名的 Domain,浏览器会直接丢弃,不会有提示。

Path的默认值更反直觉:不写Path时,浏览器采用请求路径的上一级目录作为默认路径,而不是/。比如你在/api/login这个地址返回了一条不带Path的 Cookie,那默认路径就是/api,/user/info这个接口是带不上这条 Cookie 的。这条规则坑过的人不计其数,开头我自己遇到的那个问题就是它的变种。所以一条铁律:绝大多数业务 Cookie 都显式写Path=/,除非你有特别明确的多应用隔离需求。

关于匹配逻辑,很多人以为Path=/api只能匹配/api本身,其实浏览器的判断是“路径前缀匹配”:/api、/api/、/api/user都会带上,但/apix不会。这种按目录边界匹配的细节,在做多子应用部署时很有用——比如主站和一个后台管理应用挂在同一个域名下,两边各自把 Cookie 的 Path 写死到自己的目录,就能干净隔离,互不干扰。反过来说,如果两个应用都在/下发 Cookie 且名字撞了,就会出现互相覆盖的灵异现象,排查起来极其费劲,因为浏览器不会告诉你“这里发生了覆盖”。

2.3 Expires 和 Max-Age:会话 Cookie 与持久 Cookie 的分界线

不写Expires也不写Max-Age,这条 Cookie 就是“会话 Cookie”,存在内存里,浏览器进程结束就没了。但这里要说一句实话:现代浏览器普遍带有“恢复上次打开的页面”功能,很多情况下会话 Cookie 会被保留下来,所以不要用“没设过期时间”来当作安全手段,会话超时该在后端做就在后端做。

Expires是一个绝对时间,格式必须是 GMT 格式的日期字符串,早年间大家手写这个格式经常出错;Max-Age是相对当前时间的秒数,更好用。两者同时存在时,Max-Age优先级更高。这也是我推荐统一用Max-Age的原因:省心,不容易被服务器时钟偏差搞出问题。

删除 Cookie 的正确姿势也是靠这两个字段:把Max-Age设为 0(或者Expires设成一个过去的日期),浏览器就会立刻把它删掉。但注意——删除时必须和当初写入时的Domain、Path完全一致,否则你只是在另一个作用域里写了一条同名 Cookie 快速过期的空记录,原 Cookie 依然稳稳存在。这一点在“退出登录”功能上翻车的人特别多:退出接口返回了清除 Cookie 的指令,用户点完确实退出了,换个页面又自动登录上了,因为它删的不是同一条。

2.4 Secure、HttpOnly、SameSite 这三个开关,是安全与可用性的取舍旋钮

Secure表示这条 Cookie 只通过 HTTPS 传输,在 HTTP 页面上不发送。它的作用是把 Cookie 的传输从明文通道里拿开。开发本地环境用 HTTP 时,如果误加了Secure,表现就是“代码没错但 Cookie 永远存不进去”,这是新手最常见的自我怀疑时刻。

HttpOnly表示这条 Cookie 前端 JS 读不到,document.cookie里根本不会出现它。它是防 XSS 窃取会话标识的一道闸门。但要做好心理准备:一旦开启HttpOnly,前端就再也无法通过 JS 判断“用户是否已登录”,只能靠发一个探测接口或者让后端渲染状态。这个取舍我在项目里是这样处理的——会话 Cookie 一律HttpOnly,前端登录态靠一个单独的、不含敏感信息的轻量 Cookie 或者接口返回体现。

SameSite是近些年争议最多、也最影响跨域场景的一个属性,取值有三个:Strict、Lax、None。它的作用是控制跨站请求时要不要带 Cookie。这里“跨站”和“跨域”不是一回事,前者按站点(可注册域名)判断,后者按源(协议+域名+端口)判断,很多人混着理解,导致配置永远调不对。具体取值的行为对照可以看下面这张表,我在实际项目里就是照这张表配的:

取值同站请求跨站顶级导航(点链接跳过来)跨站子请求(图片、iframe、fetch)适用场景
Strict携带不携带不携带高敏感操作页、支付流程
Lax携带携带不携带绝大多数常规业务,默认首选
None携带携带携带必须跨站携带,且强制配Secure

现在主流浏览器在未显式声明SameSite时的默认行为已经趋近于Lax,这意味着“什么都不写”在跨站场景下大概率会失效。所以我的建议是:永远显式写出SameSite的值,不要依赖默认值,因为不同实现、不同版本的默认值并不完全统一,靠默认就等于把行为交给了运气。

3. 从签发到落地:Cookie 在浏览器里的生命周期实测

3.1 服务端签发时,那几个反直觉的行为

一个响应里可以下发多条 Cookie,做法有两种:重复多个Set-Cookie头,或者在支持的语言里传一个数组。千万别用逗号把它们拼成一条,因为Expires的日期字符串里本身就含逗号,拼接后解析会彻底乱掉。这是我在早期做接口时亲手写出来的 bug,症状是“Chrome 能存上、另一个浏览器存不上”,本质就是解析分歧。

Node 环境里最直接的写法:

// 原生 Node res.setHeader('Set-Cookie', [ `sid=${sid}; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax`, `theme=dark; Path=/; Max-Age=2592000; SameSite=Lax` ]);

如果是用常见框架封装的写法,属性名要和自己写的字符串版本对应好:

// 以 Express 风格举例(其他框架思路一致) res.cookie('sid', sid, { path: '/', maxAge: 7 * 24 * 3600 * 1000, // 注意这里的单位是毫秒 httpOnly: true, secure: true, sameSite: 'lax' });

这里有一个高频踩坑点:原生的Max-Age单位是秒,而多数框架封装后的maxAge单位是毫秒。写错的结果不是报错,而是 Cookie 要么秒过期,要么活了 1000 倍的时间。我第一次遇到时的表现是“登录后过几秒就掉线”,排查了半天 session 存储,最后发现是单位问题。

另一个细节是姓名重复覆盖:同一个Domain+Path+Name的组合只能存在一条 Cookie。你再次下发同名的就会覆盖掉旧值,不会产生两条。所以续期逻辑可以很简单——只需重新下发一次同名 Cookie,带上新的Max-Age即可,不需要先删再写。

3.2 过期、清理和“看不到但确实存在”

浏览器清理 Cookie 的时机不止“到期”一种,还有容量超限时的驱逐。这里的行为各实现不完全一致,但有一个通用结论值得记住:当某个域名下的 Cookie 总量超过限制时,浏览器可能直接丢弃一些,而且不会通知你。表现就是“代码逻辑明明没问题,但偶尔丢登录态”,尤其在同一个主域名下挂了很多子系统、每个系统都在根路径乱写 Cookie 的项目里特别容易发生。

至于“看不到但确实存在”,最典型的场景就是HttpOnly的 Cookie。你在开发者工具的document.cookie里打印不出来,但在网络面板的请求头里明明带着它。很多新手在这里会反复怀疑“到底存没存上”,正确做法是直接看请求头——请求头是唯一的事实来源,document.cookie只是前端视角的一部分。我曾经带过同学排查问题,他在控制台打印了十几次document.cookie都是空,最后我们打开网络面板一看,请求头里稳稳带着sid,问题根本不在 Cookie 上。

还有一个容易忽视的清理场景:切换协议。同一个域名从 HTTP 跳到 HTTPS 之后,非Secure的 Cookie 和Secure的 Cookie 是两套独立的存储,Secure那条在 HTTP 页面里看不见。所以本地从 HTTP 切到 HTTPS 调试时,很容易出现“旧 Cookie 还在、新的写不进去”的混乱状态,我的习惯是每次切换环境先手动清一遍该站点的 Cookie。

3.3 数量与体积的硬限制,决定了 Cookie 只能放“轻”的东西

行业内比较通行的上限是:单个 Cookie 约 4KB,单个域名下的 Cookie 数量在几十条的量级,总量在几十 KB。这不是某个规范强制,而是各家实现的共同选择,目的是防止请求头无限膨胀。

这个限制带来的直接结论就是 Cookie 的“性价比”问题。假设你的站点有 60 个静态资源请求,你挂了 3 条共 2KB 的 Cookie,那就是白白多传 120KB 的请求头,移动网络下这个开销并不小。所以我给自己项目定的规矩很明确:

  • 会话标识、少量用户偏好(主题、语言)放 Cookie,因为它们需要在服务端渲染时就被读到。
  • 业务数据、列表数据、表单草稿这类,一律放本地存储或内存状态。
  • 任何需要跨请求共享但服务端不需要读的东西,优先考虑本地存储,把请求头留干净。

如果你的接口需要传一些“每次都要带、但服务端其实只在少数接口用”的信息,可以反向利用Path把 Cookie 限制在特定路径下,这样静态资源请求就不会带它。这个小技巧在优化首屏请求体积时经常被忽略。

4. 前端读写 Cookie 的实操:document.cookie 的怪脾气与封装思路

4.1 写入是“追加”、读取是“大字符串”,两件事都反直觉

document.cookie这个 API 是前端里少见的“读写不对称”设计。读取的时候,它返回当前页面可见的所有 Cookie 拼成的一个字符串,格式是a=1; b=2; c=3,你需要自己解析。写入的时候,它不是覆盖整个字符串,而是把你这次写的这一条合并进去——同名同路径的覆盖,不同的追加。

这个设计导致很多莫名其妙的 bug。比如有人写了这样的代码想“清空 Cookie”:

document.cookie = ''; // 这条什么都不会发生

正确的删除姿势是下发一条同名、同路径、立即过期的 Cookie:

document.cookie = 'sid=; Path=/; Max-Age=0';

注意这里必须写Path=/,如果你当初写入时写的是Path=/,删除时却漏写了,那在浏览器看来这是两个作用域的 Cookie,删除不会生效。

另一个怪脾气是:前端无法设置HttpOnly,写了也会被忽略。这反过来也说明一件事——只要这条 Cookie 能被前端读到,它就有被脚本窃取的风险,所以安全等级高的标识必须由后端设置HttpOnly,前端不要试图去读写它。我在评审前端代码时,只要看到有人试图从document.cookie里读会话标识,就会立刻把这段逻辑改成由接口返回状态。

4.2 一份够用的读写封装,以及几个必须考虑的边缘情况

原生 API 用起来不舒服,自己包一层是常规操作。下面这个封装我在多个项目里用过,逻辑不复杂但把几个坑都堵上了:

function getCookie(name) { const raw = document.cookie; if (!raw) return null; const parts = raw.split('; '); for (let i = 0; i < parts.length; i++) { const eq = parts[i].indexOf('='); if (eq === -1) continue; const key = parts[i].slice(0, eq); if (key === name) { return decodeURIComponent(parts[i].slice(eq + 1)); } } return null; } function setCookie(name, value, options) { const opts = options || {}; let str = encodeURIComponent(name) + '=' + encodeURIComponent(value); if (typeof opts.maxAge === 'number') str += '; Max-Age=' + opts.maxAge; str += '; Path=' + (opts.path || '/'); if (opts.domain) str += '; Domain=' + opts.domain; if (opts.secure) str += '; Secure'; if (opts.sameSite) str += '; SameSite=' + opts.sameSite; document.cookie = str; } function removeCookie(name, options) { const opts = options || {}; setCookie(name, '', { path: opts.path || '/', domain: opts.domain, maxAge: 0 }); }

几个容易忽略的点:拆分用'; '而不是';',虽然多数情况下两者都能work,但用'; '更贴合标准格式,也更稳;值用indexOf('=')取第一个等号位置,因为值里可能包含等号(比如 base64 的 padding),用split('=')会把值切碎;decodeURIComponent一定要加try/catch,因为值可能被别的地方写入过非编码内容,遇到%后跟非法字符会抛异常,这个异常没人捕获的话会中断整个初始化流程。

4.3 当前页面能看到哪些 Cookie,取决于四个条件同时成立

前端读不到一条 Cookie,原因可能有四种,我排查时按这个顺序过一遍,基本十秒定位:

  • 它被设置了HttpOnly,前端天生不可见。
  • 它的Path不覆盖当前页面路径。
  • 它的Domain不覆盖当前域名,或者它是 host-only 且你现在在子域名下。
  • 它已经过期,或者被同名 Cookie 覆盖了。

这四种情况在网络面板里都不容易直接看出来,最实用的方法是打开开发者工具里专门管理 Cookie 的那一栏,找到对应域名的列表,逐条看Path、Domain、Expires、HttpOnly四列。我个人的习惯是,只要排查“登录态丢失”这类问题,第一件事就是把这一栏截图,比反复读代码高效得多。曾经有个问题,因为测试环境里有一条同名的历史 Cookie 一直没清,本地无论怎么改代码都复现不了线上,最后就是靠这张列表一眼看穿的。

5. 前后端分离之后,Cookie 失效的排查链路怎么走

5.1 先确认“请求里到底有没有带”,再谈其他

跨域场景下 Cookie 消失,十有八九是三件事中的一件:浏览器根本没存、浏览器存了但没带、带了但后端没读到。这三件事的排查顺序不能乱,我通常按下面的链路一步步往下走,每一步都有明确的观测点:

  1. 看响应头:登录接口返回里有没有Set-Cookie?如果被代理、网关或者框架的错误处理吞掉了,这一步就断了。我遇到过反向代理默认不转发Set-Cookie的情况,表现是后端日志里一切正常,前端就是存不上。
  2. 看 Cookie 列表:开发者工具的存储面板里有没有这条 Cookie?如果没有,检查Secure(页面是 HTTP 却要求Secure)、SameSite=None(没配Secure会被拒绝)、Domain是否匹配当前域名。
  3. 看请求头:业务接口的请求头里有没有Cookie?如果没有,先看请求地址和 Cookie 的 Domain/Path 是否匹配,再看是不是跨站请求被SameSite拦住了。
  4. 看后端:请求头里有,但后端取不到值。这时候要检查后端读的是哪个字段、网关有没有改写请求头、名字有没有大小写或前缀混淆。

这条链路的价值在于它是“单向收敛”的:每一步的结论都会排除一整类可能性,不会来回打转。我见过不少同学排查跨域 Cookie 问题时,一上来就改后端的 CORS 配置,改了三小时,其实问题在第 2 步——Cookie 压根没存上。

5.2 一个完整的跨域携带示例,把三个开关同时打开

假设前端在https://app.example.test,接口在https://api.example.test,这两个是不同源、但同站(可注册域名相同)。要让 Cookie 正常携带,三处都要配好,缺一不可。

第一处,后端下发 Cookie 时:

res.cookie('sid', sid, { path: '/', maxAge: 7 * 24 * 3600 * 1000, httpOnly: true, secure: true, sameSite: 'lax' // 同站跨域,Lax 就够 });

第二处,后端的跨域响应头必须允许携带凭证,并且Access-Control-Allow-Origin不能用通配符,必须回显具体的源:

res.setHeader('Access-Control-Allow-Origin', 'https://app.example.test'); res.setHeader('Access-Control-Allow-Credentials', 'true');

第三处,前端的请求必须带上凭证选项:

fetch('https://api.example.test/user/info', { method: 'GET', credentials: 'include' });

如果用的是常见的 HTTP 请求库,那一侧也有对应的默认值开关,默认值是“不携带跨域 Cookie”。这块最坑的地方在于:它不携带的时候不报错,只是悄悄不带,接口返回 401,你就开始怀疑人生。我一般会在项目初始化时就统一把这个默认值设好,绝不依赖每个调用点手动指定,否则一定会漏。

5.3 跨站场景下的 SameSite=None,和它带来的连带成本

如果前端和接口真的在不同站点(比如嵌入到另一个站点里的页面),那就必须用SameSite=None; Secure。这两个属性是绑定的,只写None不写Secure,浏览器会直接拒绝这条 Cookie,而且通常只在控制台里给一条警告,很容易被忽略。

但我要提醒的是,选None是有代价的:它等于放弃了SameSite提供的跨站请求保护,需要另外的手段来做请求来源校验。所以我的判断标准很简单——能用同站部署解决,就不要用SameSite=None。实际项目里,把前端和接口部署到同一个可注册域名下(一个主域 + 一个 API 子域)通常并不难,能省掉一大堆跨站问题。

还要提一句第三方 Cookie 的现状:主流浏览器对第三方 Cookie 的限制越来越严格,因此依赖第三方 Cookie 的方案(比如嵌入场景下靠 Cookie 维持身份)稳定性逐年下降。如果你的功能设计强依赖第三方 Cookie,建议尽早评估替代方案,比如由外层页面通过消息通道把身份信息传给内嵌页面,而不是让内嵌页面自己去读 Cookie。这个改动越早做越省事。

6. Cookie、Session、Token、本地存储:把东西放对位置比选对技术更重要

6.1 四者的角色其实不冲突,混淆才产生问题

经常看到“Cookie 和 Session 哪个好”这类讨论,其实这两个东西根本不是同一个层面的东西。Cookie 是浏览器端的存储与传输机制,Session 是服务端的会话状态存储方式。Session 需要一把钥匙来找到对应的数据,这把钥匙通常就存在 Cookie 里。所以现实中的组合是“Cookie 存 sessionId + 服务端存 Session 数据”,而不是二选一。

Token 是另一种思路:把状态编码进一个自包含的字符串里,服务端不存(或者说少存),每次校验靠签名。它可以放在 Cookie 里由浏览器自动携带,也可以放在请求头里由前端手动加。放 Cookie 里省事但会受 CSRF 影响;放请求头里规避了 CSRF 但需要前端自己管理,并且要额外考虑存储位置的安全性问题。

本地存储(localStorage / sessionStorage)则是纯浏览器端的键值存储,不参与请求自动携带,必须由前端代码显式读取再放进请求头。这一点决定了它和 Cookie 的根本差异:它不会自动跟着请求跑,所以不存在请求头膨胀和 CSRF 问题,但也意味着服务端渲染时读不到它。

6.2 一张对照表,把选型这件事说清楚

维度CookielocalStoragesessionStorage服务端 Session
是否自动随请求发送是否否不适用
服务端渲染可读是否否是
容量约 4KB/条数 MB数 MB取决于存储介质
生命周期由Max-Age控制手动清除关闭标签页即清由服务端控制
XSS 风险可设HttpOnly规避无法规避无法规避不适用
CSRF 风险存在(可配SameSite缓解)无无取决于传递方式

看完这张表,选型其实就变成了三个问题:服务端要不要读?要读就只能是 Cookie。能不能接受被脚本读到?不能就HttpOnlyCookie。数据量是不是超过了 4KB?超了就换地方。

我在实际项目里最常见的误用是:把整个用户信息对象(包含权限列表、菜单树)塞进 Cookie。这带来的后果不只是请求头膨胀,更麻烦的是数据一致性——用户在后台被改了权限,Cookie 里的旧权限还在,页面上照旧显示那些菜单,点进去全是 403。权限这类会变化的数据,永远应该由接口实时获取,而不是缓存到 Cookie 里。

6.3 关于“会话标识放哪里”的那个取舍,我的实际选择

如果项目是传统的服务端渲染,会话标识放HttpOnly + Secure + SameSite=Lax的 Cookie,几乎是最省心的方案,前端完全不用管身份传递。

如果是前后端分离、纯 API 形态,我更倾向于把标识放请求头,配合SameSite和后端校验来源。原因是这类项目里跨域配置本来就复杂,一旦涉及多端(网页、小程序、桌面客户端),Cookie 的域和站点规则会让你反复调试,而请求头方案天然没有这些限制。

但有一种情况我会坚定选 Cookie:需要浏览器参与“无感续期”的场景。因为 Cookie 的过期由浏览器管理,服务端重新下发一条就能续,前端一行代码都不用写;而请求头方案必须由前端自己算过期时间、自己发续期请求、自己处理续期失败,逻辑量明显更大,也更容易漏掉边界情况。这个取舍没有标准答案,关键是知道每条路的成本在哪。

7. 上线前必须过的那几个安全检查,以及我踩过的两个坑

7.1 会话类 Cookie 的最小配置集,抄就完了

凡是承载身份标识的 Cookie,我给自己定的最低配是这五项,缺一项都要在代码评审时被打回:

res.cookie('sid', sid, { path: '/', maxAge: 2 * 3600 * 1000, httpOnly: true, secure: true, sameSite: 'lax' });

httpOnly挡脚本读取,secure挡明文传输,sameSite挡跨站自动携带,path限定作用域避免泄漏到整个域名下不必要的路径,maxAge让会话有明确的过期边界。这五项加起来基本覆盖了大部分日常攻击面。

至于过期时间定多长,我的经验是按业务敏感度分档:后台管理类 1 到 2 小时,普通用户端 7 天左右,配合“活跃则续期”的策略。不要图省事直接设一年,那样一旦出现凭证泄漏,处置窗口会非常被动。

7.2 前缀机制:一个很多人不知道但极其好用的防御手段

浏览器支持用 Cookie 名字的前缀来强制某些安全属性,这是防御“子域名覆盖”问题的利器。规则大致是这样:名字以__Host-开头的 Cookie,必须满足Secure、来自安全通道、Path=/,并且不允许设置Domain,这就保证了它是当前域独占的,任何子域名都无法伪造或覆盖它。名字以__Secure-开头的,则强制要求Secure。

为什么这个有用?想象一个场景:主域名下有一个存在 XSS 漏洞的子域名,攻击者可以在那个子域名上写入一个Domain=.主域名的 Cookie 来覆盖你的会话标识,这被称为会话固定攻击。用__Host-前缀就堵住了这条路。我在一个多子域的项目里上线这个前缀之后,顺带还发现了两处历史遗留的 Cookie 覆盖冲突,收益比预期大。

需要注意的是,改名前缀属于破坏性变更——旧 Cookie 名变了,线上老用户的会话会失效一次。所以要在低峰期做,并且提前在媒体渠道或系统公告里说明“可能会有一次重新登录”。这个操作我吃过亏:某次没打招呼直接改,第二天客服那边收到一批“账号被盗”的工单,其实只是所有人被登出了一次。

7.3 CSRF 和 XSS 的边界,别指望一个属性解决所有问题

SameSite能缓解 CSRF,HttpOnly能缓解会话标识被窃取,但这两个都不是完整方案。我在实际项目里的做法是分层的:

  • 对状态变更类接口(下单、改密码、转账、删除),除了 Cookie 校验,再加一道显式的令牌校验或来源(Origin / Referer)校验。原因很简单——SameSite的判定依赖浏览器实现,而项目里往往存在浏览器版本或接入端类型不一致的情况,不能当作唯一防线。
  • 对 XSS,重点在输出转义和内容安全策略配置,HttpOnly只是把损失从“会话被直接盗走”降低到“页面内被操作”,不代表没事。

还有个容易忽略的细节:SameSite=Lax对顶级导航是放行的,也就是用户点一个外部链接跳到你的站点,Cookie 是会带上的。这意味着如果你的某个 GET 接口会修改数据(这本身就是设计错误),Lax救不了你。所以我一直坚持“GET 只读、变更用 POST”,这条纪律比任何属性配置都管用。

最后分享一个调试小技巧:排查 Cookie 问题时,用命令行工具直接看响应头有时候比浏览器更直观,因为它不会替你做任何判断,也不会隐藏任何字段:

curl -i -X POST 'https://api.example.test/login' \ -H 'Content-Type: application/json' \ -d '{"name":"tester","pass":"***"}'

返回里Set-Cookie那一行的每一个属性都清清楚楚,包括你以为是默认值但其实没生效的那些。我排查线上问题时经常先用这条命令确认服务端到底发了什么,再回过头看浏览器的行为,能省掉大量“猜”的时间。上线前把 Cookie 的那几个属性当回事,后面能少掉很多次半夜被告警叫醒的经历。

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

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

立即咨询