技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTBR

你还在直接用 localStorage 么?先把边界和安全性弄清楚

所属馆藏
Browser
文件格式
Markdown
原始路径
Browser/14-你还在直接用 localStorage 么?该提升下逼格了
本文目录11 个章节
  1. 一、先说结论:Web Storage 适合存什么?
  2. 二、localStorage 和 sessionStorage
  3. 三、为什么要做一层封装?
  4. 四、一个更稳妥的过期封装
  5. 五、跨标签页变化和响应式状态
  6. 六、关于“给 localStorage 加密”
  7. 七、应该选择哪种浏览器存储?
  8. 八、在 SSR 和框架项目中使用
  9. 九、保留原文设计时应删除的错误结论
  10. 总结
  11. 参考资料

你还在直接用 localStorage 么?先把边界和安全性弄清楚

Category(分类): Browser, Web Storage Status: 已更新

原文发表于 2022 年,重点是给 localStorage/sessionStorage 增加前缀、过期时间和加密封装。这个思路仍然有参考价值,但需要先纠正一个重要误区:把数据用前端代码加密,不等于数据获得了安全存储能力。运行在同一页面中的恶意脚本通常也能读取存储、调用解密函数或窃取密钥。

一、先说结论:Web Storage 适合存什么?

localStoragesessionStorage 都是浏览器提供的同步字符串键值存储。它们适合存放体积较小、泄露后影响有限的客户端偏好,例如:

  • 主题、语言、列表排序方式;
  • 可丢失的草稿或最近一次筛选条件;
  • 非敏感的功能开关和版本标记;
  • 已经可以从服务端重新获取的轻量缓存。

不建议直接存放:

  • 密码、长期会话标识、刷新令牌和支付信息;
  • 身份证件、健康记录等敏感个人信息;
  • 任何“只要被 JavaScript 读到就会造成严重后果”的秘密。

只要页面存在 XSS,攻击脚本就可以调用 localStorage.getItem(),也可以读取或篡改 sessionStorage。OWASP 因此建议不要把会话标识放进 Web Storage。需要让 JavaScript 不能读取的会话 Cookie,应由服务端设置 HttpOnly; Secure; SameSite=Lax/Strict 等属性;这也不能消除 XSS 发起当前用户请求的风险,但可以降低令牌直接被读取和外传的风险。

二、localStoragesessionStorage

1. localStorage

  • 按源(scheme + host + port)隔离;
  • 同源文档通常共享同一个存储区域;
  • 浏览器重启后通常仍然存在;
  • 没有内置过期时间;
  • setItemgetItemremoveItemclear 是同步操作;
  • 数据变化时,其他同源文档通常可以收到 storage 事件。

http://example.comhttps://example.com 是不同源,因此对应的 localStorage 也不同。文件 URL 的存储行为没有统一保证,不要把 file: 页面当成生产环境测试方式。

2. sessionStorage

  • 按源和顶层浏览上下文(通常是标签页)隔离;
  • 刷新页面通常保留,关闭标签页或页面会话后清除;
  • 同一标签页中的同源 iframe 可以访问相应的存储区域;
  • 从带有 opener 的页面打开新标签页时,初始数据可能被复制,但之后两边相互独立;
  • 更适合当前标签页的临时状态和表单草稿。

“session”指浏览器页面会话,不是服务端 Session,也不保证用户关闭浏览器前数据绝不被浏览器策略清理。

Web Storage 封装示意(原文配图)

3. 存储访问可能失败

读取 window.localStorage 本身就可能抛出 SecurityError,例如用户阻止持久化数据、浏览器隐私策略限制,或页面来自不适合的 file:/data: URL。写入超出配额时可能抛出 QuotaExceededError

隐私浏览模式通常仍提供 Storage API,但数据会在隐私会话结束时清除。第三方 iframe 的存储访问还可能受到浏览器的第三方存储分区和用户隐私策略影响。

4. 容量不是“永远 5MB”

不同 API、浏览器和平台的配额策略不同。MDN 当前将 Web Storage 的常见上限描述为每个源约 5 MiB 的 localStorage 和约 5 MiB 的 sessionStorage,但应用不应把这个数字当作跨平台契约。写入必须捕获异常;大数据应考虑 IndexedDB、Cache API 或 OPFS。

可以用 Storage Manager 做估算,但结果只是估计值:

const estimate = await navigator.storage?.estimate()

if (estimate) {
  console.log({
    usage: estimate.usage,
    quota: estimate.quota
  })
}

