☰
Cookie 从原理到实战:登录态、跨域与安全排查指南
2026/10/26 15:29:26 网站建设 项目流程

干这行时间长了,总被刚入行的朋友问起同一件事:为什么本地测得好好的登录功能,一上线就“掉登录”?为什么清一下浏览器缓存就能解决一半的诡异问题?答案八成和 Cookie 有关。Cookie 是 Web 里最古老、最不起眼、却几乎无处不在的一层机制,小到记住你上次填的用户名,大到维持整个登录态,背后都是它在跑。可偏偏这么基础的东西,很多教程只教你document.cookie怎么赋值,却不讲 Domain 为什么没生效、SameSite 为什么把请求拦了、大小超了为什么静默失败。这篇就把 Cookie 从概念、属性、实操到排查一次性讲清楚,既适合刚接触前后端交互的新手打底,也适合已经写过登录模块、但被跨域和失效问题折腾过的朋友查漏补缺。下面所有结论都来自实际项目里踩出来的经验,能直接抄作业。

1. Cookie 到底是什么:从一次登录说起

1.1 用生活类比理解 Cookie 的本质

我习惯把 Cookie 理解成“商家发给你的一张会员小票”。你第一次进店报手机号注册,前台给你打印一张小票,上面写着会员编号、有效期、哪家门店能用。之后你每次进店,出示这张小票,前台不用再问你叫什么、什么时候办的卡,直接认票不认人。Cookie 干的事情一模一样:服务端在响应头里塞一张“小票”,浏览器负责保管,后续每次向同一站点发请求,浏览器自动把小票附上,服务端看一眼就知道“哦,是老张”。

这个类比里有个关键点容易被忽略:小票是浏览器替你保管并自动出示的,不是你的代码每次手动去加的。很多人第一次写接口调试时纳闷“我明明没在请求头里写 Cookie,服务端怎么拿到我的登录态的”,原因就在这里。自动携带这个特性,是 Cookie 好用的根源,也是后面所有安全问题的根源——它太好用了,好用到来者不拒,只要域名和路径对得上,浏览器就替你把票递出去,哪怕这个请求是你没意识到的第三方页面发起的。

还有一个常见误解:Cookie 不是“存在服务器上的用户数据”。它实实在在存在你自己的电脑或手机里,服务端只是发了一张票,票据内容浏览器看得见、改得动(除非你做了签名或加密)。所以凡是“把用户余额、权限等级明文写进 Cookie”的做法,本质上等于把家门钥匙挂在门把手上。明白这一点,后面讲 HttpOnly 和签名机制时你就不会觉得是多余的设计。

1.2 Cookie 在 HTTP 无状态协议中的角色

HTTP 协议本身是“健忘”的。你发一个请求,服务端处理完返回结果,这一轮交互就结束了,服务端不会记得上一秒你是谁。这种设计在早期看网页时没问题,反正每次都是新内容;但一旦涉及登录、购物车、多步表单,就必须要有个东西把多次请求“串”起来。Cookie 就是最早、最通用的那根线。

它的工作方式很朴素:服务端在响应头里写Set-Cookie,浏览器按规则存下来;下次请求同一站点,浏览器在请求头里补一个Cookie,把存的内容带回去。一来一回,无状态的 HTTP 就被“人为”地加上了状态。你可以把它看成给每个请求贴了一张便签,便签内容由服务端决定,浏览器负责递送。

这里要区分两个概念:会话标识和会话数据。正规做法是 Cookie 里只放一个随机生成的 Session ID(一串没规律的字符),真正的用户信息存在服务端的内存、Redis 或数据库里。浏览器每次带着 ID 来,服务端拿 ID 去查“这个 ID 对应谁”。这样做的好处是:即使 Cookie 被人看到,他也只拿到一串无意义的字符,改不了内容、猜不出身份。反过来,如果把userId=1001; role=admin直接写进 Cookie,那就是把数据库字段暴露在客户端,风险极高。我见过不止一个早期项目这么干,后来做权限校验时被改包提权,返工成本很高。

