前端安全存储方案

中等 🟡前端安全
21 个标签
预计阅读时间:60 分钟
前端安全安全存储localStorageSessionStorage加密CookieWeb Crypto APIHttpOnly CookieBFFOAuth2OIDCPKCErefresh tokenAES-GCMIndexedDBCryptoKeyCHIPSService WorkerGDPRHMACArgon2

前端安全存储方案

前端应用总要在浏览器里存点东西:用户偏好、主题设置、购物车、登录凭证……问题是,浏览器本质上是一个"公共场所"——用户能打开开发者工具随便翻看,任何在页面上运行的脚本(包括被 XSS 注入的恶意脚本)也能读取这些数据。所以"在前端安全地存储数据",首先要想清楚一个残酷的现实:前端没有真正的秘密。凡是能被浏览器读到的,理论上都能被攻击者读到。

打个比方:localStorage 就像贴在办公室公告栏上的便利贴,谁路过都能看;HttpOnly Cookie 则像放进一个只有前台(服务器)能开、你自己都看不见内容的保险箱——你只能"出示"它,却读不出里面写了什么。安全存储的艺术,就是把不同敏感度的数据放进合适的"容器",并对确实敏感的数据加密。

存储方案比较

localStorage:

持久存储,除非手动清除否则一直在
容量约 5MB,同源共享
同步 API,读写会阻塞主线程
JavaScript 可任意读写 → 极易被 XSS 窃取

sessionStorage:

仅在当前标签页会话期间有效,关闭标签即清除
容量约 5MB,同源且不跨标签页共享
同步 API
同样可被 XSS 读取,但生命周期更短

IndexedDB:

面向大数据量的结构化存储,容量可达数百 MB 甚至更多
异步 API,不阻塞主线程
适合离线缓存、大对象、复杂查询
仍是 JS 可读,需自行加密敏感字段

Cookie:

容量小(约 4KB),每次请求自动携带(增加流量)
可设 `HttpOnly`(JS 读不到)、`Secure`(仅 HTTPS)、`SameSite`(防 CSRF)
配置得当的 HttpOnly Cookie 是前端存放会话凭证的最佳选择

为什么存储安全重要

一次成功的 XSS 攻击,往往第一步就是"扫荡"localStorage:把里面的 JWT、access token 全部打包发到攻击者服务器。如果你把登录令牌存在 localStorage,等于把家门钥匙贴在公告栏上——一旦页面有 XSS,账户即刻失守。反之,把令牌放进 HttpOnly Cookie,即使页面被注入脚本,脚本也读不到令牌内容,攻击成本大大提高。所以"存哪里"这个看似琐碎的决定,直接决定了 XSS 的破坏上限。

安全存储策略

按敏感度分类存放:

| 数据类型 | 举例 | 推荐存储位置 |

| --- | --- | --- |

| 高敏感凭证 | 会话令牌、refresh token | HttpOnly + Secure Cookie |

| 敏感业务数据 | 部分个人信息 | 加密后存 IndexedDB,或不落地 |

| 会话态临时数据 | 表单草稿、当前步骤 | sessionStorage |

| 非敏感偏好 | 主题、语言、布局 | localStorage |

| 大体量离线数据 | 离线文档、图片缓存 | IndexedDB |

核心原则:

真正敏感的数据尽量不在前端持久化;必须存则加密存储
令牌类优先 HttpOnly Cookie,别放 localStorage
用户登出、会话结束时主动清理敏感数据
对读出的数据做完整性校验,防篡改

代码示例

示例 1:安全的存储封装(带过期时间的 localStorage)

javascriptCode
const safeStorage = {
  set(key, value, ttlMs) {
    const record = {
      value,
      expireAt: ttlMs ? Date.now() + ttlMs : null,
    };
    localStorage.setItem(key, JSON.stringify(record));
  },
  get(key) {
    const raw = localStorage.getItem(key);
    if (!raw) return null;
    try {
      const record = JSON.parse(raw);
      if (record.expireAt && Date.now() > record.expireAt) {
        localStorage.removeItem(key); // 过期自动清理
        return null;
      }
      return record.value;
    } catch {
      return null; // 数据被篡改/损坏时安全降级
    }
  },
  remove(key) {
    localStorage.removeItem(key);
  },
};

safeStorage.set('theme', 'dark', 7 * 24 * 60 * 60 * 1000); // 存 7 天

示例 2:用 Web Crypto API 做 AES-GCM 加密存储

javascriptCode
// 从口令派生密钥(PBKDF2),再用 AES-GCM 加解密
async function deriveKey(passphrase, salt) {
  const enc = new TextEncoder();
  const baseKey = await crypto.subtle.importKey(
    'raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveKey']
  );
  return crypto.subtle.deriveKey(
    { name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
    baseKey,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

async function encryptAndStore(key, plainText) {
  const enc = new TextEncoder();
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const cryptoKey = await deriveKey('user-passphrase', salt);
  const cipher = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, cryptoKey, enc.encode(plainText)
  );
  // 把 salt + iv + 密文一起存,解密时需要
  const payload = {
    salt: Array.from(salt),
    iv: Array.from(iv),
    data: Array.from(new Uint8Array(cipher)),
  };
  localStorage.setItem(key, JSON.stringify(payload));
}

async function loadAndDecrypt(key) {
  const raw = localStorage.getItem(key);
  if (!raw) return null;
  const { salt, iv, data } = JSON.parse(raw);
  const cryptoKey = await deriveKey('user-passphrase', new Uint8Array(salt));
  const plain = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: new Uint8Array(iv) },
    cryptoKey,
    new Uint8Array(data)
  );
  return new TextDecoder().decode(plain);
}