三、为什么要做一层封装?

原文设计了以下能力,这些能力仍然值得保留:

  • 区分 localStoragesessionStorage
  • 使用项目名前缀,避免与同源其他应用的 key 冲突;
  • 为数据增加过期时间;
  • 提供 setgethaskeyslengthremoveclear
  • 捕获 Storage 不可用、JSON 损坏和配额超限等异常。

但封装时还要注意:

  • clear() 会清空当前存储区域中的全部数据,不能直接用于只清理自己应用的数据;
  • Storage.key(index) 的顺序不应作为稳定业务排序;
  • 过期数据不会被浏览器自动按你的业务规则删除,通常需要在读取或枚举时惰性清理;
  • localStorage 同步读写会占用主线程,大量数据和频繁序列化会造成卡顿;
  • SSR、预渲染和 Worker 环境没有可直接使用的 window.localStorage

四、一个更稳妥的过期封装

1. 设计数据格式

Storage 只能存字符串,因此对象需要序列化。与其把“存储时刻 + 持续时间”分别保存并在各处手动比较,不如直接保存绝对过期时间:

{
  "version": 1,
  "value": { "theme": "dark" },
  "expiresAt": 1735689600000
}

expiresAt: null 表示按应用规则长期保存,但仍不代表浏览器永不清理,也不代表数据永不过时。

2. 完整基础实现

下面的实现延续原文的 API 设计,但改成:

  • 默认不加密;
  • 使用 typeof window 兼容 SSR;
  • 检查真实读写能力,而不只判断属性是否存在;
  • 过期时间使用毫秒时间戳;
  • clearStorage 只清理当前应用前缀;
  • getStorageAll 只枚举自己的 key;
  • 对损坏数据采取删除并返回 null 的策略。
const config = {
  type: 'localStorage',
  prefix: 'my-app:',
  ttl: 0 // 秒;0 表示不设置业务过期时间
}

function getStorage(type = config.type) {
  if (typeof window === 'undefined') {
    return null
  }

  if (type !== 'localStorage' && type !== 'sessionStorage') {
    throw new TypeError(`Unsupported storage type: ${type}`)
  }

  try {
    return window[type]
  } catch (error) {
    return null
  }
}

function makeKey(key) {
  if (typeof key !== 'string' || key.length === 0) {
    throw new TypeError('Storage key must be a non-empty string')
  }

  return `${config.prefix}${key}`
}

function removePrefix(key) {
  return key.startsWith(config.prefix)
    ? key.slice(config.prefix.length)
    : key
}

export function isSupportStorage(type = config.type) {
  const storage = getStorage(type)
  if (!storage) return false

  const probeKey = `${config.prefix}__probe__${Math.random().toString(36).slice(2)}`

  try {
    storage.setItem(probeKey, '1')
    storage.removeItem(probeKey)
    return true
  } catch {
    return false
  }
}

export function setStorage(key, value, options = {}) {
  const storage = getStorage(options.type || config.type)
  if (!storage) throw new Error('当前环境不可用 Web Storage')

  const ttl = options.ttl ?? config.ttl
  if (!Number.isFinite(ttl) || ttl < 0) {
    throw new TypeError('ttl must be a non-negative number')
  }

  const record = {
    version: 1,
    value: value === undefined ? null : value,
    expiresAt: ttl > 0 ? Date.now() + ttl * 1000 : null
  }

  // QuotaExceededError、SecurityError 等异常应交给调用方处理或上报。
  storage.setItem(makeKey(key), JSON.stringify(record))
}

export function getStorageValue(key, options = {}) {
  const storage = getStorage(options.type || config.type)
  if (!storage) return null

  const fullKey = makeKey(key)
  const raw = storage.getItem(fullKey)
  if (raw === null) return null

  try {
    const record = JSON.parse(raw)
    if (
      !record ||
      typeof record !== 'object' ||
      record.version !== 1 ||
      !Object.prototype.hasOwnProperty.call(record, 'value')
    ) {
      throw new Error('Invalid storage record')
    }

    if (
      record.expiresAt !== null &&
      Number.isFinite(record.expiresAt) &&
      record.expiresAt <= Date.now()
    ) {
      storage.removeItem(fullKey)
      return null
    }

    return record.value
  } catch {
    // 数据可能来自旧版本或被用户手动改坏,避免每次读取都报错。
    storage.removeItem(fullKey)
    return null
  }
}