1.3 一次完整 Cookie 往返的现场记录

光说理论容易飘,我把一次真实的登录往返抓包过程拆开写,你对照着看就通了。

第一步,用户提交账号密码,请求类似这样:

POST /api/login HTTP/1.1 Host: example.com Content-Type: application/json {"username":"demo","password":"******"}

第二步,服务端校验通过,在响应头里下发 Cookie:

HTTP/1.1 200 OK Content-Type: application/json Set-Cookie: sid=8f3a9c2e7b1d4a6f; Path=/; Domain=example.com; Max-Age=7200; HttpOnly; Secure; SameSite=Lax {"code":0,"msg":"ok"}

第三步,浏览器把这行Set-Cookie解析、校验、落盘。注意它不会无脑存,会检查 Domain 是否属于当前站点、Secure 是否要求 HTTPS、SameSite 是否满足当前上下文。任何一条不满足,这条 Cookie 就被丢弃,而且不会有明显报错,这是新手最容易卡住的地方。

第四步,用户后续访问https://example.com/api/userinfo,浏览器自动补上:

GET /api/userinfo HTTP/1.1 Host: example.com Cookie: sid=8f3a9c2e7b1d4a6f

服务端拿到sid,去会话存储里查出对应用户,返回数据。

整个链路里,开发者真正要操心的只有两件事:下发时属性写对,排查时知道去哪看。浏览器开发者工具的 Application/存储面板里,能直接看到当前域名下所有 Cookie 的名称、值、Domain、Path、过期时间、HttpOnly、Secure、SameSite 标记,这是排查的第一现场,比猜快得多。

2. Cookie 的核心属性拆解:每个字段都是踩坑换来的

2.1 生命周期:Expires 与 Max-Age 到底该用哪个

Cookie 分两种命:会话 Cookie和持久 Cookie。不写过期时间的,就是会话 Cookie,存在浏览器内存里,关掉浏览器(准确说是关闭整个浏览器进程,不是关标签页)就没了。写了过期时间的,落到磁盘,关电脑再开还在,直到时间到或者被清理。

控制生命周期的属性有两个:Expires和Max-Age。Expires是一个绝对时间点,格式是 HTTP 日期,比如Expires=Wed, 21 Oct 2026 07:28:00 GMT;Max-Age是一个相对秒数,比如Max-Age=7200表示两小时后过期。两个都写的话,现代浏览器以 Max-Age 为准,Expires 只是给老客户端兜底,现在基本可以只写 Max-Age。

这里有两个真实踩过的坑。第一个是时区。Expires 必须用 GMT 时间,如果你用本地时间拼字符串,服务器又在东八区,那就会整体偏移八小时,表现出来就是“明明设了一天,怎么两小时就掉了”。用 Max-Age 就没这个问题,秒数是绝对的,跟时区无关,所以我个人的习惯是能用 Max-Age 就不用 Expires。

第二个坑更隐蔽:设成 0 或负数不是“永不过期”,而是“立即删除”。想让用户退出登录,服务端下发Max-Age=0或者一个过去的时间点,浏览器收到后会把同名 Cookie 清掉。这个技巧在“退出登录”接口里非常常用,比前端手动删更可靠,因为 HttpOnly 的 Cookie 前端根本删不了。

还有一个常识要纠正:很多人以为 Cookie “永不过期”是好事,把过期时间设成十年。实际经验是,长期 Cookie 一旦泄露,攻击窗口极长,而且用户换设备、清缓存后体验也不连贯。常见的登录态做法是:会话 Cookie 设较短(比如 2 小时),配合一个较长的 Refresh Token 做续期,既安全又不折腾用户。

2.2 作用域:Domain 与 Path 决定了谁有资格看见

Cookie 不是全局的,它有明确的“辖区”。Domain管域名,Path管路径,两个一起决定浏览器在什么时候把这张票递出去。