示例 3:服务端下发 HttpOnly 会话 Cookie(前端读不到,最安全)

javascriptCode
// Express:令牌存 HttpOnly Cookie,杜绝 JS 读取
res.cookie('sessionId', sessionId, {
  httpOnly: true,   // JS 无法访问,XSS 也偷不走
  secure: true,     // 仅 HTTPS
  sameSite: 'strict', // 抵御 CSRF
  maxAge: 3600000,  // 1 小时
  path: '/',
});

示例 4:登出时彻底清理前端敏感数据

javascriptCode
function secureLogout() {
  // 清空 storage
  localStorage.clear();
  sessionStorage.clear();
  // 通知后端清除 HttpOnly Cookie(前端无法直接删)
  fetch('/api/logout', { method: 'POST', credentials: 'include' });
  // 清理内存中的敏感引用
  window.__USER__ = null;
}

示例 5:用 idb 封装 IndexedDB 存储大对象

javascriptCode
import { openDB } from 'idb';

const dbPromise = openDB('app-cache', 1, {
  upgrade(db) {
    db.createObjectStore('documents', { keyPath: 'id' });
  },
});

export async function saveDoc(doc) {
  const db = await dbPromise;
  await db.put('documents', doc); // 敏感字段应先加密再存
}

export async function getDoc(id) {
  const db = await dbPromise;
  return (await dbPromise) && db.get('documents', id);
}

安全风险与防护

XSS 窃取 → localStorage 的头号威胁: 任何注入脚本都能读走 localStorage。防护:严格 XSS 防御 + 敏感数据不放 localStorage + 令牌用 HttpOnly Cookie。

CSRF → Cookie 相关风险: Cookie 自动携带,需配 `SameSite` + CSRF Token。

信息泄露: 存储内容可能被翻看。防护:加密、最小化存储、及时清理。

数据篡改: 用户或脚本可改写本地数据。防护:读出时做完整性/签名校验,异常则丢弃。

真实案例

案例一:JWT 存 localStorage 导致批量盗号

某 SaaS 应用把 JWT 存进 localStorage 以方便前端读取用户信息。后来某个引入的第三方脚本存在 XSS,攻击者一段代码就把所有在线用户的 JWT 打包外发,实现大规模会话劫持。事后整改方案是:令牌改存 HttpOnly Cookie,前端需要的非敏感用户信息单独用一个普通接口获取。

案例二:本地"记住我"明文存密码

某工具站为实现"记住密码",直接把密码明文写进 localStorage。用户在公用电脑登录后,下一位使用者打开开发者工具即可看到明文密码。正确做法是根本不在前端存密码,改用服务端签发的长效 refresh token(HttpOnly Cookie)。

案例三:客户端信任本地价格被篡改

某电商把商品价格缓存在 localStorage 并在下单时直接回传,用户改一下本地数据就能"一元购"。教训:前端存储的一切都不可信,价格、权限、金额等必须以服务端为准,前端存储只能用于展示与体验优化。

数据与对比

| 存储方式 | 容量 | JS 可读 | 随请求发送 | 生命周期 | 适合存 |

| --- | --- | --- | --- | --- | --- |

| localStorage | ~5MB | 是 | 否 | 持久 | 非敏感偏好 |

| sessionStorage | ~5MB | 是 | 否 | 标签页会话 | 临时会话数据 |

| Cookie(HttpOnly) | ~4KB | 否 | 是 | 可设置 | 会话令牌 |

| IndexedDB | 数百 MB+ | 是 | 否 | 持久 | 大型/离线数据 |

| 令牌存放位置 | 抗 XSS | 抗 CSRF | 综合评价 |

| --- | --- | --- | --- |

| localStorage | 差 | 好 | 不推荐存令牌 |

| 普通 Cookie | 差 | 差 | 不推荐 |

| HttpOnly+SameSite Cookie | 好 | 好 | 推荐 |

常见坑

把 JWT/令牌塞进 localStorage:XSS 一旦发生即全军覆没,令牌应放 HttpOnly Cookie。
以为前端加密就万无一失:密钥若也在前端,攻击者能拿到密钥就能解密;前端加密只能提高门槛,抵御不了运行时脚本。
信任本地数据:金额、权限、价格等必须服务端校验,前端存储仅供展示。
登出没清理干净:只跳转不清 storage/Cookie,令牌仍可复用。
在同步 API 里存大数据:localStorage 是同步的,存大对象会卡住主线程,大数据用 IndexedDB。
跨子域 Cookie 泄露:Cookie 的 domain 设太宽会被子域读取,敏感 Cookie 建议加 `__Host-` 前缀。

最佳实践

令牌类走 HttpOnly + Secure + SameSite Cookie,不要放 localStorage
敏感数据尽量不落地,必须存则用 Web Crypto API 加密
按敏感度选容器:偏好用 localStorage、会话用 sessionStorage、大数据用 IndexedDB
登出/超时主动清理所有本地敏感数据
一切前端数据都当"不可信",关键校验放服务端
配合强 XSS 防御(CSP、输出编码),这是保护本地存储的根基

令牌存放方案深入对比

前面提到"令牌别放 localStorage",但实际项目里到底该放哪儿?答案不是非黑即白,而是一套权衡。下面把五种主流方案摊开对比,帮你按场景选型。

五种方案一览:

| 方案 | 抗 XSS 窃取 | 抗 CSRF | 刷新页面存活 | 跨标签共享 | 实现复杂度 | 典型场景 |

| --- | --- | --- | --- | --- | --- | --- |

| localStorage | 差 | 好 | 是 | 是 | 低 | 不推荐存令牌 |