export function hasStorage(key, options = {}) {
  const storage = getStorage(options.type || config.type)
  if (!storage) return false

  const fullKey = makeKey(key)
  const raw = storage.getItem(fullKey)
  if (raw === null) return false

  try {
    const record = JSON.parse(raw)
    if (
      !record ||
      typeof record !== 'object' ||
      record.version !== 1 ||
      !Object.prototype.hasOwnProperty.call(record, 'value')
    ) {
      throw new Error('Invalid storage record')
    }

    if (
      record.expiresAt !== null &&
      Number.isFinite(record.expiresAt) &&
      record.expiresAt <= Date.now()
    ) {
      storage.removeItem(fullKey)
      return false
    }
    return true
  } catch {
    storage.removeItem(fullKey)
    return false
  }
}

export function removeStorage(key, options = {}) {
  const storage = getStorage(options.type || config.type)
  storage?.removeItem(makeKey(key))
}

export function getStorageKeys(options = {}) {
  const storage = getStorage(options.type || config.type)
  if (!storage) return []

  const keys = []
  for (let index = 0; index < storage.length; index += 1) {
    const key = storage.key(index)
    if (key?.startsWith(config.prefix)) {
      keys.push(removePrefix(key))
    }
  }
  return keys
}

export function getStorageForIndex(index, options = {}) {
  const keys = getStorageKeys(options)
  return keys[index] ?? null
}

export function getStorageLength(options = {}) {
  return getStorageKeys(options).length
}

export function getAllStorage(options = {}) {
  return getStorageKeys(options)
    .filter(key => hasStorage(key, options))
    .map(key => ({ key, value: getStorageValue(key, options) }))
}

export function clearStorage(options = {}) {
  const storage = getStorage(options.type || config.type)
  if (!storage) return

  getStorageKeys(options).forEach(key => {
    storage.removeItem(makeKey(key))
  })
}

调用示例:

setStorage('draft', { title: 'Web Storage' }, { ttl: 30 * 60 })
const draft = getStorageValue('draft')

setStorage('tab-only-state', { step: 2 }, {
  type: 'sessionStorage',
  ttl: 10 * 60
})

removeStorage('draft')
clearStorage({ type: 'sessionStorage' })

3. 这个实现仍然不是“自动过期服务”

过期时间只是记录在数据中的业务字段。浏览器不会在时间到达时主动调用 removeItem,上面的实现是在读取和枚举时惰性删除。如果必须及时清理,可以在应用生命周期内安排定时器,但页面进入后台、被挂起或被关闭时定时器都不保证准时运行。

如果数据需要跨设备、可审计或可靠保存,应把真实状态放在服务端,客户端只保留临时副本。

五、跨标签页变化和响应式状态

storage 事件可以用于同源文档之间的简单同步:

window.addEventListener('storage', event => {
  if (event.storageArea !== window.localStorage) return
  if (event.key !== 'my-app:theme') return

  console.log('其他页面修改了主题:', event.newValue)
})

注意:

  • 事件通常发送给其他同源文档,不会在发起写入的同一个页面中触发;
  • sessionStorage 的事件范围与顶层页面会话有关;
  • event.newValueevent.oldValue 是字符串或 null,需要自行解析;
  • 这不是响应式系统,当前页面修改 Storage 后仍要主动更新内存状态。

Vue、React 等状态管理工具解决的是当前应用运行时状态的订阅和更新问题;localStorage 负责持久化,两者可以组合,但不能互相替代。现代 Vue 3/Nuxt 项目更适合使用 composable 或 store 插件,而不是把 Vue.use()$storage 混入所有组件。

六、关于“给 localStorage 加密”

1. 原文方案为什么不安全

原文使用 crypto-js、固定的 AES-CBC 密钥和固定 IV,并把密钥直接写进前端代码。这个方案存在几个问题:

  • 打包后的密钥可以被用户或攻击者取出;
  • XSS 可以直接读取明文 Storage,也可以调用同一个解密逻辑;
  • 固定 IV 会泄露重复明文块等模式;
  • CBC 本身不提供认证,密文可能被篡改而未被可靠检测;
  • MD5 不能把一个公开的前端字符串变成真正的秘密。

因此,不能把这类代码宣传为“防止用户查看或防止 XSS 窃取”。如果只是为了避免普通用户在 DevTools 中看到明文,前端编码或加密只能增加阅读成本,不能建立安全边界。

2. Web Crypto 的正确使用方向

如果业务确实需要对本地数据做机密性和完整性保护,可以使用 Web Crypto 的 AES-GCM,并使用随机 IV:

