Web Crypto 安全认证实战:从 JWT 签名到端到端加密
登录按钮背后的请求里,藏着整个 Web 安全最复杂的部分之一。
2026 年的今天,前端几乎从不「裸跑」:登录拿到 JWT、Token 存到内存、用 Authorization: Bearer 调 API、刷新 Token 时跑 OAuth2 PKCE、敏感场景还要端到端加密。这一切的底层,几乎都绕不开浏览器原生提供的 crypto.subtle。
但很多前端工程师对它的理解停留在「MD5 一下」「AES 加个密」。结果就是:JWT 在前端用 localStorage 一存,被 XSS 一锅端;端到端加密方案自己拍脑袋设计,IV 重用、密钥派生参数过小,最后等于没加密。
这篇文章不讲 crypto-js,不讲第三方 SDK,只用浏览器原生的 Web Crypto API。从最基础的 HMAC 验签,到 JWT 完整性保护,到 ECDH 端到端密钥交换,再到完整的「客户端 → 服务端 → 客户端」安全通道设计。每一步都配可运行代码 + 攻击模型分析 + 生产级参数表。
一、为什么是 Web Crypto API,不是 crypto-js?
先回答一个常被忽略的问题:Node.js 端有 crypto 模块,为什么浏览器里就不能用 require('crypto')?
答案是沙箱安全。浏览器拒绝给 JS 任何「真随机数生成器 + 大数运算 + 加密原语」的访问权限,否则恶意脚本可以在后台暴力破解用户密码。Web Crypto API 是浏览器唯一的加密接口,且只在安全上下文(HTTPS、localhost)中可用。
| 维度 | Web Crypto API | crypto-js / jsencrypt |
|---|---|---|
| 实现层 | 浏览器原生(C/C++ + 硬件加速) | 纯 JavaScript |
| 性能(1MB AES) | ~5ms | ~80ms |
| 认证 | FIPS 140-2(部分浏览器) | 无 |
| 抗侧信道 | 浏览器进程隔离 | 完全暴露在 JS 层 |
| 异步 API | 强制 async | 同步(阻塞主线程) |
| 体积 | 0 KB | ~200KB |
| 真随机数 | crypto.getRandomValues(CSPRNG) | 需 polyfill(Math.random 不安全) |
结论:生产环境不要用任何 JS 加密库。Web Crypto API 是唯一选择。
二、HMAC 签名:JWT 的心脏
JWT(JSON Web Token)的结构是 base64url(header).base64url(payload).signature。其中 signature 就是用 HMAC(哈希消息认证码)对前两段做的对称签名。
2.1 手动签一个 JWT
/** * 将 ArrayBuffer / Uint8Array 转为 base64url */function bufToB64Url(buf) { const bytes = new Uint8Array(buf); let bin = ''; for (let i = 0; i < bytes.byteLength; i++) bin += String.fromCharCode(bytes[i]); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');}
function strToB64Url(str) { return bufToB64Url(new TextEncoder().encode(str));}
/** * 用 HMAC-SHA256 签名 JWT * @param {object} payload - 要签名的数据 * @param {string} secret - 服务端密钥(前端不能拥有!但演示用) * @returns {string} JWT */async function signJWT(payload, secret) { const header = { alg: 'HS256', typ: 'JWT' }; const h = strToB64Url(JSON.stringify(header)); const p = strToB64Url(JSON.stringify(payload));
// 关键步骤:HMAC-SHA256 const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'] );
const sigBuf = await crypto.subtle.sign( 'HMAC', key, new TextEncoder().encode(`${h}.${p}`) );
return `${h}.${p}.${bufToB64Url(sigBuf)}`;}
// === 使用示例 ===const token = await signJWT( { userId: 12345, role: 'admin', exp: Math.floor(Date.now() / 1000) + 3600 }, 'my-super-secret-key-at-least-32-bytes');console.log(token);// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMzQ1LCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3NTBfQ...2.2 验签:前端能做吗?
技术上能,权限上不该能。如果前端能验签,意味着前端拥有密钥,攻击者只要拿到前端 JS 就能伪造 Token。
但下面这种场景是合理的:前端验签的密钥 ≠ 服务端签发的密钥。例如公开的 JWK(公钥)用于验签,私钥永远只留在服务端(即 RS256 模式)。
/** * 用公钥验签 JWT(RS256) * @param {string} token - JWT * @param {string} publicKeyPem - PEM 格式的公钥 * @returns {Promise<object|null>} payload 或 null */async function verifyRS256(token, publicKeyPem) { const [h, p, s] = token.split('.'); if (!h || !p || !s) return null;
// 把 PEM 头尾去掉,base64 decode,再包成 ArrayBuffer const pemBody = publicKeyPem .replace(/-----BEGIN PUBLIC KEY-----/, '') .replace(/-----END PUBLIC KEY-----/, '') .replace(/\s+/g, ''); const der = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0));
const key = await crypto.subtle.importKey( 'spki', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'] );
const sig = Uint8Array.from( s.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice(0, (4 - s.length % 4) % 4), c => c.charCodeAt(0) );
const ok = await crypto.subtle.verify( { name: 'RSASSA-PKCS1-v1_5' }, key, sig, new TextEncoder().encode(`${h}.${p}`) );
if (!ok) return null;
const payload = JSON.parse(atob(p.replace(/-/g, '+').replace(/_/g, '/')));
// 还要校验 exp if (payload.exp && payload.exp * 1000 < Date.now()) return null;
return payload;}关键原则:HS256 用对称密钥(签发方和验证方共享)→ 不能在前端。RS256 / ES256 用非对称密钥(私钥签发、公钥验证)→ 公钥可以放前端。
2.3 算法选择:HS256 vs RS256 vs ES256
| 算法 | 类型 | 签名长度 | 验签速度 | 推荐场景 |
|---|---|---|---|---|
| HS256 | 对称 | 32B | 极快 | 服务端单点(不推荐前后端分离) |
| RS256 | RSA 非对称 | 256B | 较慢 | 传统企业,对兼容性要求高 |
| ES256 | 椭圆曲线 | 64B | 快 | 现代 Web 应用首选 |
| EdDSA | Ed25519 | 64B | 极快 | 新项目,性能敏感 |
ES256 签名长度只有 RS256 的 1/4,验签速度 5-10 倍,密钥也短(256 位),是当前最推荐的 JWT 算法。
三、密码哈希:从 PBKDF2 到 Argon2
永远不要在前端「加密」密码。前端能做的是:用慢哈希函数派生出密钥,做端到端加密。但要让前端登录用慢哈希,又会泄露用户密码长度信息。
3.1 PBKDF2:Web Crypto 自带的方案
/** * 用密码派生 AES 密钥 * @param {string} password - 用户密码 * @param {Uint8Array} salt - 盐(每个用户唯一) * @param {number} iterations - 迭代次数(越大越慢) */async function deriveKey(password, salt, iterations = 600_000) { const baseKey = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey'] );
return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations, hash: 'SHA-256', }, baseKey, { name: 'AES-GCM', length: 256 }, false, // 不可导出 ['encrypt', 'decrypt'] );}
// 使用:派生 AES 密钥const salt = crypto.getRandomValues(new Uint8Array(16));const aesKey = await deriveKey('user-input-password', salt, 600_000);3.2 迭代次数怎么定?
OWASP 2026 推荐:PBKDF2-SHA256 ≥ 600,000 次。在 2020 年这个数字还是 310,000。原因是硬件变快了。
测试你的设备能跑多快:
async function benchmarkPBKDF2(targetMs = 250) { const password = 'benchmark-password'; const salt = crypto.getRandomValues(new Uint8Array(16)); const baseKey = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits'] );
let iterations = 100_000; while (iterations <= 2_000_000) { const t0 = performance.now(); await crypto.subtle.deriveBits( { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, baseKey, 256 ); const t1 = performance.now(); if (t1 - t0 > targetMs) return iterations; iterations = Math.floor(iterations * 1.5); } return iterations;}
const optimal = await benchmarkPBKDF2(250);console.log(`推荐迭代次数: ${optimal}`);目标:单次派生耗时 200-500ms。在桌面端通常是 600k-1M,在低端手机上可能只能跑到 200k-400k。
3.3 为什么不用 SHA-256 简单哈希?
// ❌ 错误示范const wrong = await crypto.subtle.digest('SHA-256', new TextEncoder().encode('password123'));// 1ms 就能算出 1 亿次!
// ✅ 正确:慢哈希 + 盐const right = await deriveKey('password123', userSalt, 600_000);// 600k 次 SHA-256 + 16 字节盐 + 不可导出⚠️ Web Crypto 没有 Argon2。Argon2 是目前抗 GPU/ASIC 破解最强的算法,但浏览器不支持。Worklet 或 WASM(libsodium.js)可以补全,但增加 ~150KB 体积。
四、ECDH 密钥交换:端到端加密的基石
要实现「服务端永远看不到明文」的端到端加密(E2EE),需要客户端和客户端之间协商出一个共享密钥。ECDH(椭圆曲线 Diffie-Hellman)就是干这个的。
4.1 ECDH 原理(30 秒版)
- Alice 生成自己的椭圆曲线密钥对
(a, A),其中A = a × G(G 是基点)。 - Bob 也生成
(b, B)。 - Alice 把
A发给 Bob,Bob 把B发给 Alice。 - Alice 计算
a × B = a × (b × G) = abG。 - Bob 计算
b × A = b × (a × G) = abG。 - 双方得到了相同的共享密钥
abG,而中间人只看到A和B,无法反推出a或b(离散对数难题)。
4.2 完整代码:双方协商共享密钥
/** * 生成 ECDH 密钥对(P-256 曲线) */async function generateECDH() { return crypto.subtle.generateKey( { name: 'ECDH', namedCurve: 'P-256' }, true, // 可导出(公钥要发给别人) ['deriveKey', 'deriveBits'] );}
/** * 导出公钥为 raw bytes */async function exportPublic(keyPair) { const raw = await crypto.subtle.exportKey('raw', keyPair.publicKey); return new Uint8Array(raw);}
/** * 用自己的私钥 + 对方公钥派生共享密钥 */async function deriveSharedKey(myPrivateKey, theirPublicRaw) { const theirPub = await crypto.subtle.importKey( 'raw', theirPublicRaw, { name: 'ECDH', namedCurve: 'P-256' }, false, [] );
return crypto.subtle.deriveKey( { name: 'ECDH', public: theirPub }, myPrivateKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] );}
// === Alice 端 ===const alice = await generateECDH();const alicePub = await exportPublic(alice);
// 把 alicePub 发送给 Bob(明文传输,**公钥不是秘密**)// ... 网络传输 ...
// === Bob 端 ===const bob = await generateECDH();const bobPub = await exportPublic(bob);
// 收到 alicePubconst aliceShared = await deriveSharedKey(bob.privateKey, alicePub);
// 把 bobPub 发回给 Alice// ... 网络传输 ...
// === Alice 收到 bobPub ===const bobShared = await deriveSharedKey(alice.privateKey, bobPub);
// 现在 aliceShared 和 bobShared 是同一个 AES 密钥// 可以用它加密消息:const iv = crypto.getRandomValues(new Uint8Array(12));const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, aliceShared, new TextEncoder().encode('https://e2ee-secret-message'));4.3 中间人攻击(MITM)问题
裸的 ECDH 有一个致命缺陷:公钥在传输过程中可以被替换。Mallory 拦截 Alice 的公钥,自己生成一对密钥,把自己的公钥发给 Bob,Bob 以为自己在和 Alice 通信。
解决方案:数字签名 + 信任锚。Alice 在发送公钥时,用长期身份密钥(RSA / Ed25519)签名。Bob 收到后,用已知的 Alice 公钥(从 CA、TOFU、或 QR 码)验证签名。
/** * 用 Ed25519 签名 ECDH 公钥(解决 MITM) */async function signECDHWithIdentity(ecdhPubRaw, identityPrivateKey) { return crypto.subtle.sign('Ed25519', identityPrivateKey, ecdhPubRaw);}
async function verifyECDHFromIdentity(ecdhPubRaw, sig, identityPublicKey) { return crypto.subtle.verify('Ed25519', identityPublicKey, sig, ecdhPubRaw);}五、AES-GCM 实战:加密敏感消息
拿到了对称密钥(无论是 ECDH 协商还是 PBKDF2 派生),加密流程都一样。
5.1 一个完整的端到端加密消息系统
class SecureChannel { #key; // CryptoKey,外部无法访问
constructor(key) { this.#key = key; }
/** * 加密消息,返回「IV + 密文」的拼接 */ async encrypt(plaintext) { const iv = crypto.getRandomValues(new Uint8Array(12)); const cipherBuf = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, this.#key, new TextEncoder().encode(plaintext) ); // 前 12 字节是 IV,后面是密文(包含 GCM 标签) const out = new Uint8Array(iv.byteLength + cipherBuf.byteLength); out.set(iv, 0); out.set(new Uint8Array(cipherBuf), iv.byteLength); return out; }
/** * 解密(自动验证 GCM 认证标签) */ async decrypt(packed) { const iv = packed.slice(0, 12); const cipher = packed.slice(12); const plainBuf = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, this.#key, cipher ); return new TextDecoder().decode(plainBuf); }}
// === 使用 ===const aesKey = await deriveKey('shared-passphrase', salt, 600_000);const channel = new SecureChannel(aesKey);
const encrypted = await channel.encrypt('银行卡号: 6225 1234 5678 9012');// 存入 IndexedDB / 发送到服务端(服务端看到的是乱码)const decrypted = await channel.decrypt(encrypted);console.log(decrypted); // '银行卡号: 6225 1234 5678 9012'5.2 IV 重用:最容易犯的致命错误
AES-GCM 的安全性有一个硬性约束:同一密钥下,每个 IV 只能用一次。如果两次加密用了相同的 (key, iv) 组合,攻击者可以直接恢复出明文(包括明文 XOR、密钥流)。
// ❌ 致命错误:固定 IVconst fixedIV = new Uint8Array(12); // 全 0async function brokenEncrypt(plain) { return new Uint8Array(await crypto.subtle.encrypt( { name: 'AES-GCM', iv: fixedIV }, key, new TextEncoder().encode(plain) ));}
// ✅ 正确:每次随机 IVasync function safeEncrypt(plain) { const iv = crypto.getRandomValues(new Uint8Array(12)); // ...}
// ✅ 更好:用计数器(确定性加密场景)// 维护一个全局自增的 12 字节计数器let counter = 0n;async function deterministicEncrypt(plain) { const iv = new Uint8Array(12); new DataView(iv.buffer).setBigUint64(4, counter++); // 后 8 字节自增 return crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plain));}检查清单:每次加密前,确认
iv来自crypto.getRandomValues(new Uint8Array(12))或单调递增的计数器。
六、实战:构建一个零知识笔记应用
把上面所有东西串起来,做一个「服务端永远看不到明文」的笔记应用。
6.1 架构
用户密码 ─PBKDF2→ AES 主密钥(不存储,每次输入密码派生) ↓ AES-GCM 加密 ↓ 密文(IV + 密文 + 标签) ↓ 服务端存储服务端永远只有密文和用户盐。即便数据库泄露,没有用户密码的攻击者拿不到明文。
6.2 完整代码
const PBKDF2_ITERATIONS = 600_000;const PBKDF2_HASH = 'SHA-256';const AES_LENGTH = 256;
/** 从用户密码派生 AES 密钥(每次登录/解锁时调用) */async function unlockVault(password, saltB64) { const salt = Uint8Array.from(atob(saltB64), c => c.charCodeAt(0)); const baseKey = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey'] ); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH }, baseKey, { name: 'AES-GCM', length: AES_LENGTH }, false, ['encrypt', 'decrypt'] );}
/** 加密单个笔记 */async function encryptNote(key, title, content) { const iv = crypto.getRandomValues(new Uint8Array(12)); const payload = JSON.stringify({ title, content, ts: Date.now() }); const cipher = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, new TextEncoder().encode(payload) ); return { iv: btoa(String.fromCharCode(...iv)), cipher: btoa(String.fromCharCode(...new Uint8Array(cipher))), };}
/** 解密单个笔记 */async function decryptNote(key, { iv, cipher }) { const ivBytes = Uint8Array.from(atob(iv), c => c.charCodeAt(0)); const cipherBytes = Uint8Array.from(atob(cipher), c => c.charCodeAt(0)); const plain = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: ivBytes }, key, cipherBytes ); return JSON.parse(new TextDecoder().decode(plain));}
// === 用户注册:服务端生成 salt 并保存 ===async function registerUser(username, password) { const salt = crypto.getRandomValues(new Uint8Array(16)); // 服务端只需要存:username, salt,**永远不存密码哈希** await fetch('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, salt: btoa(String.fromCharCode(...salt)), }), });}
// === 用户登录 ===async function loginAndCreateNote(username, password, noteText) { // 1. 拉取 salt const res = await fetch(`/api/salt?username=${username}`); const { salt } = await res.json();
// 2. 派生密钥 const key = await unlockVault(password, salt);
// 3. 加密笔记 const encrypted = await encryptNote(key, '我的秘密', noteText);
// 4. 发送密文到服务端 await fetch('/api/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, ...encrypted }), });
// 5. 密钥变量出作用域,等 GC 回收 return true;}6.3 服务端只看到什么?
{ "username": "alice", "iv": "8xK2pQ7nT5...", "cipher": "9zL4mR1sV8..."}服务端完全无法恢复出 noteText。数据库泄露、员工越权、服务器被攻陷——攻击者拿到的只有密文。
七、关键安全清单
写完代码,按这个清单逐项检查:
| 检查项 | 标准 | 怎么验证 |
|---|---|---|
| HTTPS | 部署到 HTTPS 或 localhost | window.isSecureContext |
| IV 唯一 | 同一密钥下 IV 不重复 | 每次 crypto.getRandomValues(new Uint8Array(12)) |
| 盐随机 | 每个用户/每次派生独立 | 16 字节 CSPRNG |
| PBKDF2 迭代 | ≥ 600,000 | 实际测量耗时 ≥ 200ms |
| 密钥不导出 | extractable: false | deriveKey 时设置 |
| 密钥清理 | 内存中不残留明文 | 闭包退出 + 主动 unset |
| 算法现代 | 不用 MD5 / SHA1 / AES-CBC | 改用 SHA-256 / AES-GCM |
| JWT 算法 | 不用 alg: none | 服务端严格校验 alg |
| 时序攻击 | HMAC 比较用 crypto.subtle.verify | 不要 === 手动比 |
| 跨标签页同步 | 用 BroadcastChannel + 派生密钥 | 不要直接传密钥 |
八、性能数据
在我手头的 M2 MacBook 上做的一组基准测试(仅供参考):
| 操作 | 耗时 | 备注 |
|---|---|---|
| 生成 ECDH P-256 密钥对 | 2.1ms | 单次 |
| ECDH 派生 AES 密钥 | 1.4ms | 双方各一次 |
| AES-GCM 加密 1KB | 0.05ms | 极快 |
| AES-GCM 加密 1MB | 8ms | 实测 |
| HMAC-SHA256 签名 | 0.02ms | 32 字节输出 |
| PBKDF2 派生(600k 迭代) | 280ms | 桌面端典型值 |
| PBKDF2 派生(600k 迭代) | 1.2s | iPhone 13 |
关键启示:在桌面端 PBKDF2 600k 迭代约 280ms,用户可接受。移动端可能要降到 200k-400k。
九、常见错误(线上事故总结)
9.1 alg: none 漏洞
很多年前 JWT 库允许 alg: none,即无签名。攻击者直接把 header 改成 {"alg":"none"} 就能伪造 Token。
// ❌ 攻击载荷const malicious = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' })) + '.' + btoa(JSON.stringify({ userId: 999, role: 'admin' })) + '.';// 没有任何 signature 段
// ✅ 防御:服务端只接受白名单算法// const allowedAlgs = ['HS256', 'RS256'];// verify(token, key, { algorithms: allowedAlgs });9.2 localStorage 存 Token + XSS
Token 放 localStorage,被 XSS 一扫就完蛋。
// ❌ 错误localStorage.setItem('token', jwt);
// ✅ 正确:存内存 + httpOnly Cookie 双管齐下// 1. 内存(用于当前请求)let inMemoryToken = null;function setToken(t) { inMemoryToken = t; }function getAuthHeader() { return inMemoryToken ? `Bearer ${inMemoryToken}` : ''; }
// 2. 刷新 Token 用 httpOnly Cookie(防 XSS)// 服务端:res.cookie('refresh', token, { httpOnly: true, secure: true, sameSite: 'strict' });9.3 HMAC 时序攻击
a === b 这种字符串比较,会因为「第一个不同字符的位置」泄露前缀信息。攻击者可以逐字节猜出 HMAC 值。
// ❌ 时序攻击function badVerify(received, expected) { return received === expected;}
// ✅ 常时比较(用 Web Crypto 自带 verify)const ok = await crypto.subtle.verify('HMAC', key, sig, msg);// 内部用恒定时间算法十、延伸阅读
- MDN - Web Crypto API:https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API
- OWASP Password Storage Cheat Sheet:https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- JWT Best Current Practices (RFC 8725):https://datatracker.ietf.org/doc/html/rfc8725
- NIST SP 800-38D (AES-GCM):https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
- Signal Protocol(端到端加密参考实现):https://signal.org/docs/
总结:Web Crypto API 提供了和系统级加密库同级别的能力,但接口设计偏底层。生产中要关注的不是「能不能实现」,而是「参数对不对、IV 用没用对、密钥有没有泄露」。把这份清单和文章里的代码模板存下来,几乎能覆盖 90% 的前端认证场景。
剩下的 10%?那就要上 Signal 协议了,下次再聊。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!