Web Crypto API 实战:用浏览器原生加密保护前端数据

Web Crypto API 是浏览器内置的密码学接口,让前端加密不再依赖任何第三方库。本文用可运行的示例,带你掌握对称加密、非对称签名、口令哈希与随机数生成,把敏感数据安全地留在客户端。

一、为什么前端也需要加密

很多同学以为“上了 HTTPS 就安全了”,其实 HTTPS 只保护“传输中”的数据。一旦数据落到 localStorage、IndexedDB 或内存之外,就是以明文形态存在。前端加密主要解决三类问题:其一,静态数据防护,让本地缓存的令牌、配置即使被读取也无法还原;其二,端到端保密,典型如加密备忘录、本地笔记;其三,完整性校验,用签名确认一段配置或消息确实来自可信来源、未被篡改。需要特别注意的是,window.crypto.subtle 仅在“安全上下文”(HTTPS 或 localhost)下可用,因此调试时要确认站点协议。

举个具体场景:你把调用后端接口的 Token 存进 localStorage 方便刷新免登录,某天用户电脑中了盗号木马,攻击者读走这段明文 Token 就能冒充用户。如果你存的是“用用户口令加密后的密文”,攻击者拿到也只是一串乱码,必须再拿到口令才能还原——这就是前端加密带来的额外安全边界。它不能替代 HTTPS 与服务端鉴权,却是纵深防御里常被忽略的一块。

二、Web Crypto API 速览

核心是两个对象:crypto.subtle(SubtleCrypto)提供加密、解密、签名、摘要、密钥派生等原子能力;CryptoKey 是密钥的抽象,默认不可被导出,避免密钥泄漏。所有方法都返回 Promise,因此必须用 async/await 或 .then() 处理。下面这张表列出了最常用的算法与适用场景。

记住一点:SubtleCrypto 故意不提供 MD5、DES、RC4 这类已被攻破的弱算法,也不支持“不校验完整性”的纯 AES-CBC 之外的危险组合。它的设计哲学是“只暴露安全 Defaults”,这能帮开发者少踩坑。如果你的业务需要兼容老系统、不得不处理弱算法,那通常意味着要在服务端做转换,而不是在前端强行实现。

算法类型典型用途
AES-GCM对称加密业务数据、令牌
RSA-OAEP / ECDH非对称加密密钥、密钥协商
RSASSA-PKCS1-v1_5 / ECDSA非对称数字签名、来源校验
PBKDF2派生由口令派生密钥
SHA-256/384/512摘要指纹、完整性

三、对称加密实战:用 AES-GCM 加密文本

AES-GCM 是目前前端对称加密的首选:它同时提供机密性和完整性校验。每次加密都应使用随机 iv(初始化向量),且不要重复使用。下面是一段可直接运行的完整示例。

async function aesEncrypt(plainText) {
  const enc = new TextEncoder();
  // 生成 256 位 AES 密钥,不可导出(内存中使用)
  const key = await crypto.subtle.generateKey(
    { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const data = enc.encode(plainText);
  const buf = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key, data);
  return { cipher: new Uint8Array(buf), iv, key };
}

async function aesDecrypt(cipher, iv, key) {
  const buf = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv }, key, cipher);
  return new TextDecoder().decode(buf);
}

注意 encrypt 返回的密文已经内置了 GCM 的认证标签,解密时若数据被篡改会直接抛错,这正是它比“AES-CBC + 自算 MAC”更省心的地方。

生产环境里,密钥通常不会像示例这样每次随机 generateKey 后丢弃。更常见的做法是:在用户登录时用口令派生出密钥,或把随机生成的密钥再用“主密钥”封装(wrapKey/unwrapKey)后持久化。AES-GCM 推荐使用 256 位密钥与 96 位(12 字节)IV,IV 不需要保密,但绝不能对同一个密钥重复使用相同的 IV,否则会泄露明文异或值。如果你需要并行加密多段数据,为每段生成独立随机 IV 即可。