先说 Domain。不写 Domain 的时候,Cookie 默认只属于当前域名,叫host-only,www.example.com的 Cookie 不会发给api.example.com。写了Domain=example.com,则所有子域名共享,www、api、m都能收到。这里有个硬规则:Domain 不能跨主域设置,example.com的页面不能给other.com设 Cookie,这是浏览器安全模型的基本盘。另外,Domain=.example.com前面的点在现代浏览器里已经等同于不写点,别再纠结这个细节。

再说 Path。默认 Path 是当前请求路径的上一级目录。比如页面路径是/user/profile/index,不写 Path 时默认是/user/profile,那么/user/profile/avatar能带上,/api/data就带不上。写Path=/表示全站可用。我见过一个典型事故:某项目把接口拆到了/api/v2/前缀下,但登录 Cookie 的 Path 写死成/api/v1/,结果升级后所有请求都不带 Cookie,表现为“登录成功但接口全 401”,排查了半天以为是网关问题,最后发现是 Path 不匹配。

注意:Domain 和 Path 都只在设置时生效,已经存下来的 Cookie 不会因为你后来改了配置就自动迁移。改了作用域,需要重新下发一次,或者引导用户重新登录。

还有一个容易混淆的点:Path 不是“权限”,不是“认证边界”。它只是浏览器决定带不带 Cookie 的字符串前缀匹配规则,服务端不能指望靠 Path 来隔离权限。真正的权限校验必须在服务端做,别把 Path 当安全控制手段。

2.3 安全三件套:Secure、HttpOnly、SameSite

这三个属性是 Cookie 安全的地基,缺一个都可能出问题。

Secure的意思很简单:只允许在 HTTPS 连接上发送这条 Cookie。HTTP 请求即使域名匹配也不会带上。这个属性防的是中间人窃听,在公共网络环境下尤其重要。本地开发时经常遇到“加了 Secure 结果 http://localhost 测不了”的情况,浏览器的处理是:localhost和127.0.0.1被视为安全上下文,Secure 的 Cookie 可以正常工作,所以本地调试不必为此去掉 Secure。如果确实需要在内网 HTTP 环境调试,可以临时用环境变量控制,但上线必须开。

HttpOnly的意思是:这条 Cookie 不允许被 JavaScript 通过document.cookie读取。它防的是 XSS——页面被注入恶意脚本后,脚本第一件事往往就是偷 Cookie 发走。加了 HttpOnly,脚本读不到,攻击价值大幅下降。但要说清楚它的边界:HttpOnly 只挡“读取”,不挡“利用”。攻击者仍然可以在页面里直接发起请求,浏览器照样会自动带上 Cookie,这就是 CSRF 的土壤。所以 HttpOnly 是必要条件,不是充分条件。

SameSite是近几年被提上台面的属性,它管的是“跨站请求要不要带上这条 Cookie”。取值有三个:

取值行为适用场景
Strict跨站请求一律不带高敏感操作,如支付、后台管理
Lax顶层导航的 GET 请求可带,其他跨站请求不带大部分站点的默认选择
None跨站也带,但必须同时设置 Secure第三方嵌入、跨域单点登录

不写 SameSite 时,主流浏览器现在默认按Lax处理。这个默认值改动坑了不少老项目:以前靠 iframe 嵌入或者跨站 POST 提交的流程,突然发现 Cookie 不带了,表单提交后变成未登录状态。解决办法要么改成SameSite=None; Secure,要么把交互方式改成顶层跳转。

2.4 属性组合速查表:别凭记忆写

属性多,组合起来容易漏,我整理了一张常用组合表,写代码时对着抄。

场景推荐组合说明
登录会话Path=/; Max-Age=7200; HttpOnly; Secure; SameSite=Lax最通用,防脚本读取,防大部分跨站
前台可读的偏好Path=/; Max-Age=31536000; SameSite=Lax如主题、语言,不需要 HttpOnly
跨域接口调用Path=/; HttpOnly; Secure; SameSite=None必须 HTTPS,且服务端要配 CORS 凭证
退出登录Path=/; Max-Age=0同名同作用域才能删掉
子域共享Domain=example.com; Path=/注意所有子域都能读写