| sessionStorage | 差 | 好 | 否 | 否 | 低 | 不推荐存令牌 |

| 内存(in-memory) | 较好 | 好 | 否 | 否 | 中 | access token 短存 |

| HttpOnly Cookie | 好 | 需 SameSite/Token | 是 | 是 | 中 | refresh token / 会话 |

| BFF 模式 | 最好 | 好 | 是 | 是 | 高 | 中大型应用首选 |

为什么内存比 localStorage 好? 令牌只存在 JS 变量里,不落地任何持久层。XSS 脚本要偷它,必须在令牌还"活着"时精准读取那个变量;而 localStorage 是随时可被 `localStorage.getItem` 一次性扫走的公开仓库。内存方案缺点是刷新页面即丢失,所以要配合"用 refresh token 静默换新"来补足体验。

主流最佳实践:access token 存内存 + refresh token 存 HttpOnly Cookie。

access token 生命周期短(如 5-15 分钟),放内存,随用随取,页面刷新丢了无所谓;
refresh token 生命周期长(如 7-30 天),放 HttpOnly + Secure + SameSite Cookie,JS 读不到,XSS 偷不走;
页面加载或 access token 过期时,调用刷新接口(浏览器自动带上 refresh Cookie)换一个新的 access token 到内存。

示例 6:access token 存内存 + refresh token 存 HttpOnly Cookie 的完整实现

javascriptCode
// ===== 前端:内存令牌管理器 + 自动刷新 =====
class TokenManager {
  #accessToken = null;      // 私有字段,仅存内存
  #expireAt = 0;            // access token 过期时间戳
  #refreshing = null;       // 正在刷新的 Promise,防并发重复刷新

  setAccessToken(token, expiresInSec) {
    this.#accessToken = token;
    this.#expireAt = Date.now() + expiresInSec * 1000;
  }

  // 判断是否临近过期(留 30 秒缓冲)
  #isExpiring() {
    return !this.#accessToken || Date.now() > this.#expireAt - 30_000;
  }

  // 静默刷新:浏览器自动携带 HttpOnly refresh Cookie
  async #refresh() {
    const res = await fetch('/api/auth/refresh', {
      method: 'POST',
      credentials: 'include', // 关键:带上 Cookie
    });
    if (!res.ok) throw new Error('refresh failed');
    const { accessToken, expiresIn } = await res.json();
    this.setAccessToken(accessToken, expiresIn);
    return accessToken;
  }

  // 取一个可用的 access token(必要时先刷新),并发安全
  async getValidToken() {
    if (!this.#isExpiring()) return this.#accessToken;
    // 若已有刷新在途,复用同一个 Promise,避免并发多次刷新
    if (!this.#refreshing) {
      this.#refreshing = this.#refresh().finally(() => {
        this.#refreshing = null;
      });
    }
    return this.#refreshing;
  }

  clear() {
    this.#accessToken = null;
    this.#expireAt = 0;
  }
}

export const tokenManager = new TokenManager();

// ===== 封装 fetch:自动附加 access token + 401 重试 =====
export async function authFetch(url, options = {}) {
  const token = await tokenManager.getValidToken();
  const doFetch = (t) =>
    fetch(url, {
      ...options,
      credentials: 'include',
      headers: { ...options.headers, Authorization: `Bearer ${t}` },
    });

  let res = await doFetch(token);
  if (res.status === 401) {
    // access token 可能刚失效,强制刷新一次再重试
    tokenManager.clear();
    const fresh = await tokenManager.getValidToken();
    res = await doFetch(fresh);
  }
  return res;
}
javascriptCode
// ===== 服务端(Express):登录下发 + 刷新接口 =====
// 登录成功:access token 走响应体(前端存内存),refresh token 走 HttpOnly Cookie
app.post('/api/auth/login', async (req, res) => {
  const user = await verifyCredentials(req.body);
  const accessToken = signAccessToken(user, '15m');
  const refreshToken = signRefreshToken(user, '30d');

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    path: '/api/auth/refresh', // 仅刷新接口可见,最小暴露面
    maxAge: 30 * 24 * 3600 * 1000,
  });
  res.json({ accessToken, expiresIn: 900 }); // 900s = 15min
});

// 刷新:校验 refresh Cookie,签发新 access token
app.post('/api/auth/refresh', async (req, res) => {
  const token = req.cookies.refreshToken;
  if (!token) return res.status(401).json({ error: 'no refresh token' });
  try {
    const payload = verifyRefreshToken(token);
    const accessToken = signAccessToken(payload, '15m');
    res.json({ accessToken, expiresIn: 900 });
  } catch {
    res.status(401).json({ error: 'invalid refresh token' });
  }
});

OAuth2 / OIDC 前端令牌管理最佳实践

现代前端接入第三方登录(Google、GitHub、企业 SSO)几乎都走 OAuth2 / OpenID Connect。这里有几条与"存储安全"强相关的要点。

必须用 Authorization Code + PKCE,不要用隐式流(Implicit Flow)。 隐式流把 access token 直接从 URL 片段(`#access_token=...`)返回,会残留在浏览器历史、Referer、日志里,早已被 OAuth 2.1 废弃。PKCE(Proof Key for Code Exchange)让公开客户端(SPA)也能安全换码。

示例 7:前端生成 PKCE code_verifier / code_challenge

