做Web开发这几年,Cookie大概是让我又爱又恨的一个东西。爱是因为它太强大了,登录状态、个性化设置、购物车、埋点追踪,哪一样背后都有它的影子;恨是因为它太容易出问题了,明明前端已经写了document.cookie = 'xxx',刷新一下服务端却收不到,查了半天才发现是 Path 没配对。标题里的四件事——Cookie的创建、获取、修改、存活时间,其实正好是绝大多数后端同学和前端同学每天都要面对的基础四件套。这篇文章不打算去抄MDN文档,而是想站在实际开发的角度,把这四件事讲透:它们各自在什么场景下用、底层是怎么流转的,以及我在真实项目里踩过的那些坑。
1. 从无状态到有状态:Cookie为什么能撑起登录体系
想象一下你在开发一个后台管理系统,用户输入账号密码,登录成功后跳到首页,然后在首页点了一个按钮发起请求,结果服务端一脸茫然:“你是谁?”这不是服务端故意刁难你,而是HTTP协议的天性——无状态。每一个HTTP请求都是独立的,服务端处理完当前请求后,不会自动记住“刚才那个请求来自谁”,就像一家生意很好的奶茶店,店员不会记得每个顾客刚才点过什么,只会在每一单上重新确认。
Cookie就是用来打破这种无状态的“身份牌”。
1.1 一次完整登录背后的Cookie流转
我直接用最常见的登录场景来说吧。当用户提交登录表单后,整个链路是这样的:
- 浏览器向服务端发起登录请求(比如
POST /login),携带账号密码。 - 服务端校验通过后,在响应头里设置一条
Set-Cookie: session_id=abc123; Path=/; HttpOnly,然后返回登录成功。 - 浏览器收到响应头里的
Set-Cookie,会把它按域名和路径保存到本地。 - 之后每一次向满足条件的域名/路径发起请求,浏览器都会自动在请求头里带上
Cookie: session_id=abc123。 - 服务端从请求头中取出session_id,去内存或Redis中查询对应的用户信息,于是就能识别“你是谁”了。
这里面最值得注意的一点是:Cookie不是前端代码一点一点传过去的,而是浏览器自动携带的。很多新手会误以为document.cookie设置完之后,需要手动读出来再放到请求里,其实完全不用。浏览器早就把这件事做完了,这也是Cookie和普通localStorage最本质的区别。
1.2 身份牌为什么要由服务端来发
既然Cookie能存信息,那能不能让前端自己写一个user=张三放进去,服务端直接读?技术上能,但不安全。因为前端代码完全可控,任何人都可以伪造user=张三。更合理的做法是服务端只下发一个随机、难猜的session_id,真正对应用户身份的数据留在服务端;前端保存的只是一个“令牌”,没有这个令牌,任何人都无法去服务端翻数据。
我个人的理解是,Cookie本身不重要,重要的是它背后对应的那一段会话状态。这也是为什么Cookie里通常不该存密码、手机号等敏感信息,而应该通过一个随机的标识符去服务端取数据。理解了这个前提,后面再看创建、获取这些操作,思路就会清晰很多:我们操作Cookie,本质上是在操作一张能打开服务端大门的钥匙,而不是在钥匙上写满个人简历。
2. 拆解Cookie的组成要素:同一颗Cookie的秘密都写在属性里
很多教程在讲Cookie时,喜欢直接给一行document.cookie = "key=value",然后告诉你这样就能用了。确实能建出来一颗Cookie,但这颗Cookie的很多行为是不可控的。比如默认情况下它只在当前浏览器会话有效,一关浏览器就没了;又比如它默认只作用于当前路径,你在/admin页面设置的Cookie,在/user页面可能压根拿不到。
要真正掌握Cookie的创建、获取、修改、存活时间,第一步是先认识它的全部属性。
2.1 Cookie的核心属性对照
我用表格把常用属性整理一下,这是我自己项目中一直贴在工位上的清单:
| 属性 | 作用 | 创建时容易踩的坑 |
|---|---|---|
| name=value | 存储的键值对 | value里有中文或特殊字符会出问题,需要URL编码 |
| Domain | 指定Cookie作用的域名 | 想跨子域时忘记设为顶级域,导致每个子域各存一份 |
| Path | 指定Cookie作用的路径 | 路径不一致会导致同名Cookie共存,修改失效 |
| Expires | 绝对过期时间(GMT格式) | 用了本地时区字符串,导致时间换算错乱 |
| Max-Age | 相对过期秒数 | 单位是秒,不是毫秒,前端常见错误 |
| Secure | 仅允许HTTPS传输 | 本地环境没配HTTPS时,设了Secure反而写不进去 |
| HttpOnly | 禁止JavaScript读取 | 前端想通过document.cookie拿数据会拿不到 |
| SameSite | 控制跨站请求是否携带Cookie | 第三方登录回调场景里,设置不当会丢Cookie |
2.2 属性之间是怎么相互约束的
Domain和Path这两个属性共同决定了“哪些请求会自动带上这颗Cookie”。浏览器在发送请求前,会拿请求的域名和路径去跟这颗Cookie的Domain和Path做匹配,完全匹配才带上。Domain匹配规则是“当前域名等于Cookie的Domain,或者是它的子域名”;Path匹配规则则是“请求路径必须以Cookie的Path开头”。
举个例子:如果服务端下发了一条Cookie: token=abc; Domain=example.com; Path=/user,那么浏览器在请求https://example.com/user/profile时会带上它,而在请求https://example.com/admin时不会带。这很合理,等于给Cookie划定了活动范围。
我最初做多系统对接时,在这个点上栽过跟头。当时两个子域分别放了Cookie,导致用户从app.example.com跳到home.example.com后,登录状态就丢了。后来在服务端统一把Domain设置为example.com,Path设置为/,才解决了跨子域共享的问题。这里值得多说一句:Domain设置得越广,Cookie被发送的范围就越大,安全暴露面也越大,所以不要为了省事把所有Cookie都铺满顶级域,还是得按业务边界来。
3. 创建Cookie:服务端Set-Cookie与前端document.cookie的配合
创建Cookie是四个操作里最基础、也最容易写错的一环。我见过不少项目,前后端各写一套,前端用document.cookie存用户信息,后端也用Set-Cookie存session,结果两条Cookie的Domain、Path不一致,互相干扰。所以这一节我会把创建这件事拆清楚。
3.1 服务端创建:最正统的方式
服务端创建Cookie就是通过HTTP响应头中的Set-Cookie字段。下面给出三种最常见的后端写法,业务上可以直接参考:
Java Servlet 的写法:
Cookie tokenCookie = new Cookie("token", "value123"); tokenCookie.setPath("/"); tokenCookie.setMaxAge(60 * 60 * 24 * 7); // 7天 tokenCookie.setHttpOnly(true); tokenCookie.setSecure(true); response.addCookie(tokenCookie);Python Flask 的写法:
resp = make_response("登录成功") resp.set_cookie( "token", "value123", path="/", max_age=7 * 24 * 3600, httponly=True, secure=True ) return respNode.js Express 的写法:
res.cookie('token', 'value123', { path: '/', maxAge: 7 * 24 * 60 * 60 * 1000, // 注意这里是毫秒 httpOnly: true, secure: true });这三种写法的共性很明显:都要指定Path,都要设定过期时间,生产环境都要开HttpOnly和Secure。特别提醒,Express的maxAge单位是毫秒,而Set-Cookie头的Max-Age单位是秒,这是一个极其容易让人懵圈的地方。我把同一天内这三种写法的代码放在一起看,常常能发现同一套语义在不同框架里的单位差异,提前记住能省下不少调试时间。
3.2 前端创建:适合存非敏感信息
前端创建用的是document.cookie,它是读和写共用一个接口的。写的时候不像是普通赋值那样直接覆盖整个Cookie,而是在字符串后面追加一个键值对:
document.cookie = "theme=dark; path=/; max-age=${7 * 24 * 60 * 60}";浏览器在解析这行代码时,会把theme=dark作为一个新的Cookie插入到与当前页面匹配的作用域里。如果同作用域下已经存在同名的theme,则覆盖;如果不存在,则新增。也正因为这个机制,前端创建通常只适合存一些非敏感信息,比如主题、语言、横幅是否关闭等。真正的登录态令牌,一定要交给服务端通过Set-Cookie下发,并且加HttpOnly。
3.3 测试工具里创建Cookie:用Jmeter时常用
接口联调时经常需要在工具里维护Cookie。Jmeter里我习惯用一个HTTP Cookie管理器组件,介于测试脚本和后端之间。它有几种用法:一是手动添加一组name/value;二是直接从登录接口的响应头里自动提取Set-Cookie,后续请求自动携带。这跟浏览器行为是能对应上的,思路理解之后,测起来就不会觉得玄。用工具测Cookie时,我建议每次跑完脚本都清空一下Cookie缓存,避免上一个用例的Cookie污染下一个用例的请求,尤其是登录态Cookie存在的情况下,这种污染很容易让你误以为代码有bug。
4. 获取Cookie:不同的读取路径决定了你能拿到什么
“获取”这个词在不同语境下含义差异很大。前端说的获取,是指从document.cookie里把某个键的值取出来;后端说的获取,是指从HTTP请求头中解析Cookie字段;而调试场景下的获取,则通常是在DevTools里直接观察一颗Cookie的完整属性。理解这些路径的区别,才能在排查问题时快速定位。
4.1 前端获取:document.cookie是一长串字符串
对浏览器来说,document.cookie返回的是所有能被JavaScript访问且匹配当前文档路径的Cookie,格式是key1=value1; key2=value2。这是一个字符串,本身没有提供类似getCookie('name')这样的方法,所以前端一般都会自己写解析函数。我常用的一个版本是这样:
function getCookie(name) { const match = document.cookie.match(new RegExp('(^|; )' + encodeURIComponent(name) + '=([^;]*)')); return match ? decodeURIComponent(match[2]) : null; }注意两点:第一,name需要做一次编码,否则Cookie值里的特殊字符会把整条字符串搞乱;第二,该方法拿不到带HttpOnly标记的Cookie。后一点经常让前端同事困惑——“我明明看到浏览器里有这颗Cookie,为什么document.cookie取不到?”答案就是HttpOnly,它是专门用来挡JavaScript读取的,目的就是降低浏览器脚本盗取Cookie的风险。读不到其实才是安全设计生效了。
4.2 后端获取:从请求头里找Cookie
后端获取Cookie就简单很多,因为浏览器已经把匹配的Cookie拼在请求头里了,服务端只要解析Cookie请求头即可。以三种语言为例:
Java:
Cookie[] cookies = request.getCookies(); for (Cookie c : cookies) { if ("token".equals(c.getName())) { String token = c.getValue(); System.out.println(token); } }Flask:
token = request.cookies.get('token')Express(需要cookie-parser中间件):
const token = req.cookies.token;注意,Cookie会自动携带在同一个域名的所有匹配请求里,包括静态资源请求、图片请求。所以如果后端接口日志里打出了请求头,看到一堆Cookie是很正常的事,不代表前端代码在刻意发送。需要警惕的反而是反面情况:如果请求头里没有带上预期的Cookie,通常就是作用域不匹配或者Cookie已经过期,这时候不要先去查业务代码,先看这几点。
4.3 浏览器调试面板:最直接的“黑盒观测”
Chrome DevTools的Application面板里,左侧选择Cookies,再选当前站点,就能看到每颗Cookie的Name、Value、Domain、Path、Expires/Max-Age、HttpOnly、Secure、SameSite等全部属性。这里面每一列都可以双击修改,是排查Cookie问题时最方便的入口。Network面板也能直观看到每个请求的请求头Cookie和响应头Set-Cookie,适合观察Cookie在整个请求链路中的流转。我排查问题时通常先在Application面板里确认“有没有这颗Cookie、属性对不对”,再去Network面板确认“请求到底带没带”,两步配合下来,大部分问题都能锁定到具体环节。
5. 修改Cookie:同名覆盖背后的匹配规则
修改Cookie这个概念,很多文章讲得很简单:“重新写一遍同名Cookie就行。”但如果你把Path或Domain理解错了,会发现改了之后旧值还在,新值也出来了,服务端读到的永远是旧值,非常头疼。
5.1 所谓修改,实际是“在同一个作用域里覆盖同名”
浏览器内部并不会去“找旧Cookie然后改value”,而是先把新的键值对插进去,然后用匹配规则去检查作用域。如果新Cookie的Domain、Path、Secure属性等与已有“同名Cookie”完全一致,则覆盖它的Value和过期时间;如果作用域不完全一致,它就会被当作一颗新的Cookie,和旧Cookie共存。
所以我总结了修改Cookie时必须保持一致的几个条件:名字一致、Domain一致、Path一致、Secure一致。前端修改一个Cookie的最小代码是这样:
document.cookie = "token=newValue; path=/; max-age=86400";但这段代码只有在旧Cookie的Path也恰好是/时才会真正生效。如果旧Cookie是在/admin路径下创建的,而当前页面是首页,这行代码不但改不掉,还会在/下面新建一颗同名Cookie。后果就是首页请求带新值,admin页面请求带旧值。这种问题最难排查,因为看起来代码没问题,功能却“时好时坏”,实际上只是作用域在暗中捣乱。
5.2 服务端修改与HttpOnly的边界
服务端修改Cookie的方式同样是重新下发一个同名Set-Cookie,基本等同于“重新创建”,但这里有一个被很多人忽略的点:HttpOnly并不是Cookie的“不可变锁死属性”,而只是浏览器执行读写时的约束。如果服务端重新下发了一颗同名、同Path、同样作用域的Cookie,但这次没有带HttpOnly,那么这颗Cookie就会失去HttpOnly保护,变成JavaScript可读的。反过来,如果你用document.cookie去覆盖一颗原本HttpOnly的Cookie,因为浏览器根本不允许JS访问HttpOnly的键,所以覆盖是无效的,你只是在同域下新增了一颗非HttpOnly的同名Cookie而已,服务端拿到的仍然可能是旧值。
所以关于“修改”,我建议按照场景分流:如果要修改的Cookie带HttpOnly,基本只能走服务端下发路径;如果不带,前端和后端都可以改,但务必先确认旧Cookie的Path和Domain。尤其不要在服务端修改Cookie时图省事只改Value不重新设置Path,一旦新Cookie的Path跟旧Cookie的Path不一致,就会造成“修改后多出一个Cookie”的现象。
5.3 修改后的验证方式
改完Cookie一定要立刻验证,别等用户反馈。最简单的方法是在DevTools的Application面板里看一眼那颗Cookie的过期时间是否变化;或者打开Network面板,找到刚才触发的请求,检查请求头里Cookie字段的实际值。如果发现新旧值都在,或者旧值仍然被带上,马上回查作用域匹配问题。把问题圈定在属性不匹配上,比瞎试快得多。我自己每次改完,都会刻意做一个“重放请求”的操作:清掉浏览器里所有同名Cookie,再重新走一遍登录流程,确认服务端重新下发的Cookie是预期值。这个步骤看起来很笨,但能提前暴露很多覆盖失效的场景。
6. 存活时间:会话Cookie与持久Cookie的管理策略
存活时间这四个字,是Cookie体系里最容易出“看起来奇怪”问题的地方。我遇到过用户反馈:“为什么我关掉浏览器再打开,登录就掉了?”也有“为什么我没设置过期时间,Cookie却一直存在?”这些现象其实都和Cookie存活时间的管理机制有关。
6.1 会话Cookie与持久Cookie的分界线
判断一颗Cookie是会话级还是持久级,标准只有一个:创建时有没有设置Expires或Max-Age。
- 如果两个属性都没设置,它就是会话Cookie,理论上浏览器关闭后就会删除。注意“关闭浏览器”这个行为在真实世界里并不总那么靠谱,现代浏览器常会有会话恢复功能,重开浏览器后会把之前的会话Cookie一起恢复,所以很多用户会感觉“我明明没设过期时间,Cookie却一直在”。
- 如果设置了
Expires或Max-Age,它就是持久Cookie,到期后自动删除。Expires是绝对时间点(GMT格式),Max-Age是相对秒数,两者同时存在时浏览器会优先生效Max-Age。
给两个示例感受一下区别:
// 会话Cookie Set-Cookie: session_id=abc123; Path=/ // 持久Cookie,7天后过期 Set-Cookie: session_id=abc123; Path=/; Max-Age=6048006.2 过期时间的计算与常见的“提前消失”
持久Cookie的过期时间,是由浏览器本地时间对照属性里的时间点来判定的,这就引出了几个很现实的坑。如果用户电脑系统时间被改乱,Cookie可能立刻过期或变长;如果你把时间格式写错,比如把本地时区的字符串当GMT字符串下发,不同时区的用户表现就不一样。所以正规做法是服务端统一生成正确时区的GMT时间,或者直接用相对秒数的Max-Age,让浏览器自身处理时间换算,能省去一大部分时区纠纷。
另外还有一个经常被忽略的点:Cookie总数量和总大小是有限制的。浏览器对每个域名通常只会保留20到50个Cookie,单个Cookie大小上限一般是4KB。如果你的业务大量使用Cookie,到了上限后,老的Cookie可能会被静默清除。这种失效方式跟“到期”无关,排查时格外隐蔽。我遇到过的情况是,业务方在Cookie里塞了一串很长的业务参数,结果导致同一域下其他业务线的Cookie被浏览器自动挤掉了,最后只能把大对象迁移到服务端存储,Cookie里只留ID。
6.3 登录场景下的滑动续期
登录类系统里,我们通常不希望用户7天后还能继续登录,而是希望“活跃用户一直不用登,沉寂用户逐渐被清理”。这种需求靠固定过期时间是做不到的。更常见的做法是滑动续期:用户每次请求时,服务端重新下发一个相同作用域的新Cookie,把过期时间往后顺延。比如后端用Max-Age=1800秒,用户每30分钟内有操作,就续到30分钟后;如果他连续30分钟没操作,Cookie自然过期,再访问就需要重新登录。
这里要提醒一点:重新下发Cookie时,Domain、Path、Secure、HttpOnly几个属性必须与旧Cookie保持一致,否则会产生一颗新Cookie,老的继续存在,最后可能造成“新旧Cookie交替携带”的诡异现象。我见过好几个项目因为这个看起来像随机掉线的bug。排查这类问题有个小技巧:在Network面板里连续观察两次请求的Set-Cookie响应头,看两次下发的Domain和Path是否一致,只要不一致,问题几乎就锁定在属性漂移上了。
7. 实操中踩过的Cookie坑:编码、限制、作用域与调试技巧
最后这部分是我最想写的,因为大部分Cookie问题,文档里不会说,只有真正上线被用户骂过才知道。我按踩坑频率从高到低整理了几个方向。
7.1 中文和特殊字符:Cookie不是你想存就能存
Cookie的value在传输层看本质上是一个字符串,但它有字符限制,直接写中文很容易出现乱码或解析异常。标准的做法是写入时用encodeURIComponent编码,读取时再用decodeURIComponent解码。前后端都要配套处理,不然就会出现“前端写入正常,后端读出来一团乱”的情况。
// 写入 document.cookie = "nickname=" + encodeURIComponent("张三") + "; path=/"; // 读取 const raw = getCookie('nickname'); const realValue = raw ? decodeURIComponent(raw) : null;这里我还想补充一个容易踩的细节:encodeURIComponent之后,分号、逗号、空格这些Cookie非法字符会被转成百分号编码,但加号会被编码成%20还是+是有历史差异的。为了避免歧义,我习惯在解码时统一按标准方式处理,不要依赖底层框架帮你做什么额外解码,前后端各管好自己那一层最稳妥。
7.2 数量与大小的硬限制
前面提过,单Cookie上限约4KB、单域名数量约20到50个,不同浏览器有细微差异。这决定了一个设计原则:Cookie只用来存标识和短小配置,不存业务对象、不存列表数据。如果项目里已经出现“Cookie写多了导致登录态被挤出”的情况,就需要尽快把大对象迁移到服务端存储,Cookie里只留一个ID。这个原则看起来简单,但很多项目是在上线后流量起来时才意识到,与其到时候紧急重构,不如一开始就把Cookie当“门禁卡”用。
7.3 作用域不一致导致的“改不动”和“取不到”
同名的Cookie可以对应多个Path,因为作用域不同。比如token在/和/admin下同时存在,浏览器请求/admin下的页面时,默认带的是哪一条,取决于路径匹配优先级,而这在不同浏览器里甚至可能有细微差异,非常容易让人困惑。我的建议是:永远不要让同名Cookie有两个不同的Path。创建时统一用Path=/,如果要区分业务,改成不同名字,而不是不同路径。
7.4 生产环境一定要Review的三个属性
我上线前会固定检查三样东西:有没有设置HttpOnly,有没有设置Secure,有没有根据业务设置SameSite。HttpOnly防止脚本盗读,Secure防止通过HTTP明文传输时被中间链路截获,SameSite控制Cookie是否会随跨站请求发送。尤其当项目涉及第三方登录或支付回调时,SameSite设置错误会让Cookie在跳转过程中神秘丢失,排查起来极其费劲。我自己的经验是:如果没有特殊需求,SameSite优先设成Lax,既保留常规导航下的Cookie携带能力,又能挡住大部分跨站隐私风险。
7.5 调试工具三板斧
最后分享我自己调试Cookie的三板斧。
第一条,DevTools的Application面板,适合静态检查某颗Cookie的完整属性,双击可以手动改值、改过期时间,是我最常用的手段。
第二条,Network面板,适合动态观察一次请求完整带上了哪些Cookie,以及响应头里是否返回了新的Set-Cookie。有时候问题不在“写不进去”,而在“写入的Cookie没有随请求发出来”。
第三条,命令行curl -v,适合在完全没有浏览器干预的情况下,模拟一个客户端看服务端到底下发了什么头。比如怀疑网关或代理篡改了Set-Cookie时,用curl能还原最原始的服务端行为。
curl -v -X POST https://api.example.com/login \ -H "Content-Type: application/json" \ -d '{"username":"test","password":"123456"}'看输出里Set-Cookie那一段,就知道服务端到底有没有正确下发Cookie了。
说实话,Cookie这块内容看起来简单,我写的过程中脑子里已经过了好几个现场事故的片段。现在我自己的习惯是,凡是新增或修改Cookie逻辑,一定先在DevTools里把旧Cookie的Name、Path、Domain、HttpOnly这四个值截个图,再动手改,改完再对照截图确认是不是真的覆盖掉了。这个习惯看起来笨,但已经帮我避免了好几次线上回归。希望这篇整理也能让你少踩几个雷。