表格里最右边那列是关键:属性组合不是越多越好,是越匹配越好。曾经有个项目为了“安全”,给所有 Cookie 都加了SameSite=Strict,结果用户从外部链接点进来时全是未登录状态,因为 Strict 下顶层导航也不带 Cookie。后来改成 Lax 才恢复正常。安全属性和用户体验之间永远要做权衡,别一刀切。

3. 实操:从前端到服务端把 Cookie 玩明白

3.1 服务端下发 Cookie 的正确写法

先看服务端。不管你用哪种语言,最终都是往响应头写Set-Cookie,但不同框架封装程度不一样,坑也不一样。

Node.js 原生写法:

res.setHeader('Set-Cookie', [ `sid=${sessionId}; Path=/; Max-Age=7200; HttpOnly; Secure; SameSite=Lax` ]);

注意Set-Cookie是可以出现多次的,一次响应能下发多条 Cookie,所以它的值用数组表示。如果你用对象或者用setHeader传单个字符串,多条 Cookie 就会互相覆盖,表现出来就是“只生效了一条”。这个细节在需要同时下发会话 Cookie 和偏好 Cookie 时特别容易翻车。

Express 的写法更省事:

res.cookie('sid', sessionId, { path: '/', maxAge: 7200 * 1000, // 注意单位是毫秒 httpOnly: true, secure: true, sameSite: 'lax' });

这里最大的坑是单位。原生Max-Age是秒,Express 的maxAge是毫秒,写错了就是“设了两小时结果两秒就没了”或者“设两小时结果变成两个月”。我踩过一次,排查时一度怀疑是浏览器 bug,最后发现少乘了 1000。建议团队里统一约定:配置项命名带上单位,比如sessionMaxAgeMs,从源头避免。

Java 生态(Servlet)里通常用Cookie对象加addCookie,但它没有直接提供 SameSite 的 setter,需要手动拼响应头或者用框架的封装。Spring Boot 里较新的版本支持通过server.servlet.session.cookie.same-site配置,或者用ResponseCookie构造。这一点在跨域调试时经常被漏掉,导致明明配了 SameSite 却没生效。

提示:下发 Cookie 前先确认响应没有被 CDN 或反向代理改写。有些代理会过滤Set-Cookie头,尤其是带SameSite=None的,遇到“接口返回里有,浏览器里没有”的情况,先看代理层。

3.2 前端 JavaScript 读写 Cookie 的正确姿势

document.cookie这个 API 设计得相当反直觉,第一次用的人基本都会懵。

读的时候,它返回当前域名下所有非 HttpOnly的 Cookie,拼成一个字符串,格式是key1=value1; key2=value2。你必须自己写解析函数:

function getCookie(name) { const target = `${encodeURIComponent(name)}=`; const parts = document.cookie ? document.cookie.split('; ') : []; for (const part of parts) { if (part.startsWith(target)) { return decodeURIComponent(part.slice(target.length)); } } return null; }

写的时候更怪,不是赋值覆盖,而是追加:

document.cookie = 'theme=dark; Path=/; Max-Age=31536000; SameSite=Lax';

这条语句只会新增或覆盖同名同 Domain 同 Path的那一条,其他 Cookie 纹丝不动。如果你只写document.cookie = 'theme=dark',Path 默认是当前页面路径,很容易出现“首页能读到、子目录读不到”的问题。所以前端写 Cookie,务必显式写 Path=/。

删的时候没法直接删,只能设过期:

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

同样是同名同 Path 才能删掉,Domain 不一致也删不掉。这就是为什么我建议:能由服务端管的状态,尽量别让前端管。前端的 Cookie 操作没有事务、没有回滚、没有统一入口,散落在各个组件里,后期维护很痛苦。像主题、语言这种纯客户端偏好,其实用 localStorage 更合适,后面会讲。

3.3 一个可复现的登录态保持 Demo

光看片段不够,我把一个最小可运行的登录态流程拆成步骤,你照着搭一遍就彻底明白了。

第一步,搭一个能收发的服务端。两个接口:/api/login负责校验并下发 Cookie,/api/me负责根据 Cookie 返回当前用户。

const sessions = new Map(); // 演示用,生产环境请换成 Redis 等 app.post('/api/login', (req, res) => { const { username, password } = req.body; if (username !== 'demo' || password !== 'demo123') { return res.status(401).json({ code: 401, msg: '账号或密码错误' }); } const sid = crypto.randomUUID(); sessions.set(sid, { username, loginAt: Date.now() }); res.cookie('sid', sid, { path: '/', maxAge: 2 * 60 * 60 * 1000, httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax' }); res.json({ code: 0, msg: 'ok' }); }); app.get('/api/me', (req, res) => { const sid = req.cookies?.sid; const user = sid ? sessions.get(sid) : null; if (!user) return res.status(401).json({ code: 401, msg: '未登录' }); res.json({ code: 0, data: { username: user.username } }); });

第二步,前端发起登录。如果是同源部署,直接fetch('/api/login', {...})就行。如果是前后端分离、跨域部署,必须显式带上凭证:

await fetch('https://api.example.com/api/login', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'demo', password: 'demo123' }) });

第三步,服务端配 CORS。这一步是最容易漏的。跨域场景下,服务端必须返回Access-Control-Allow-Credentials: true,并且Access-Control-Allow-Origin不能是*,必须是具体域名。很多人卡在这里,前端报“凭证模式下的请求被拦截”,其实原因就在这两条配置。

第四步,验证。登录成功后,打开开发者工具的存储面板,应该能看到sid,并且 HttpOnly 那一列是勾上的。再访问/api/me,返回用户信息即成功。如果/api/me一直 401,按后面的排查表逐条走。

这个 Demo 只有几十行,但把 Cookie 下发、携带、跨域、安全属性全串起来了。我建议每个新人都手搭一遍,比看十篇文章管用。

3.4 参数计算:过期时间、签名与容量估算

写业务代码时,有几个数需要自己算清楚,拍脑袋容易出问题。

过期时间怎么定。登录态太短,用户频繁重登,体验差;太长,泄露风险高。我的经验值是:普通业务后台 2 小时,配合操作时自动续期;面向消费者、使用频率高的产品,可以做到 7 天,但要有服务端的强制失效机制(比如改密码后踢掉所有会话)。续期的实现方式是每次请求命中会话就刷新一次服务端存储的 TTL,而不是反复重发 Cookie,减少无谓的头部开销。

签名怎么算。如果确实需要在 Cookie 里放少量非敏感信息(比如多租户的租户标识),一定要签名。常见做法是payload.signature格式,签名用 HMAC-SHA256,密钥只存在服务端:

const crypto = require('crypto'); function sign(payload, secret) { const data = Buffer.from(JSON.stringify(payload)).toString('base64url'); const mac = crypto.createHmac('sha256', secret).update(data).digest('base64url'); return `${data}.${mac}`; }

校验时先验签再解析,验签失败直接拒绝。注意要用恒定时间比较,防止时序攻击,Node 里可以用crypto.timingSafeEqual。这个细节写业务代码时基本没人提,但安全评审时经常被点名。

容量怎么估。单个 Cookie 规范上建议不超过 4KB,浏览器对单域名 Cookie 总数量也有限制(各家不同,通常在几十到一两百条量级)。4KB 是什么概念?大概 4000 个英文字符,中文字符按编码算会更占空间。实际项目里我一般控制在 1KB 以内,因为 Cookie 是每个请求都要带上的,域名下所有静态资源请求、接口请求全算进去,一条臃肿的 Cookie 会显著增加上行流量。我见过有团队把整个用户权限树序列化进 Cookie,接近 4KB,结果每次请求头部都巨大,弱网下首屏明显变慢,后来换成服务端存储加一个短 ID,流量立刻降下来。

4. Cookie、LocalStorage、SessionStorage 怎么选

4.1 三者能力对比表

这三个东西经常被混着用,先把差异摆清楚。

维度CookieLocalStorageSessionStorage
容量约 4KB约 5MB约 5MB
生命周期可设置,跨会话永久,除非手动清除标签页关闭即清
是否随请求发送是,自动携带否否
前端可读HttpOnly 时不可读可读可读
跨标签页共享是是否
服务端可设置是否否

这张表里最关键的一行是“是否随请求发送”。Cookie 的独特价值就在这里:服务端能直接读到,不需要前端额外拼请求头。而 LocalStorage 和 SessionStorage 纯粹是客户端存储,服务端想看就得靠前端手动塞进请求头,多一层代码,也多一层出错的可能。

4.2 选型背后的真实考量

选型的核心问题是:这份数据需不需要服务端知道,以及需不需要防脚本读取。

登录凭证必须服务端知道,所以只能放 Cookie,并且要 HttpOnly。这一点没有商量的余地,放 LocalStorage 是常见但危险的做法——一旦 XSS,脚本一行localStorage.getItem('token')就把 token 拿走了,而 HttpOnly 的 Cookie 至少能挡住直接读取。

大块的业务缓存、列表数据、草稿,放 LocalStorage 更合适。它容量大、不占请求头、读写是同步的(虽然同步也意味着大量读写会阻塞主线程,注意别在循环里狂写)。像表单草稿这种“只在这一个标签页有效”的数据,用 SessionStorage 更贴切,关掉页面自动清,不会残留脏数据。

有一个常被忽视的点:Cookie 是有请求开销的。假设你的站点域名下有 30 个静态资源请求,Cookie 是 2KB,那就额外多传 60KB。移动端弱网下这不是小数目。所以静态资源最好放独立域名或者 CDN 域名,天然不带业务 Cookie,这也是很多大站把图片、脚本放static子域的原因之一。

4.3 常见误用场景

几个我实际见过的误用,值得列出来提醒。

第一,把 token 同时存 Cookie 和 LocalStorage。有的团队为了“双保险”,两边都存一份,结果退出登录时只清了一边,另一边还能用,形成幽灵会话。正确做法是单一来源,清就清干净。

第二,用 Cookie 存大 JSON。前面说过容量问题,还有个隐患是编码:JSON 里的分号、逗号、等号在 Cookie 字符串里都是分隔符,不做编码直接塞进去,解析时必然出错。必须encodeURIComponent。

第三,把 SessionStorage 当跨标签页共享用。它不共享,新开一个标签页就是空的。需要跨标签页同步的登录态,只能靠 Cookie 或者 BroadcastChannel 之类的手段。

第四,以为清了 LocalStorage 就等于退出登录。如果登录态在 Cookie 里,清 LocalStorage 一点用没有,用户下次打开还是登录状态。这类“退出不彻底”的问题在测试阶段经常被漏掉。

5. 安全相关的坑:CSRF、XSS 与会话固定

5.1 HttpOnly 挡得住什么、挡不住什么

HttpOnly 的作用边界必须说清楚,否则容易产生虚假的安全感。

它挡住的:document.cookie读取。XSS 注入的脚本想直接把会话 Cookie 发到攻击者服务器,读不到值,这条路断了。

它挡不住的:脚本仍然可以在当前页面上下文里发起请求。比如注入一段fetch('/api/transfer', {method:'POST', ...}),浏览器会自动带上 Cookie,服务端看来这就是一个合法请求。所以 HttpOnly 降低了 XSS 的危害,但没消除。真正要做的还是从源头防 XSS:输出转义、内容安全策略、避免innerHTML拼接用户输入。

还有一个细节:HttpOnly 是设置时的属性,一旦设上,前端就无法通过脚本把它改掉或者删掉。这也是为什么退出登录最好由服务端下发删除指令,前端删不掉 HttpOnly Cookie,只能干瞪眼。

5.2 SameSite 的三种取值实测差异

SameSite 这三个值的行为差异,光看文档不够直观,我用一个具体场景说明:用户在 A 站页面里点击一个链接跳到 B 站,或者 A 站页面里有个 iframe 嵌着 B 站,或者 A 站页面用表单 POST 提交到 B 站。

Strict下,上面三种情况浏览器都不给 B 站带 Cookie。表现就是:用户从搜索引擎、从邮件、从聊天软件点链接进来,一律显示未登录,必须先回首页再点一次才恢复。这个体验很割裂,所以 Strict 一般只用在支付确认、后台管理等极敏感路径上。

Lax下,顶层导航的 GET 请求会带 Cookie,iframe 和跨站 POST 不带。这个折中覆盖了绝大多数正常场景:点链接进来能保持登录,同时挡住了跨站表单提交这类典型 CSRF。所以它成了浏览器默认值。

None下,跨站也带,但强制要求 Secure。这是第三方嵌入、跨域单点登录场景的唯一选择。用它的代价是 CSRF 防护要另想办法,不能指望 SameSite 兜底。

注意:SameSite 的判断依据是“站点”,不是“域名”。a.example.com和b.example.com属于同一个站点,互相请求不算跨站。这个规则经常被搞混,导致误判。

5.3 CSRF 防护的组合拳

单靠一个属性防不住 CSRF,实际项目里我是这么组合的。

第一层,SameSite=Lax作为默认值,挡住大部分跨站表单提交。第二层,敏感接口校验Origin或Referer头,来源不在白名单直接拒绝。第三层,非 GET 接口加 CSRF Token:服务端下发一个随机 token(可以放在页面里或者单独的 Cookie 里),前端提交时放进请求头,服务端比对。第四层,关键操作要求二次验证,比如改密码、转账要求重新输入密码或者短信验证码。

这四层不是每层都要上,按业务敏感度取舍。普通的内容发布接口,第一层加第二层就够了;资金相关的,四层全上也不为过。

还有一个常被忽略的点:GET 接口不要产生副作用。有些老项目用GET /api/delete?id=1删数据,这种设计在 SameSite=Lax 下依然会被跨站顶层导航触发(因为 GET 导航会带 Cookie),风险很高。规范做法是删除、修改一律用 POST/PUT/DELETE,并且带 CSRF Token。

6. 常见问题与排查实录

6.1 问题速查表

排查 Cookie 问题,先看现象再对照下表,能省很多时间。

现象可能原因排查动作
响应头有 Set-Cookie,浏览器里没有Domain 不匹配、Secure 但走 HTTP、SameSite 被拦看存储面板是否有该条目,看协议是否为 HTTPS
登录成功,后续接口 401Path 不匹配、跨域没带凭证、CORS 配置缺 Allow-Credentials看请求头 Cookie 是否存在,看 CORS 响应头
关闭浏览器后登录态丢失会话 Cookie 未设过期时间检查是否设置了 Max-Age
子域名之间登录态不共享Domain 未设置或设置为主域检查 Domain 值
退出登录后仍能访问删除时 Path/Domain 与原 Cookie 不一致保证删除指令的作用域完全一致
偶发登录态丢失多实例部署但会话存在单机内存检查会话存储是否共享
Cookie 值被截断值中含分号、逗号未编码统一 encodeURIComponent
跨站嵌入页面未登录SameSite=Lax 拦截改 None + Secure,或改交互方式

表格里“偶发登录态丢失”这一条值得单独说。我遇到过一次,用户反馈“十次里有两三次要重新登录”,本地完全复现不了。最后定位到是服务端部署了多个实例,会话存在各自的内存里,负载均衡把请求打到不同机器,命中率大概七成,所以表现为偶发。换成集中式会话存储后彻底解决。这类问题排查时容易往浏览器方向想,其实根子在服务端架构。

6.2 几个真实的排查过程

案例一:跨域登录,前端一直拿不到登录态。