四、非对称签名:用 RSA 校验数据来源

当你需要确认“这段配置确实由我签发、没被中间人替换”,就用私钥签名、公钥验签。下面是 RSA 签名与验证的最小实现。

签名和加密经常被混淆:加密解决“只有接收方能读”,签名解决“这段内容确实来自发送方且没被改”。二者可以组合——先用接收方公钥加密,再用发送方私钥签名,就能同时实现保密与可信。前端常见用法是给静态配置、许可证或离线数据包加签,启动时用内置公钥验签,一旦被篡改立即拒绝加载,比单纯比对哈希更安全(因为攻击者无法伪造签名)。

async function signData(payload, privateKey) {
  const data = new TextEncoder().encode(payload);
  const sig = await crypto.subtle.sign(
    { name: "RSASSA-PKCS1-v1_5" }, privateKey, data);
  return new Uint8Array(sig);
}

async function verifyData(payload, signature, publicKey) {
  const data = new TextEncoder().encode(payload);
  return crypto.subtle.verify(
    { name: "RSASSA-PKCS1-v1_5" }, publicKey, signature, data);
}

// 生成签名密钥对(验签用公钥可安全公开)
const kp = await crypto.subtle.generateKey(
  { name: "RSASSA-PKCS1-v1_5", modulusLength: 2048,
    publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
  true, ["sign", "verify"]);

五、口令安全:用 PBKDF2 哈希存储密码

前端绝不要明文保存口令,也不要用一次 SHA-256 直接哈希(太快、易被彩虹表反查)。正确做法是用 PBKDF2 配合随机 salt 做大量迭代,把“记忆口令”拉伸成固定长度的密钥。

迭代次数不是越大越好,需要在安全性与用户体验之间权衡:10 万次在现代设备上通常只需几十毫秒,而攻击者离线爆破时会被显著拖慢。salt 的作用是让相同口令产生不同结果,防止攻击者用预计算的彩虹表批量撞库,因此 salt 必须随机且和密文一起保存(无需保密)。如果追求更强抗 GPU 破解能力,可考虑 scrypt 或 Argon2,它们在服务端更常见,前端若无对应原生支持则需引入轻量库。

async function deriveKeyFromPassword(password, salt) {
  const baseKey = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(password), "PBKDF2", false,
    ["deriveKey"]);
  return crypto.subtle.deriveKey(
    { name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
    baseKey,
    { name: "AES-GCM", length: 256 },
    false, ["encrypt", "decrypt"]);
}
// salt 必须为随机值,且与密文一并保存
const salt = crypto.getRandomValues(new Uint8Array(16));
方案抗暴力破解前端适用性
明文 / 单次 SHA极差禁用
PBKDF2(10 万次)好推荐
bcrypt / scrypt / Argon2更好更推荐(需库或服务端)

六、摘要与随机数

digest 用于生成数据指纹,getRandomValues 提供密码学安全的随机数,用来生成 IV、salt 或一次性令牌。它与 Math.random() 完全不同——后者可被预测,绝不能用在密码学场景。

指纹常用于“快速比对两段数据是否一致”或“为文件生成唯一标识”,但它不可逆、也不保密,不能当作加密使用。随机数方面,getRandomValues 的熵来自系统,质量足够;如果你需要生成大段随机密钥材料(如 32 字节以上的随机数),同样用它即可,不必自己拼接。切忌用时间戳、自增 ID 当 IV 或 nonce,这类可预测值会直接破坏加密安全性。

async function sha256(text) {
  const buf = await crypto.subtle.digest(
    "SHA-256", new TextEncoder().encode(text));
  return Array.from(new Uint8Array(buf))
    .map(b => b.toString(16).padStart(2, "0")).join("");
}
const nonce = crypto.getRandomValues(new Uint8Array(16));

七、综合实战:加密敏感配置后存入本地

把前面的能力串起来:先用用户口令经 PBKDF2 派生 AES 密钥,再加密一段 JSON 配置,最后把密文与 salt、iv 一起存入 localStorage。读取时重新派生密钥并解密。关于本地存储的取舍,可参考我们的《浏览器存储选型:IndexedDB与localStorage》。下面给出写入与读取两个函数。

const STORE_KEY = "app.secure.config";

async function saveConfig(password, config) {
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const key = await deriveKeyFromPassword(password, salt);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const cipher = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv }, key,
    new TextEncoder().encode(JSON.stringify(config)));
  const payload = {
    v: 1,
    salt: btoa(String.fromCharCode(...salt)),
    iv: btoa(String.fromCharCode(...iv)),
    data: btoa(String.fromCharCode(...new Uint8Array(cipher)))
  };
  localStorage.setItem(STORE_KEY, JSON.stringify(payload));
}