function toBase64(bytes) {
  let binary = ''
  for (const byte of bytes) {
    binary += String.fromCharCode(byte)
  }
  return btoa(binary)
}

async function encryptJson(value, key) {
  const iv = crypto.getRandomValues(new Uint8Array(12))
  const plaintext = new TextEncoder().encode(JSON.stringify(value))
  const ciphertext = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    plaintext
  )

  return {
    iv: toBase64(iv),
    data: toBase64(new Uint8Array(ciphertext))
  }
}

const key = await crypto.subtle.generateKey(
  { name: 'AES-GCM', length: 256 },
  false,
  ['encrypt', 'decrypt']
)

这段代码只演示算法接口,密钥管理才是核心:

  • 如果密钥和代码一起发布,XSS 仍然可以使用它;
  • 如果密钥来自用户输入的口令,可以考虑 PBKDF2/Argon2 等密钥派生,并做好口令丢失处理;
  • 如果密钥只存在服务端,就不要把服务端秘密下发到浏览器;
  • 不要复用 IV;每次 AES-GCM 加密都应生成新的随机 IV;
  • 加密数据仍可能被恶意脚本删除、替换或阻止读取。

认证、授权和会话安全应由服务端和安全 Cookie 负责,而不是由 Storage 加密封装负责。WebAuthn、硬件密钥或服务端会话适合更高安全等级的场景。

七、应该选择哪种浏览器存储?

方案适合场景主要限制
localStorage小量、非敏感、跨页面持久偏好同步、字符串、易被 XSS 读取、容量有限
sessionStorage当前标签页的临时状态页面会话结束后清理,不能当服务端 Session
Cookie需要随 HTTP 请求发送的少量会话状态体积小、增加请求体;敏感会话应使用 HttpOnly 等属性
IndexedDB较大结构化数据、离线数据、异步读写API 更复杂,仍不能当作秘密存储
Cache APIService Worker 管理请求/响应缓存需要设计缓存失效、版本和敏感响应策略
OPFS大文件和 Origin 私有文件操作适用场景更专业,仍受配额和浏览器策略影响
服务端数据库权威、可审计、跨设备数据需要网络、认证和服务端成本

不要把 Cookie、localStorage、IndexedDB、Cache API 统称为“应用缓存”。HTTP 缓存、Web Storage、Cache Storage 和服务端缓存的语义、生命周期和安全边界不同。

八、在 SSR 和框架项目中使用

浏览器 Storage 只能在客户端访问。Nuxt、Next 等 SSR 应用中不要在模块顶层直接执行:

// ❌ 服务端渲染阶段没有 window
const theme = window.localStorage.getItem('theme')

可以在客户端生命周期或 composable 中访问,并考虑首屏水合一致性:

export function readTheme() {
  if (import.meta.server) return 'light'
  return localStorage.getItem('my-app:theme') || 'light'
}

页面第一次服务端输出和客户端读取到的值不同,可能产生 hydration mismatch 或闪烁。主题等场景可以使用内联初始化脚本、Cookie 或服务端可读取的状态解决。

九、保留原文设计时应删除的错误结论

  • “加密后就安全”:密钥在前端时不能抵抗 XSS,也不能替代服务端认证;
  • localStorage 可以像 Cookie 一样自动过期”:没有原生过期字段,需要应用自己记录并清理;
  • clear() 只清除当前项目”:它会清除当前 Storage 区域的所有 key;
  • “每次读取都能自动续期”:只有显式重新写入过期时间才会续期,原文代码中的续期注释并未真正执行;
  • localStorage 变化后当前页面会自动更新”storage 事件主要用于其他文档,当前页面需要自己更新状态;
  • “把 token 放 localStorage 再用 AES 加密即可”:不应把会话令牌放在可被页面 JavaScript 读取的存储中。

总结

  1. Web Storage 是同步的、字符串型的客户端存储,不是数据库,也不是安全容器;
  2. localStorage 跨标签页持久化,sessionStorage 绑定页面会话,二者都没有原生过期时间;
  3. 前缀、过期时间、异常处理和按前缀清理是值得保留的封装能力;
  4. clear()、Storage 配额、隐私模式、第三方分区和 SSR 环境都要认真处理;
  5. 前端硬编码密钥不能保护本地秘密,AES-GCM 也不能解决 XSS;
  6. 敏感会话优先采用服务端会话和 HttpOnly Cookie,复杂离线数据考虑 IndexedDB/Cache API;
  7. 存储只保存可丢失的客户端状态,权威数据仍应由服务端负责。

参考资料

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS