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 |
| 解密抛 DOMException | IV 复用或数据被改 | 每次随机 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,而不是直接存明文密钥。第三,大文件加密建议分块处理,避免一次性把所有内容读进内存。掌握这些,你就能在客户端安全地保管敏感信息了。
最后提醒一句工程化建议:把加密逻辑封装成独立模块并写好单元测试,覆盖“错误口令解密失败”“密文被篡改抛错”“空输入处理”等边界;前端加密的价值在于可预期、可复现的行为,而不是临时拼凑几行代码。当你把它当作基础设施而非一次性脚本时,才能真正为产品加上那一层额外的安全边界。




