前端安全存储方案
前端安全存储方案
前端应用总要在浏览器里存点东西:用户偏好、主题设置、购物车、登录凭证……问题是,浏览器本质上是一个"公共场所"——用户能打开开发者工具随便翻看,任何在页面上运行的脚本(包括被 XSS 注入的恶意脚本)也能读取这些数据。所以"在前端安全地存储数据",首先要想清楚一个残酷的现实:前端没有真正的秘密。凡是能被浏览器读到的,理论上都能被攻击者读到。
打个比方:localStorage 就像贴在办公室公告栏上的便利贴,谁路过都能看;HttpOnly Cookie 则像放进一个只有前台(服务器)能开、你自己都看不见内容的保险箱——你只能"出示"它,却读不出里面写了什么。安全存储的艺术,就是把不同敏感度的数据放进合适的"容器",并对确实敏感的数据加密。
存储方案比较
localStorage:
sessionStorage:
IndexedDB:
Cookie:
为什么存储安全重要
一次成功的 XSS 攻击,往往第一步就是"扫荡"localStorage:把里面的 JWT、access token 全部打包发到攻击者服务器。如果你把登录令牌存在 localStorage,等于把家门钥匙贴在公告栏上——一旦页面有 XSS,账户即刻失守。反之,把令牌放进 HttpOnly Cookie,即使页面被注入脚本,脚本也读不到令牌内容,攻击成本大大提高。所以"存哪里"这个看似琐碎的决定,直接决定了 XSS 的破坏上限。
安全存储策略
按敏感度分类存放:
| 数据类型 | 举例 | 推荐存储位置 |
| --- | --- | --- |
| 高敏感凭证 | 会话令牌、refresh token | HttpOnly + Secure Cookie |
| 敏感业务数据 | 部分个人信息 | 加密后存 IndexedDB,或不落地 |
| 会话态临时数据 | 表单草稿、当前步骤 | sessionStorage |
| 非敏感偏好 | 主题、语言、布局 | localStorage |
| 大体量离线数据 | 离线文档、图片缓存 | IndexedDB |
核心原则:
代码示例
示例 1:安全的存储封装(带过期时间的 localStorage)
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 加密存储
// 从口令派生密钥(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(前端读不到,最安全)
// Express:令牌存 HttpOnly Cookie,杜绝 JS 读取
res.cookie('sessionId', sessionId, {
httpOnly: true, // JS 无法访问,XSS 也偷不走
secure: true, // 仅 HTTPS
sameSite: 'strict', // 抵御 CSRF
maxAge: 3600000, // 1 小时
path: '/',
});示例 4:登出时彻底清理前端敏感数据
function secureLogout() {
// 清空 storage
localStorage.clear();
sessionStorage.clear();
// 通知后端清除 HttpOnly Cookie(前端无法直接删)
fetch('/api/logout', { method: 'POST', credentials: 'include' });
// 清理内存中的敏感引用
window.__USER__ = null;
}示例 5:用 idb 封装 IndexedDB 存储大对象
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 | 好 | 好 | 推荐 |
常见坑
最佳实践
令牌存放方案深入对比
前面提到"令牌别放 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。
示例 6:access token 存内存 + refresh token 存 HttpOnly Cookie 的完整实现
// ===== 前端:内存令牌管理器 + 自动刷新 =====
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;
}// ===== 服务端(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
// 生成高熵 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 轮换 + 重放检测
// 每个 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 通信。
工作流程:
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
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(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
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 被降级/越域覆盖:
// 用 __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)。
// 第三方嵌入组件写分区 Cookie(需同时带 Secure)
res.cookie('widget_state', value, {
httpOnly: true, secure: true, sameSite: 'none', partitioned: true,
});存储配额与持久化
浏览器给每个源分配的存储是有限且可能被清理的。
查询配额与用量:`navigator.storage.estimate()`。
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)"存储,获批后数据不会被自动驱逐,只能由用户手动清除。
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 能拦截网络请求做离线缓存,但也带来安全面:
// 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)"与"非必要",非必要项在用户同意前不落地,并提供可撤回同意的入口。
// 简易同意闸门:非必要存储要先过 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 签名,能检测数据是否被用户或脚本篡改(注意:签名密钥在前端,只能防"无密钥的篡改",防不了能读到密钥的运行时攻击,属完整性而非机密性手段)。
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`。
// 方式一: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 密钥)
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 可用性与容量兜底
// 无痕模式/配额为 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),攻击面更小。
// 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 });
});
}// 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 的常见成因。
防护要点:
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 字节 | 完整性校验 |
扩展最佳实践清单
总结
前端存储安全的第一性原理是:浏览器里没有秘密。因此策略不是"如何把秘密藏好",而是"根本别在前端放秘密,非放不可就加密并及时清理"。令牌用 HttpOnly Cookie,偏好用 localStorage,大数据用 IndexedDB,敏感字段用 Web Crypto 加密——各就各位,才是真正的安全存储。
| 维度 | 要点 | 推荐做法 |
| --- | --- | --- |
| 核心认知 | 前端无秘密 | 敏感数据不落地 |
| 令牌存放 | 防 XSS 窃取 | HttpOnly+Secure+SameSite Cookie |
| 加密 | 提高破解门槛 | Web Crypto AES-GCM |
| 分类存储 | 各得其所 | 偏好/会话/大数据分别选容器 |
| 清理 | 减少暴露窗口 | 登出与超时主动清 |
| 信任模型 | 前端不可信 | 关键校验放服务端 |
一句话记忆:能不在前端存的秘密就别存,非存不可就锁进 HttpOnly 保险箱并加密。