Persistent Cookie 实现原理与前端持久化登录实践

一莹 Dev 安全 阅读 2,532
赞 19 收藏
二维码
手机扫码查看
反馈

谁更灵活?谁更省事?Persistent Cookie 的三种实战方案对比

我最近在重构一个老项目的身份认证模块,后端坚持用 Session + Persistent Cookie 做登录态维持,前端得配合做「自动续期」和「跨页面感知」。本来以为就 setCookie 一行的事,结果踩了一堆坑:Safari 拒绝第三方上下文写入、Chrome 89+ 的 SameSite=Lax 默认行为让登录后跳转直接丢 session、iOS 微信 WebView 里 document.cookie 根本读不到 Secure Cookie……折腾了两天,干脆把几种主流 Persistent Cookie 方案拉出来,真实项目里跑一遍,说说我的血泪体会。

Persistent Cookie 实现原理与前端持久化登录实践

结论先甩这儿:我目前主力用的是「服务端 Set-Cookie + 前端不手动操作 cookie」这一套,配合 fetch 的 credentials: ‘include’ 和响应头里的 Max-Age/Expires 控制生命周期。不是最炫的,但最稳,也最省心。

方案一:纯前端 JS 手动 setCookie(别碰)

就是用 document.cookie = "token=abc123; expires=Fri, 31 Dec 9999 23:59:59 GMT; path=/; domain=.jztheme.com; secure; HttpOnly=false" 这种方式。我早年很喜欢,因为“全在自己手里”,想啥时候设就啥时候设,想改啥属性就改啥属性。

但真用到生产环境,问题一堆:

  • Safari 16.4+ 在无用户交互(比如 onload 里)直接 setCookie,会被静默忽略——你根本不知道它没生效;
  • HttpOnly=true 的 cookie,JS 根本读不到,你连“校验是否写成功”都做不到;
  • domain 写错一个点(比如写成 domain=jztheme.com 而不是 domain=.jztheme.com),子域就全挂了;
  • Secure 属性在本地开发(http://localhost)下直接失效,测试环境和线上行为不一致,排查时怀疑人生。

代码看着简单,实际维护成本高,而且根本不可靠。我现在看到项目里还有这种写法,第一反应就是删掉重写。

方案二:服务端 Set-Cookie + 前端被动接收(我主力用的)

登录接口返回 200,响应头带:

Set-Cookie: auth_token=xyz789; Path=/; Domain=.jztheme.com; Max-Age=2592000; Secure; HttpOnly; SameSite=Lax

前端只管调用登录 API,然后后续所有请求带上 credentials: 'include'

// 登录
fetch('https://jztheme.com/api/login', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email, password })
});

// 后续请求自动带上 auth_token
fetch('https://jztheme.com/api/profile', {
  credentials: 'include'
});

这个方案的优点太实在了:

  • HttpOnly 安全性拉满,XSS 基本没法偷 token;
  • Max-Age 是服务端控制的,前端不用管过期逻辑,也不用自己算时间戳;
  • SameSite 配置由后端统一管理,前端不用操心 CSRF 风险;
  • 浏览器原生支持,兼容性从 IE11 到 Chrome 120 全覆盖(只要 header 对得上)。

唯一要注意的坑是:第一次登录后跳转页面,必须确保新页面的请求也带 credentials: ‘include’,否则 cookie 不会发出去。我之前在一个 Vue Router 导航守卫里漏写了,结果用户登录完进首页,发现还是未登录状态,查了半小时才发现是 fetch 没配 credentials。

方案三:localStorage + 定期刷新 Cookie(折中但有隐患)

有些团队为了“前端完全可控”,搞了个混合方案:登录成功后,把 token 存 localStorage,再用这个 token 主动向服务端发个 /refresh 接口,换回一个新的 Persistent Cookie。

// 登录成功后
localStorage.setItem('auth_token', response.token);

// 立即刷新 cookie
fetch('https://jztheme.com/api/refresh-cookie', {
  method: 'POST',
  headers: { Authorization: Bearer ${response.token} }
});

好处是:token 可以自定义过期策略,比如“30 分钟无操作就清空”,还能配合 localStorage 的事件监听做多标签页同步。

但问题也很明显:

  • 多了一次网络请求,首屏体验变差;
  • 如果 /refresh 接口失败(网络抖动、服务异常),用户登录成功了却拿不到 cookie,后续请求全 401;
  • localStorage 本身可被 XSS 读取,安全性比 HttpOnly 差一截;
  • 如果用户手动清空 localStorage,cookie 还在,但前端没记录,就会出现“登出失败”或“状态错乱”。

我在上一家公司用过这个方案,上线后监控发现约 1.3% 的登录请求会出现“已登录但无法访问个人页”的报错,最后定位就是 /refresh 接口超时导致 cookie 没刷上。后来我们砍掉了这个环节,回归纯服务端 Set-Cookie。

我的选型逻辑

看场景,我一般选方案二(服务端 Set-Cookie)。除非遇到两个硬需求:

  • 需要精确控制 token 过期时间(比如按分钟级刷新)——那就上方案三,但得加兜底:检查 cookie 是否存在,不存在就重走登录;
  • 整个系统是纯静态托管(比如 GitHub Pages 或 S3),没有服务端接口——那只能退回到方案一,但必须加一层检测:document.cookie.indexOf('auth_token') !== -1,并做好降级提示。

至于方案一?现在除非是 demo 或内部工具,我基本不碰。它看起来自由,实则脆弱,而且越用越累——每次 Safari 更新都要重新测一遍 cookie 行为,不值得。

另外提醒一句:不要迷信“持久化”这个词。Persistent Cookie 本质只是 Max-Age 设得长一点,但用户清浏览器缓存、切换设备、换浏览器,照样失效。真正要做的,是把「登录态」和「用户意图」解耦:登录成功就存凭证,退出就删凭证,别指望 cookie 自己“永远在线”。

以上是我踩坑后的总结,希望对你有帮助。有不同看法欢迎评论区交流——比如你用方案三怎么解决 /refresh 失败的问题?或者你见过哪些更诡异的 cookie 行为?我很好奇。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论