javascriptCode
// 生成高熵 code_verifier,并派生 SHA-256 的 code_challenge
function base64UrlEncode(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export async function createPkcePair() {
  const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
  const codeVerifier = base64UrlEncode(verifierBytes);

  const digest = await crypto.subtle.digest(
    'SHA-256', new TextEncoder().encode(codeVerifier)
  );
  const codeChallenge = base64UrlEncode(new Uint8Array(digest));

  // verifier 只在换码时用一次,用 sessionStorage 临时存(跳转后要读回)
  sessionStorage.setItem('pkce_verifier', codeVerifier);
  return { codeVerifier, codeChallenge };
}

令牌轮换(Refresh Token Rotation)。 每次用 refresh token 换新 access token 时,服务端同时签发一个新的 refresh token 并作废旧的。这样即使旧 refresh token 泄露,一旦被使用过就失效;若攻击者与合法用户同时使用同一个 refresh token,服务端可检测到"重放"并撤销整条令牌家族(token family)。

示例 8:服务端 refresh token 轮换 + 重放检测

javascriptCode
// 每个 refresh token 记录所属 family 与是否已用过
app.post('/api/auth/refresh', async (req, res) => {
  const raw = req.cookies.refreshToken;
  const record = await db.refreshTokens.findByHash(sha256(raw));

  if (!record) return res.status(401).end();

  // 重放检测:已被使用过的令牌又出现 → 整条 family 撤销
  if (record.used) {
    await db.refreshTokens.revokeFamily(record.familyId);
    return res.status(401).json({ error: 'token reuse detected' });
  }

  await db.refreshTokens.markUsed(record.id); // 旧的作废
  const newRefresh = signRefreshToken(record.userId);
  await db.refreshTokens.save({
    hash: sha256(newRefresh),
    familyId: record.familyId, // 同一 family 延续
    userId: record.userId,
    used: false,
  });

  res.cookie('refreshToken', newRefresh, {
    httpOnly: true, secure: true, sameSite: 'strict',
    path: '/api/auth/refresh', maxAge: 30 * 24 * 3600 * 1000,
  });
  res.json({ accessToken: signAccessToken(record.userId, '15m'), expiresIn: 900 });
});

BFF(Backend-for-Frontend)模式

对安全要求高的应用,最彻底的方案是让前端"完全不碰令牌"。BFF 是一个专为前端服务的轻量后端(通常与前端同源),它替前端保管所有 OAuth 令牌,前端与 BFF 之间只用一个 HttpOnly 的会话 Cookie 通信。

工作流程:

1.用户点登录 → 前端跳转到 BFF 的 `/login` → BFF 发起 OAuth 流程;
2.OAuth 回调打到 BFF,BFF 拿到 access/refresh token,存在服务端(内存/Redis),只给浏览器种一个 HttpOnly 会话 Cookie;
3.前端调业务 API → 打到 BFF → BFF 从会话取出真实令牌,代理转发到资源服务器;
4.令牌刷新、轮换全在 BFF 内部完成,浏览器永远看不到 access token。

BFF 的安全收益:

| 维度 | 传统 SPA 直连 | BFF 模式 |

| --- | --- | --- |

| 浏览器是否持有 access token | 是(内存/storage) | 否 |

| XSS 能否偷到令牌 | 有可能 | 不能(浏览器没有令牌) |

| 令牌刷新逻辑位置 | 前端 | BFF 服务端 |

| CORS/第三方 Cookie 烦恼 | 多 | 少(同源) |

| 实现与运维成本 | 低 | 较高 |

代价是多了一层服务要部署和维护,但对金融、医疗、政务等场景,这层成本通常值得。

Web Crypto API 深入

`window.crypto.subtle` 提供浏览器原生的加解密、签名、哈希、密钥派生能力,全部异步(返回 Promise),且只在安全上下文(HTTPS / localhost)可用。

对称加密 AES-GCM。 GCM 是"认证加密"(AEAD),一次同时保证机密性与完整性——解密时若密文或附加数据被篡改,直接抛错,无需再单独做 HMAC。注意:同一密钥下 IV(12 字节)绝不能重复,否则 GCM 安全性崩溃,所以每次加密都要新随机 IV。

密钥派生:PBKDF2 vs Argon2。

| 算法 | 抗 GPU/ASIC 暴力破解 | 内存开销 | 浏览器原生支持 | 说明 |

| --- | --- | --- | --- | --- |

| PBKDF2 | 一般(仅靠迭代次数) | 低 | Web Crypto 原生 | 迭代应 ≥ 100000,实测 OWASP 建议 600000(SHA-256) |

| Argon2id | 强(内存硬) | 高(可调) | 无(需 WASM) | 现代口令哈希首选,抗并行破解 |

PBKDF2 只增加计算次数,攻击者用 GPU 大规模并行仍很快;Argon2id 额外消耗大量内存,让 GPU/ASIC 并行成本陡增,是目前口令哈希的首选。浏览器没有原生 Argon2,需引入 WASM 版(如 `argon2-browser`)。

示例 9:Argon2id(WASM)派生密钥再喂给 AES-GCM

javascriptCode
import argon2 from 'argon2-browser';

async function deriveKeyArgon2(passphrase, salt) {
  const { hash } = await argon2.hash({
    pass: passphrase,
    salt,                 // Uint8Array,至少 16 字节随机盐
    type: argon2.ArgonType.Argon2id,
    time: 3,              // 迭代轮数
    mem: 64 * 1024,       // 内存开销 64MB
    parallelism: 1,
    hashLen: 32,          // 输出 32 字节 → AES-256 密钥
  });
  // 把原始字节导入为不可导出的 CryptoKey
  return crypto.subtle.importKey(
    'raw', hash, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
  );
}

非对称加密(RSA-OAEP / ECDH)。 前端也能生成密钥对:私钥留在浏览器(理想情况非导出),公钥上传服务端;或用 ECDH 与服务端协商共享密钥做端到端加密。但要清醒认识到局限。

密钥管理的死结:前端密钥无处安放。 前端加密最尴尬的问题是——密钥放哪?

硬编码在 JS 里 → 任何人查看源码即得,等于没加密;
存 localStorage/IndexedDB → XSS 能读,等于没加密;
用户口令派生 → 相对可行,但依赖用户设置强口令,且口令一旦泄露全盘皆输;
从服务端拉取 → 传输和内存中都可能被运行时脚本截获。

所以为何前端加密作用有限: 若攻击者能在你的页面执行 JS(XSS),他就能在"数据被加密之前"或"密钥被使用之时"直接下手,加密形同虚设。前端加密真正能防的是"静态泄露"——比如设备被物理接触、或备份/日志里意外混入 storage 内容时,加密能提高门槛;但它挡不住运行时的活跃攻击。结论:前端加密是纵深防御的一层,不是银弹,绝不能替代 HttpOnly Cookie、CSP、服务端校验。

CryptoKey 的 non-extractable 与 IndexedDB 存储

Web Crypto 有个巧妙设计:生成/导入密钥时把 `extractable` 设为 `false`,得到的 `CryptoKey` 对象无法被导出成原始字节——JS 拿不到密钥的比特,只能把这个"密钥句柄"传给 `encrypt`/`decrypt` 使用。更妙的是,`CryptoKey` 可以直接被结构化克隆存进 IndexedDB。这意味着:密钥能持久保存、能跨会话复用,但即使 XSS 拿到这个对象,也导不出密钥明文,只能"借用"它加解密(仍需防范,但比明文存密钥安全得多)。

示例 10:把 non-extractable CryptoKey 存入 IndexedDB

javascriptCode
import { openDB } from 'idb';

const KEY_DB = openDB('secure-keys', 1, {
  upgrade(db) { db.createObjectStore('keys'); },
});

// 首次生成不可导出的 AES-GCM 密钥并持久化
export async function getOrCreateKey() {
  const db = await KEY_DB;
  let key = await db.get('keys', 'aes-key');
  if (!key) {
    key = await crypto.subtle.generateKey(
      { name: 'AES-GCM', length: 256 },
      false,          // extractable=false:无法导出原始密钥字节
      ['encrypt', 'decrypt']
    );
    await db.put('keys', key, 'aes-key'); // 直接存 CryptoKey 对象
  }
  return key;
}

export async function encryptField(plainText) {
  const key = await getOrCreateKey();
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const cipher = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv }, key, new TextEncoder().encode(plainText)
  );
  return { iv: Array.from(iv), data: Array.from(new Uint8Array(cipher)) };
}

export async function decryptField({ iv, data }) {
  const key = await getOrCreateKey();
  const plain = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv: new Uint8Array(iv) }, key, new Uint8Array(data)
  );
  return new TextDecoder().decode(plain);
}

比"把密钥明文写进 localStorage"安全得多:密钥永远以不可导出的句柄形式存在,攻击者无法把密钥复制出去离线解密其它数据。

Cookie 属性全解

Cookie 的安全性几乎全靠属性配置,逐个说清。

| 属性 | 作用 | 安全建议 |

| --- | --- | --- |

| HttpOnly | JS 无法通过 document.cookie 读取 | 会话/令牌类必开 |

| Secure | 仅 HTTPS 连接才发送 | 生产必开 |

| SameSite | 控制跨站请求是否携带 | 默认 Lax,敏感用 Strict |

| Domain | 指定可访问该 Cookie 的域 | 不写则仅当前主机,别设太宽 |

| Path | 指定可访问的路径前缀 | 令牌可缩到具体接口路径 |

| Max-Age | 相对存活秒数 | 优先用它 |

| Expires | 绝对过期时间(GMT) | 依赖客户端时钟,有偏差风险 |

Domain 的坑。 不写 Domain,Cookie 只属于当前主机(host-only),子域读不到,最安全;写成 `Domain=example.com` 则 `a.example.com`、`b.example.com` 都能读,任一子域被攻破都可能波及。敏感 Cookie 应尽量不设 Domain。

Max-Age vs Expires。 两者都设时现代浏览器优先 Max-Age。Expires 是绝对时间,依赖用户设备时钟,用户改系统时间可能提前失效或延后;Max-Age 是相对当前的秒数,更可靠。都不设则是"会话 Cookie",关浏览器即失效。

`__Secure-` 与 `__Host-` 前缀。 这是浏览器强制的命名约定,用来防止 Cookie 被降级/越域覆盖:

`__Secure-` 前缀:要求该 Cookie 必须带 `Secure` 且来自 HTTPS,否则浏览器拒绝写入;
`__Host-` 前缀:更严格,要求 `Secure` + 无 `Domain`(host-only) + `Path=/`。这样子域无法覆盖它,是防"子域投毒/会话固定"的强力护栏。
javascriptCode
// 用 __Host- 前缀锁定会话 Cookie,杜绝子域覆盖
res.cookie('__Host-session', sessionId, {
  httpOnly: true, secure: true, sameSite: 'strict', path: '/',
  // 注意:使用 __Host- 前缀时绝不能设置 domain
});

Partitioned(CHIPS)Cookie。 随着浏览器逐步淘汰第三方 Cookie,`Partitioned` 属性(CHIPS,Cookies Having Independent Partitioned State)让第三方 Cookie 按"顶级站点"分区存储:嵌在 `a.com` 里的第三方 iframe 写的 Cookie,与嵌在 `b.com` 里的同一第三方 Cookie 互相隔离,无法跨站点追踪,同时保留合法的嵌入式跨站功能(如客服组件、支付 iframe)。

javascriptCode
// 第三方嵌入组件写分区 Cookie(需同时带 Secure)
res.cookie('widget_state', value, {
  httpOnly: true, secure: true, sameSite: 'none', partitioned: true,
});