async function loadConfig(password) {
  const payload = JSON.parse(localStorage.getItem(STORE_KEY));
  const salt = Uint8Array.from(atob(payload.salt), c => c.charCodeAt(0));
  const key = await deriveKeyFromPassword(password, salt);
  const iv = Uint8Array.from(atob(payload.iv), c => c.charCodeAt(0));
  const cipher = Uint8Array.from(atob(payload.data), c => c.charCodeAt(0));
  const buf = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv }, key, cipher);
  return JSON.parse(new TextDecoder().decode(buf));
}

八、常见坑与排障

现象原因解决
crypto.subtle 为 undefined非安全上下文改用 HTTPS 或 localhost 调试
导出密钥报 InvalidAccessError生成时 extractable=false需要导出时传 true
解密抛 DOMExceptionIV 复用或数据被改每次随机 IV,校验来源
前后端算法对不上名称/参数不一致两侧统一算法与填充

更多前端安全基础可看《前端安全实战:XSS与CSRF防御全指南》,加密只是纵深防御的一环。

另外,算法名称必须严格匹配,例如 "AES-GCM" 不能写成 "aes-gcm",hash 字段也要和密钥生成时一致,否则会抛出 OperationError。导出的密钥若用于网络传输,记得用 exportKey 转成 raw、pkcs8 或 jwk 格式,并在对端用对应格式 importKey。跨端互通时,建议把算法参数写成共享常量,减少两边命名不一致的隐患。

九、与 Node.js crypto 的差异

Node.js 通过 require('crypto') 提供同步/异步两套 API,算法名称大体一致;浏览器则用 window.crypto.subtle,全部异步。如果做同构应用,建议把算法参数抽成常量,两端共用,避免命名不一致导致互通失败。

还有一个容易踩的点:Node.js 的 crypto 模块在较新版本里也提供了 webcrypto 子模块,其 API 与浏览器几乎一致,因此同一段逻辑在前后端可以最大复用。如果你的密钥需要在服务端生成、前端使用,优先选择 jwk 这种可序列化的导出格式,配合 HTTPS 下发,比自定义二进制编码更稳妥,也更易调试。

十、落地建议

第一,加密不能替代鉴权——它解决的是“数据被拿到也读不出”,而“谁能拿到”仍要靠《前端鉴权实战:Session、JWT 与 OAuth2》里的会话体系。第二,密钥尽量留在内存,必须持久化时用密码派生密钥去 wrapKey,而不是直接存明文密钥。第三,大文件加密建议分块处理,避免一次性把所有内容读进内存。掌握这些,你就能在客户端安全地保管敏感信息了。

最后提醒一句工程化建议:把加密逻辑封装成独立模块并写好单元测试,覆盖“错误口令解密失败”“密文被篡改抛错”“空输入处理”等边界;前端加密的价值在于可预期、可复现的行为,而不是临时拼凑几行代码。当你把它当作基础设施而非一次性脚本时,才能真正为产品加上那一层额外的安全边界。

上一篇 扩散语言模型实战:Diffusion LLM 提速生成
下一篇 Redis Stream 实战:消息队列与消费组落地
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]