前端知库

Cookie Store API 应该怎样安全地管理浏览器 Cookie?

Cookie Store API 用异步方法读取、写入和监听 Cookie,适合封装主题、语言和会话辅助状态,但不能绕过 HttpOnly、SameSite、Secure 或服务端权限校验。

参考答案

为什么需要新的 Cookie 访问方式

document.cookie 看起来只是一个字符串,但每次读取都要把当前作用域可见的 Cookie 拼接出来。Cookie 较多、页面频繁读取,或者代码在滚动和输入事件中反复访问它时,同步调用可能阻塞主线程。它还不方便监听其他代码对 Cookie 的修改,组件往往只能定时读取。

Cookie Store API 提供 Promise 形式的 get、set、delete,并支持 change 事件。它改善的是访问方式和代码组织,不会改变 Cookie 的安全模型,也不会让前端获得读取 HttpOnly Cookie 的权限。

先封装,再让业务使用

不要让每个组件自己拼接 Cookie 字符串。应该集中定义名称、路径和生命周期,并为不支持新 API 的浏览器保留降级:

JavaScript
const themeCookie = {
  name: 'theme',
  path: '/',
  sameSite: 'lax',
  maxAge: 60 * 60 * 24 * 365,
};

function readLegacyCookie(name) {
  const prefix = `${encodeURIComponent(name)}=`;
  const item = document.cookie
    .split('; ')
    .find((part) => part.startsWith(prefix));
  return item ? decodeURIComponent(item.slice(prefix.length)) : null;
}

export async function getTheme() {
  if (globalThis.cookieStore) {
    const cookie = await cookieStore.get(themeCookie.name);
    return cookie?.value ?? 'system';
  }
  return readLegacyCookie(themeCookie.name) ?? 'system';
}

export async function setTheme(theme) {
  const value = theme === 'light' || theme === 'dark' ? theme : 'system';
  if (globalThis.cookieStore) {
    await cookieStore.set({ ...themeCookie, value });
    return;
  }
  document.cookie = `${encodeURIComponent(themeCookie.name)}=${encodeURIComponent(value)}; Path=/; Max-Age=${themeCookie.maxAge}; SameSite=Lax`;
}

封装层负责编码、默认值和降级,组件只关心 getTheme() 和 setTheme()。这样以后更换存储方式、调整过期时间或修复 Cookie 属性时,不需要搜索整个项目里的字符串拼接代码。

Cookie 的作用域决定谁能看到它

Cookie 是否可读,取决于名称、域名、路径、协议和浏览器的存储策略。设置在 /account 路径下的 Cookie,在 / 页面不一定可见;使用不同 Domain 或子域名也会影响发送范围。删除时必须使用与原 Cookie 匹配的 name、path 和 domain,否则你以为删除成功,旧 Cookie 可能仍然存在。

同名 Cookie 在不同路径下可以同时存在。服务端收到请求时可能看到多个同名值,前端只删除其中一个会导致登录状态或配置表现不一致。修改 Cookie 规则时,应先盘点线上已有的路径和域名,再设计迁移策略。

身份认证不能交给可读 Cookie

登录会话通常应该由服务端设置 HttpOnly、Secure 和合适的 SameSite Cookie。HttpOnly 可以阻止 JavaScript 读取会话值,即使使用 Cookie Store API 也一样。这样能降低 XSS 直接窃取会话令牌的风险。

前端可以读取一个非敏感的 session_hint 来决定是否提前展示加载状态,但最终身份必须由服务端根据会话 Cookie 判断。不能因为前端读不到 HttpOnly Cookie 就把令牌复制到 localStorage 或普通 Cookie 中。CSRF 防护、权限校验、会话失效和刷新策略仍然是服务端责任。

监听变化时要区分自己和其他上下文

JavaScript
function watchTheme(onChange) {
  if (!globalThis.cookieStore) return () => {};

  const handler = (event) => {
    const changed = event.changed.find((cookie) => cookie.name === 'theme');
    const deleted = event.deleted.some((cookie) => cookie.name === 'theme');
    if (changed) onChange(changed.value);
    if (deleted) onChange('system');
  };

  cookieStore.addEventListener('change', handler);
  return () => cookieStore.removeEventListener('change', handler);
}

组件挂载时注册监听,卸载时必须移除,否则页面长时间运行后会累积回调。自己调用 setTheme 后不要等待 change 事件才更新当前组件,应立即更新本地状态;监听主要用于响应其他代码或其他上下文的变化。

监听事件也不是跨站同步协议。它不能监听别的域名,也不能保证所有嵌入场景下的存储分区都相同。需要跨标签页传递复杂状态时,应使用 BroadcastChannel 或重新读取共享数据,而不是把 Cookie change 当成消息队列。

服务端渲染和缓存会改变设计

如果服务端根据 Cookie 输出主题、语言或用户信息,客户端监听 Cookie 变化不会自动改变已经生成的 HTML。变化后需要重新请求数据或刷新相关区域。包含用户身份的响应还要正确设置缓存策略,避免 CDN 把一个用户的内容提供给另一个用户。

主题和语言这类非敏感偏好可以在首屏早期读取,以减少闪烁;登录状态则应让服务端决定,客户端只负责展示加载中、已登录或未登录等状态。不要为了让客户端更方便而暴露认证凭据。

异步访问仍然需要处理并发

两个请求同时刷新会话时,不能让它们各自写入不同 Cookie,再由最后完成的请求覆盖前一个结果。请求层应合并刷新任务,并在登出后拒绝旧请求写回状态。cookieStore.set() 成功只表示浏览器接受了设置,不代表服务器已经处理了业务,也不代表下一次请求一定已经完成。

测试时需要覆盖新 API 不存在、HTTPS 与 HTTP、过期和删除、不同 Path、HttpOnly 不可读、多个请求并发刷新以及页面重新打开等情况。Cookie Store 是更好的访问接口,但它不会替你解决认证协议、并发控制和安全问题。