存储配额与持久化

浏览器给每个源分配的存储是有限且可能被清理的。

查询配额与用量:`navigator.storage.estimate()`。

javascriptCode
async function reportQuota() {
  if (!navigator.storage?.estimate) return;
  const { usage, quota } = await navigator.storage.estimate();
  const usedMB = (usage / 1024 / 1024).toFixed(2);
  const quotaMB = (quota / 1024 / 1024).toFixed(2);
  const percent = ((usage / quota) * 100).toFixed(1);
  console.log(`已用 ${usedMB}MB / 配额 ${quotaMB}MB (${percent}%)`);
  return { usage, quota };
}

配额通常是整机可用磁盘的一个百分比(不同浏览器策略不同,常见为可用空间的若干比例),并非固定 5MB(那是 localStorage 的单独限制)。IndexedDB/Cache 可用的空间大得多。

持久化:`navigator.storage.persist()`。 默认存储是"尽力而为(best-effort)",在磁盘紧张时浏览器可能驱逐(eviction)你的数据。调用 `persist()` 申请"持久(persistent)"存储,获批后数据不会被自动驱逐,只能由用户手动清除。

javascriptCode
async function ensurePersistent() {
  if (!navigator.storage?.persist) return false;
  const already = await navigator.storage.persisted();
  if (already) return true;
  const granted = await navigator.storage.persist(); // 可能触发权限/静默判定
  console.log(granted ? '已获持久化存储' : '仍为尽力而为存储');
  return granted;
}

驱逐(eviction)策略。 尽力而为存储在磁盘压力下按 LRU(最近最少使用的源优先)被清理,且是"整源清除"——不会只删一半,而是把该源的所有 storage 一起清空。所以:重要离线数据要申请持久化 + 做好数据可重建的兜底,别假设本地数据永远在。

Cache Storage / Service Worker 缓存的安全考量

Service Worker 配合 Cache Storage 能拦截网络请求做离线缓存,但也带来安全面:

别缓存敏感响应。 带个人数据或授权信息的 API 响应若被塞进 Cache,任何能执行 JS 的脚本都能通过 `caches.match` 读回。缓存策略要显式排除敏感接口。
SW 作用域(scope)与劫持风险。 Service Worker 一旦注册,会持续拦截其 scope 下的请求。若攻击者能上传 JS 到你的源(比如用户可上传文件的域)并注册 SW,可长期劫持流量。务必限制可托管 JS 的路径,SW 文件本身要走严格 CSP 与完整性校验。
缓存投毒。 若缓存键设计不当(忽略了关键请求头/查询参数),可能把 A 用户的响应缓存后返给 B 用户。缓存键要能唯一区分不同用户/权限的响应,用户相关数据一般不进共享缓存。
javascriptCode
// Service Worker:显式跳过敏感接口,不进缓存
self.addEventListener('fetch', (event) => {
  const url = new URL(event.request.url);
  const SENSITIVE = ['/api/account', '/api/auth', '/api/payment'];
  if (SENSITIVE.some((p) => url.pathname.startsWith(p))) {
    return; // 直接走网络,不缓存
  }
  event.respondWith(
    caches.match(event.request).then((hit) => hit || fetch(event.request))
  );
});

隐私与合规

GDPR 与同意管理。 在欧盟等法域,非必要的存储(尤其用于追踪/分析的 Cookie 和类似技术)必须在获得用户明确同意前不得写入。合规做法:把存储分为"严格必要(strictly necessary)"与"非必要",非必要项在用户同意前不落地,并提供可撤回同意的入口。

javascriptCode
// 简易同意闸门:非必要存储要先过 consent 检查
const consent = {
  get(cat) {
    try { return JSON.parse(localStorage.getItem('consent') || '{}')[cat] === true; }
    catch { return false; }
  },
};

function setAnalytics(key, value) {
  if (!consent.get('analytics')) return; // 未同意分析类,直接不存
  localStorage.setItem(key, value);
}

隐身/无痕模式差异。 无痕模式下 localStorage/IndexedDB 通常可用,但会话结束即全部清除,配额也可能被大幅调低。部分旧环境甚至在无痕下让 `localStorage.setItem` 直接抛异常(配额为 0)。所以代码不能假设存储一定可写,要有 try/catch 兜底与降级路径。

清理策略。 主动清理是隐私与安全的重要一环:登出清、会话超时清、数据不再需要时清、版本升级时清理旧结构。别把数据无限期堆在用户设备上。

完整可运行代码示例集

示例 11:带命名空间 + 版本 + HMAC 签名校验的 storage 封装

给存储加 HMAC 签名,能检测数据是否被用户或脚本篡改(注意:签名密钥在前端,只能防"无密钥的篡改",防不了能读到密钥的运行时攻击,属完整性而非机密性手段)。

javascriptCode
class SignedStorage {
  constructor(namespace, version, hmacKey) {
    this.prefix = `${namespace}:v${version}:`;
    this.keyPromise = crypto.subtle.importKey(
      'raw', new TextEncoder().encode(hmacKey),
      { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']
    );
  }

  async #sign(text) {
    const key = await this.keyPromise;
    const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text));
    return btoa(String.fromCharCode(...new Uint8Array(sig)));
  }

  async set(key, value) {
    const body = JSON.stringify(value);
    const sig = await this.#sign(body);
    localStorage.setItem(this.prefix + key, JSON.stringify({ body, sig }));
  }

  async get(key) {
    const raw = localStorage.getItem(this.prefix + key);
    if (!raw) return null;
    try {
      const { body, sig } = JSON.parse(raw);
      const expected = await this.#sign(body);
      if (sig !== expected) {
        localStorage.removeItem(this.prefix + key); // 签名不符 → 判定篡改,丢弃
        return null;
      }
      return JSON.parse(body);
    } catch {
      return null;
    }
  }
}

示例 12:跨标签页同步(storage 事件 + BroadcastChannel)

用户在标签 A 登出,标签 B 也应立即失效。两种机制:`storage` 事件(localStorage 变化时其它同源标签触发)与更现代的 `BroadcastChannel`。

javascriptCode
// 方式一:storage 事件(仅在"其它"标签页触发,写入方自身不触发)
window.addEventListener('storage', (e) => {
  if (e.key === 'auth-state' && e.newValue === null) {
    // 别的标签登出了 → 本标签同步清理并跳登录
    tokenManager.clear();
    location.href = '/login';
  }
});

// 方式二:BroadcastChannel(结构化消息,更清晰)
const authChannel = new BroadcastChannel('auth');
authChannel.onmessage = (e) => {
  if (e.data.type === 'logout') {
    tokenManager.clear();
    location.href = '/login';
  }
};
export function broadcastLogout() {
  authChannel.postMessage({ type: 'logout' });
}

示例 13:加密 IndexedDB 封装(结合 non-extractable 密钥)

javascriptCode
import { openDB } from 'idb';

class EncryptedStore {
  constructor(dbName, storeName) {
    this.storeName = storeName;
    this.dbPromise = openDB(dbName, 1, {
      upgrade(db) {
        if (!db.objectStoreNames.contains('keys')) db.createObjectStore('keys');
        if (!db.objectStoreNames.contains(storeName)) db.createObjectStore(storeName);
      },
    });
  }

  async #key() {
    const db = await this.dbPromise;
    let key = await db.get('keys', 'k');
    if (!key) {
      key = await crypto.subtle.generateKey(
        { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
      );
      await db.put('keys', key, 'k');
    }
    return key;
  }

  async put(id, value) {
    const key = await this.#key();
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const cipher = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(value))
    );
    const db = await this.dbPromise;
    await db.put(this.storeName, { iv: Array.from(iv), data: Array.from(new Uint8Array(cipher)) }, id);
  }

  async get(id) {
    const db = await this.dbPromise;
    const rec = await db.get(this.storeName, id);
    if (!rec) return null;
    const key = await this.#key();
    const plain = await crypto.subtle.decrypt(
      { name: 'AES-GCM', iv: new Uint8Array(rec.iv) }, key, new Uint8Array(rec.data)
    );
    return JSON.parse(new TextDecoder().decode(plain));
  }
}

示例 14:检测 localStorage 可用性与容量兜底

javascriptCode
// 无痕模式/配额为 0/被禁用时,localStorage 可能抛异常,需探测 + 内存兜底
function createStorage() {
  let available = false;
  try {
    const probe = '__probe__';
    localStorage.setItem(probe, '1');
    localStorage.removeItem(probe);
    available = true;
  } catch {
    available = false; // 降级到内存 Map
  }

  const memory = new Map();
  return {
    get(k) {
      return available ? localStorage.getItem(k) : (memory.get(k) ?? null);
    },
    set(k, v) {
      if (!available) return memory.set(k, v);
      try {
        localStorage.setItem(k, v);
      } catch (e) {
        // 配额超限(QuotaExceededError):清理旧数据或降级到内存
        if (e.name === 'QuotaExceededError') memory.set(k, v);
      }
    },
    isPersistent: available,
  };
}

示例 15:在 Web Worker 中隔离敏感数据

把敏感计算/密钥使用放进 Worker,主线程(更易被 XSS 注入的 DOM 环境)拿不到 Worker 内的变量,形成一层隔离。Worker 无法访问 `document`、`localStorage`(可访问 IndexedDB),攻击面更小。

javascriptCode
// main.js:主线程只发指令,不接触密钥/明文令牌
const worker = new Worker('/crypto-worker.js');
export function encryptViaWorker(plainText) {
  return new Promise((resolve) => {
    const id = crypto.randomUUID();
    const onMsg = (e) => {
      if (e.data.id !== id) return;
      worker.removeEventListener('message', onMsg);
      resolve(e.data.result);
    };
    worker.addEventListener('message', onMsg);
    worker.postMessage({ id, action: 'encrypt', plainText });
  });
}
javascriptCode
// crypto-worker.js:密钥只存在于 Worker 作用域内
let cryptoKey = null;
async function getKey() {
  if (!cryptoKey) {
    cryptoKey = await crypto.subtle.generateKey(
      { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
    );
  }
  return cryptoKey;
}
self.onmessage = async (e) => {
  const { id, action, plainText } = e.data;
  if (action === 'encrypt') {
    const key = await getKey();
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const cipher = await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv }, key, new TextEncoder().encode(plainText)
    );
    self.postMessage({ id, result: { iv: Array.from(iv), data: Array.from(new Uint8Array(cipher)) } });
  }
};

存储型 XSS 与本地存储再深入

前面说 XSS 能读走 localStorage,这里深入到"存储与 XSS 互相放大"的关系。

本地存储会把一次性 XSS 变成持久化 XSS。 若应用把用户输入(如昵称、评论草稿)存进 localStorage/IndexedDB,之后又不加转义地渲染回页面,那么攻击者注入的脚本会被反复执行——每次打开页面都触发一次,比反射型 XSS(需诱导点击特制链接)危害更大、更持久。这类"数据存本地 + 渲染时不转义"是 DOM 型存储 XSS 的常见成因。

防护要点:

从任何存储(localStorage/IndexedDB/Cache)读出的数据都视为不可信输入,渲染前做输出编码,别用 `innerHTML` 直接塞;
需要富文本时用成熟的净化库(如 DOMPurify)清洗;
用 CSP(尤其 `script-src` 不含 `unsafe-inline`)兜底,即便有注入也难执行;
令牌等即便加密也别指望"存了就安全",运行时脚本仍能在解密后拿到明文——根子还是防住 XSS。

XSS 与令牌存放的攻防升级。 有人以为"我把令牌加密后存 localStorage 就安全了",但解密密钥/逻辑就在前端,XSS 脚本可以直接调用你暴露的解密函数拿明文,甚至直接复用你封装好的 `authFetch` 冒充用户发请求。真正抬高攻击成本的,是"浏览器根本没有令牌"(BFF)或"令牌读不到"(HttpOnly Cookie)。

更多真实案例

案例四:refresh token 不轮换导致长期劫持。 某应用把长效 refresh token 存 localStorage 且从不轮换。一次 XSS 之后攻击者拿到 refresh token,即便用户改了密码、access token 频繁过期,攻击者仍能用这张不失效的 refresh token 持续换新令牌,潜伏数月。整改:refresh token 移入 HttpOnly Cookie + 启用轮换与重放检测,任何一次异常复用即撤销整条令牌家族。

案例五:Domain 设太宽被子域窃取会话。 某站把会话 Cookie 设成 `Domain=example.com`,同时允许用户在 `user.example.com` 托管自定义页面。攻击者在自己的用户子域页面里用 `document.cookie`(会话 Cookie 未设 HttpOnly)读到了其它用户带过来的顶级域会话。教训:敏感 Cookie 不设 Domain(host-only)、加 `__Host-` 前缀、必开 HttpOnly。

案例六:缓存把私有数据发给了别人。 某应用的 Service Worker 用固定 URL 作缓存键缓存了 `/api/profile` 响应,未区分用户。共享设备上 A 登出、B 登录后,B 打开个人页看到的是缓存里 A 的资料。教训:用户相关响应不进共享缓存,缓存键必须区分用户/权限,敏感接口显式跳过缓存。

案例七:无痕模式下写入异常导致白屏。 某应用初始化时直接 `localStorage.setItem` 且未捕获异常,在某些无痕环境(配额为 0)下抛错,整个应用启动流程中断白屏。教训:所有存储写入都要 try/catch,并准备内存兜底,别让存储不可用拖垮整个应用。

关键指标与对比数据

| 项目 | 典型数值 | 说明 |

| --- | --- | --- |

| localStorage 单源上限 | 约 5MB | 同步、字符串键值 |

| Cookie 单条上限 | 约 4KB | 每次请求自动携带 |

| 单域 Cookie 条数 | 约 50 条 | 超出按策略淘汰 |

| IndexedDB/Cache 配额 | 可达磁盘可用空间的较大比例 | 远大于 localStorage |

| access token 建议时长 | 5-15 分钟 | 越短越安全,配自动刷新 |

| refresh token 建议时长 | 7-30 天 | 存 HttpOnly Cookie + 轮换 |

| PBKDF2 迭代(SHA-256) | ≥ 100000,推荐 600000 | OWASP 参考值 |

| AES-GCM IV 长度 | 12 字节 | 同密钥下绝不重复 |

| HMAC/SHA-256 输出 | 32 字节 | 完整性校验 |

扩展最佳实践清单

令牌分层:access token 存内存(短命),refresh token 存 HttpOnly Cookie(长命且轮换),高安全场景上 BFF。
OAuth 走 Code+PKCE,弃用隐式流;启用 refresh token 轮换与重放检测。
Cookie 加固三件套:HttpOnly + Secure + SameSite,敏感项加 `__Host-` 前缀、不设 Domain、缩小 Path。
加密只当纵深防御一层:AES-GCM + 随机 IV,密钥用 non-extractable CryptoKey 存 IndexedDB,别明文存密钥;口令派生优先 Argon2id。
完整性校验:读出的本地数据做 HMAC/签名或结构校验,异常即丢弃并降级。
可用性兜底:探测 localStorage 可用性,捕获 QuotaExceededError,无痕/禁用时降级内存。
配额与持久化:大数据用 IndexedDB,重要离线数据申请 `persist()`,并假设随时可能被驱逐。
跨标签同步:用 storage 事件或 BroadcastChannel 让登出/登录状态全标签一致。
隔离敏感操作:把密钥使用放 Web Worker,缩小主线程攻击面。
合规与清理:非必要存储先取得同意,登出/超时/版本升级主动清理。
一切前端数据不可信:金额、权限、价格等关键逻辑一律服务端校验。

总结

前端存储安全的第一性原理是:浏览器里没有秘密。因此策略不是"如何把秘密藏好",而是"根本别在前端放秘密,非放不可就加密并及时清理"。令牌用 HttpOnly Cookie,偏好用 localStorage,大数据用 IndexedDB,敏感字段用 Web Crypto 加密——各就各位,才是真正的安全存储。

| 维度 | 要点 | 推荐做法 |

| --- | --- | --- |

| 核心认知 | 前端无秘密 | 敏感数据不落地 |

| 令牌存放 | 防 XSS 窃取 | HttpOnly+Secure+SameSite Cookie |

| 加密 | 提高破解门槛 | Web Crypto AES-GCM |

| 分类存储 | 各得其所 | 偏好/会话/大数据分别选容器 |

| 清理 | 减少暴露窗口 | 登出与超时主动清 |

| 信任模型 | 前端不可信 | 关键校验放服务端 |

一句话记忆:能不在前端存的秘密就别存,非存不可就锁进 HttpOnly 保险箱并加密。