现象是登录接口返回 200,但紧接着的用户信息接口 401。先看请求头,发现请求里压根没有 Cookie。检查前端代码,fetch没写credentials: 'include',补上。再看,还是没有。检查服务端 CORS,Access-Control-Allow-Origin写的是*,并且没有Access-Control-Allow-Credentials。改成具体域名加true后恢复正常。

这个案例的教训是:跨域带 Cookie 需要前后端同时配合,前端声明要带,服务端声明允许带,缺一不可。而且服务端的 Origin 不能是通配符,这是规范硬性要求,不是可选项。

案例二:本地正常,线上登录态两小时掉一次。

用户反馈很规律,正好两小时。查服务端配置,会话 TTL 设的 2 小时,Cookie 的 Max-Age 也是 2 小时,看起来一致。但问题在于,用户在两小时内持续操作,服务端每次请求会刷新会话 TTL,Cookie 的过期时间却不会自动延长(浏览器不会因为收到响应就延长 Cookie 寿命),两小时一到,Cookie 被删,即使服务端会话还活着也没用了。

解决办法是加续期逻辑:当 Cookie 剩余寿命低于某个阈值(比如剩 30 分钟)时,服务端重新下发一次 Cookie,把 Max-Age 重置。这个逻辑要放在中间件里统一处理,不要散在各接口。改完之后,只要用户持续使用就不会掉线。

案例三:移动端 WebView 里 Cookie 完全失效。

同一套代码,浏览器正常,App 内置 WebView 里登录不了。排查发现 WebView 默认的 Cookie 管理策略更严格,跨域和第三方 Cookie 的处理跟浏览器不一致,而且部分系统版本对 SameSite 的支持有差异。最后的方案是让客户端开启 Cookie 同步,并且把登录流程改成顶层跳转而不是 iframe 内嵌,规避 SameSite 限制。

这个案例说明一件事:Cookie 的行为依赖运行环境,桌面浏览器、移动浏览器、WebView、小程序容器,规则都不完全一样。做跨端项目时,重要流程一定要在真实容器里测,别只在桌面浏览器调试。

6.3 独家避坑技巧

最后分享几条文档里不会写、但实际很省事的经验。

第一条,把 Cookie 配置集中管理。不要在每个接口里手写res.cookie(...)的参数。抽一个统一的配置模块,把 path、maxAge、httpOnly、secure、sameSite 全部定义好,接口只传 key 和 value。这样改一次全局生效,也不会出现某个接口漏了 HttpOnly 的情况。我在项目里见过同一条会话 Cookie,登录接口设了 HttpOnly,刷新接口忘了设,结果刷新之后 HttpOnly 就丢了,等于白设。

第二条,环境差异用配置隔离,不要用注释。本地开发要关 Secure,线上要开,很多人靠手动注释切换,早晚出事。正确做法是读环境变量,配置里写secure: isProd,本地自动关、线上自动开,减少人为失误。

第三条,调试时先看存储面板,再看网络面板。顺序反了容易绕远路。存储面板告诉你“有没有存下来、属性对不对”,网络面板告诉你“有没有带上去”。先确认存下来了,再查为什么没带,逻辑就顺了。

第四条,给关键 Cookie 加前缀。有些浏览器支持__Host-和__Secure-前缀,加了之后浏览器会强制校验必须 Secure、必须 Path=/、不能设 Domain,等于多一层保险。这个特性兼容性已经不错,关键会话 Cookie 可以加上试试。

第五条,清理逻辑要和设置逻辑严格对称。设置时用了什么 Domain 和 Path,删除时一个字符都不能差。我习惯把这两个值抽成常量,设置和删除都引用同一个常量,从根上杜绝不一致。

我自己在这些年的项目里,因为 Cookie 出的问题,八成都能归到两类:作用域没对齐,和安全属性漏配。前者靠统一配置解决,后者靠代码评审和上线检查清单兜底。这两件事做扎实,剩下那些花哨的技巧其实用不上多少。

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

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

立即咨询