干这行时间长了,总被刚入行的朋友问起同一件事:为什么本地测得好好的登录功能,一上线就“掉登录”?为什么清一下浏览器缓存就能解决一半的诡异问题?答案八成和 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 三者能力对比表
这三个东西经常被混着用,先把差异摆清楚。
| 维度 | Cookie | LocalStorage | SessionStorage |
|---|---|---|---|
| 容量 | 约 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 |
| 登录成功,后续接口 401 | Path 不匹配、跨域没带凭证、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 出的问题,八成都能归到两类:作用域没对齐,和安全属性漏配。前者靠统一配置解决,后者靠代码评审和上线检查清单兜底。这两件事做扎实,剩下那些花哨的技巧其实用不上多少。