尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
前端 AES 加密实战:Web Crypto API 与 AES-GCM 方案详解
1. 为什么要自己写 AES 加密而不是直接调后端几个月前有个项目让我很头疼前端需要把用户敏感数据加密后再传给后端后端只认 AES 加密后的密文。那会儿我第一反应是“前端加密不是脱裤子放屁吗”但需求摆在那里——某些接口不能直接用 HTTPS 裸奔明文或者中间还有一层第三方转发必须在浏览器端就把数据“锁死”。折腾了一周多踩了不少坑也把 AES 在 JavaScript 里的各种玩法摸了一遍。今天这文章就是把我最常用、最稳的一套方案沉淀下来想给同样被这个问题卡住的朋友一个可以直接照着写的参考。这篇文章适合谁前端工程师、全栈开发者或者任何需要在浏览器或者 Node.js 里做 AES 加密的同行。我会讲清楚几个核心问题为什么用 AES、为什么用 AES-GCM 而不是 AES-CBC、密钥和 IV 到底怎么管理、以及纯 JavaScript 实现和依赖 Web Crypto API 的原生实现之间该怎么选。你看完不只能抄代码还能明白背后那些“为什么”以后换场景也能自己拿主意。2. 方案选型为什么我放弃了纯 JS 库改用 Web Crypto API先说一个很多人容易掉进去的坑网上一搜 AES JavaScript出来的全是各类纯 JavaScript 实现库。它们号称“开箱即用”比如老牌的加密库或者其他轻量级实现。我之前也一度用得很开心毕竟几行代码就搞定了但后来在生产环境里遇到性能劫持和潜在的安全隐患才意识到这事没那么简单。2.1 Web Crypto API 和纯 JS 库的核心差距浏览器和 Node.js 都内置了一套 Web Crypto API也就是crypto.subtle。这套 API 直接调用底层操作系统或者浏览器引擎的加密实现是经过多年审计和硬件优化过的。反观纯 JS 库一切加密运算都在 JavaScript 虚拟机里跑的性能差了可能一个数量级都不止而且纯 JS 实现的算法如果没经过充分审查很容易有侧信道漏洞。我再打个比方纯 JS 库相当于你用纸笔在算乘法Web Crypto API 相当于你按计算器。尽管纸笔也能算对但速度慢、容易出错而且旁边站着人还可能瞄到你的算式——侧信道攻击就是这么回事。2.2 纯 JS 库也有它的舞台什么时候我还是会用它不过我不能一棍子打死纯 JS 库。有些老项目兼容性要求极高必须跑在 IE11 这种古董浏览器上Web Crypto API 指望不上那时候纯 JS 库反而是唯一选择。再比如一些 Electron 渲染进程之外的特殊环境或者 Node 版本过老拿不到全局crypto纯 JS 库也能兜底。但我个人建议是新项目一律优先用 Web Crypto API老项目或者特殊环境再考虑纯 JS。因为对于企业级 AES 加密来说安全和性能同等重要没必要为了兼容几只恐龙浏览器而拖垮整体安全性。2.3 关键决策用 AES-GCM 还是 AES-CBC这块是重头戏我单独拿出来讲。如果你只是随便百度“AES 加密 JS”搜出来的代码几乎全是 AES-CBC。但我在实际项目中强烈推荐你用 AES-GCM。两者最大的区别在于GCM 是 AEAD 模式加密的同时会生成一个认证标签tag接收方拿密文和 tag 一验就知道数据有没有被篡改。而 CBC 只负责机密性不负责完整性。换句话说CBC 模式下的攻击者可以翻转密文中的某些比特从而让解密后的明文变成攻击者想要的恶意内容——这就是著名的“比特翻转攻击”。我见过很多团队拿 CBC 加密接口参数后端解密后直接信任、并拿去拼 SQL。一旦密文被中间人改了那后果不堪设想。而 GCM 模式自带认证解密时如果发现 tag 对不上直接抛异常比你再写一层 HMAC 省事多了。所以我的结论是别再纠结了用 GCM。除非你有某种必须和第三方老系统保持兼容的硬性要求非 CBC 不可否则别给自己埋雷。3. 核心实现用 Web Crypto API 封装一个企业级 AES 加密工具类这一章我直接给出能用的代码然后逐步解释每一步在做什么、为什么这么做。代码我写成了 TypeScript因为现在的前端项目基本都是 TS 写的如果你用的是纯 JS把类型标注摘掉就行。3.1 先解决密钥导入问题从字符串到 CryptoKeyWeb Crypto API 不认识你明文传进去的字符串密钥你首先要把它“导入”成一个CryptoKey对象。这里有个常见的误区很多人以为密钥就是一个 32 字节的字符串直接utf-8编码塞进去完事。但企业级做法通常是用 PBKDF2 从口令生成密钥或者用随机生成的 bytes 作为种子密钥。如果是随机密钥通常建议生成一个 32 字节256 位的随机数组然后 base64 编码后存起来。每次需要加密时再把它导入。下面是我封装好的一段导入代码async function importKeyFromBase64(base64Key: string): PromiseCryptoKey { const rawKey base64ToArrayBuffer(base64Key); return crypto.subtle.importKey( raw, rawKey, { name: AES-GCM }, false, // extractable false防止私钥被导出 [encrypt, decrypt] ); }这里有个细节值得注意extractable我设成了false。为什么因为企业级环境里密钥一旦被导入到运行时就不应该再允许 JavaScript 把它导出去。如果某个 XSS 攻击者拿到了你的 CryptoKey 对象也无法直接把它变成原始字节带走相当于多了一层防护。另外base64ToArrayBuffer需要你自己实现我常用的是下面这段function base64ToArrayBuffer(base64: string): ArrayBuffer { const binaryString atob(base64); const bytes new Uint8Array(binaryString.length); for (let i 0; i binaryString.length; i) { bytes[i] binaryString.charCodeAt(i); } return bytes.buffer; }这段代码兼容atob浏览器和 NodeNode 16 也有全局atob都能跑。3.2 加密函数一句话说清 IV 和附加数据AES-GCM 加密时需要一个 12 字节的随机 IVnonce。这是另一个高频踩坑点IV 不能重复否则 GCM 的安全性直接崩盘。最稳妥的做法是每次加密都生成一个新的随机 IV并把它和密文一起发送给接收方。IV 不要求保密所以你完全可以把 IV 放到密文前面、一起 base64 传给后端。同时 GCM 还支持“附加认证数据Additional Authenticated Data, AAD”也就是不加密但是要参与认证的数据。我一般会把接口号、请求时间戳、甚至用户 ID 放进 AAD这样即使密文被换到另一个用户的请求里tag 也校验不过去因为 AAD 变了。这个细节是许多教程不会提的但对防重放和高危接口非常有效。加密函数如下export interface EncryptResult { ciphertextBase64: string; ivBase64: string; tagBase64?: string; // 对 GCM 来说 tag 往往拼接在密文尾部 }然后实现async function encryptAesGcm( key: CryptoKey, plaintext: string, aad?: Uint8Array ): Promise{ ciphertext: ArrayBuffer; iv: Uint8Array } { const iv crypto.getRandomValues(new Uint8Array(12)); const encodedPlaintext new TextEncoder().encode(plaintext); const ciphertext await crypto.subtle.encrypt( { name: AES-GCM, iv, additionalData: aad, tagLength: 128, // 默认就是 128 位但显式写出来更清晰 }, key, encodedPlaintext ); return { ciphertext, iv }; }注意ciphertext其实是一个 ArrayBuffer里面包含了“密文 认证标签tag”所以表面上你不需要单独取 tag。Web Crypto API 在加密时自动把 tag 拼接在了密文末尾解密时也会自动从末尾取出来校验。这是 GCM 相对其他模式在 API 层面的体贴之处。3.3 解密函数错误处理比你想象的更重要解密的时候你需要把前端传过来的 IV 和密文一起送去让后端或者本地的密钥文件来解。但如果你在前端也要做对称解密比如做本地缓存解密、或者临时回显数据那就得写一个对应的解密函数async function decryptAesGcm( key: CryptoKey, ciphertext: ArrayBuffer, iv: Uint8Array, aad?: Uint8Array ): Promisestring { try { const decryptedBuffer await crypto.subtle.decrypt( { name: AES-GCM, iv, additionalData: aad, tagLength: 128, }, key, ciphertext ); return new TextDecoder().decode(decryptedBuffer); } catch (error) { // 解密失败可能原因密钥不匹配、密文被篡改、IV 错误、AAD 不匹配 console.error(AES-GCM 解密失败, error); throw new Error(解密失败数据可能被篡改或密钥错误); } }我在项目里从来不笼统地把异常往外抛而是先打日志记录原始错误信息再向调用方抛一个通用的业务错误。为什么因为如果直接把OperationError抛给上层用户或者攻击者能通过报错信息猜到你的加密细节安全上有隐患。统一封装成“解密失败”是对业务更友好的做法。3.4 完整封装一个可以直接用的工具类把上面的逻辑串起来再补上 base64 编解码和字符串与 ArrayBuffer 的转换一个完整的企业级加密工具类就成型了。我最常用的一个版本除了加解密还额外封装了两个静态方法export class AesGcmCipher { private constructor(private key: CryptoKey) {} static async fromBase64Key(base64Key: string): PromiseAesGcmCipher { const key await importKeyFromBase64(base64Key); return new AesGcmCipher(key); } static async fromPassword(password: string, salt: Uint8Array): PromiseAesGcmCipher { const keyMaterial await crypto.subtle.importKey( raw, new TextEncoder().encode(password), PBKDF2, false, [deriveKey] ); const key await crypto.subtle.deriveKey( { name: PBKDF2, salt, iterations: 600000, hash: SHA-256, }, keyMaterial, { name: AES-GCM, length: 256 }, false, [encrypt, decrypt] ); return new AesGcmCipher(key); } async encrypt(plaintext: string): Promisestring { const { ciphertext, iv } await encryptAesGcm(this.key, plaintext); return this.pack(ciphertext, iv); } async decrypt(payload: string): Promisestring { const { ciphertext, iv } this.unpack(payload); return decryptAesGcm(this.key, ciphertext, iv); } private pack(ciphertext: ArrayBuffer, iv: Uint8Array): string { const combined new Uint8Array(iv.length ciphertext.byteLength); combined.set(iv, 0); combined.set(new Uint8Array(ciphertext), iv.length); return arrayBufferToBase64(combined.buffer); } private unpack(payload: string): { ciphertext: ArrayBuffer; iv: Uint8Array } { const combined base64ToArrayBuffer(payload); const iv combined.slice(0, 12); const ciphertext combined.slice(12); return { ciphertext, iv }; } }看到这里的pack和unpack有人可能会问为什么不把 IV 单独传给角色其实把 IV 和密文捆绑成一个 base64 字符串的好处是——前端调用方只需要处理一个字段后端也只需要解析一个字段整个接口契约更简单。而且 IV 放在最前面后端解密时先裁前 12 字节即可非常干净。4. 密钥管理别把密钥硬编码更不能写进前端代码库讲了这么多加解密算法你可能会觉得只要代码写得漂亮就安全了。但加密行业里流传着一句话算法没问题问题总出在密钥管理上。4.1 企业级场景中密钥从哪来我见过不少项目前端 JS 里直接写死一个 base64 字符串密钥然后在构建时打进 bundle 里。这简直是给黑客送礼。浏览器里跑的任何代码都是没有秘密可言的所以密钥不能放在前端静态资源里。那怎么办常见的做法有几种从后端接口动态下发页面加载时通过鉴权接口获取一次密钥然后保存在内存变量中不落 localStorage不写进任何全局变量用完翻篇。从配置中心拉取在 Node.js 后端场景密钥存放在配置中心通过环境变量注入前端不需要碰密钥本身只把需要加密的数据发给后端由后端完成加解密。结合用户口令如果你的场景允许用户输入 PIN 码或口令那可以像fromPassword那样用 PBKDF2 现场推导出密钥密码不进网络推导出来的密钥也不出内存。我重点推荐的是第一种方案的前端版改良前端拿到的是“会话密钥”有效期为 10 分钟到 1 小时不等过期后重新拉取。这样即便 bundle 被逆向工程里面也找不到任何长驻密钥就算密钥泄露时间窗也很短。4.2 关于 IV 的四个禁忌关于 IV我再补几条实操必读不要使用固定 IV。我在一次代码评审里看到有人为了“方便调试”把 IV 写死了000000000000000000000000一旦明文有重复模式比如 JSON 的{user_id:开头密文前段就会泄露关联信息。IV 不需要保密。你可以和密文一起存、一起传这是标准做法。GCM 的 IV 长度通常选 12 字节。虽然技术上支持任意长度的 IV但 12 字节是最安全的、性能也是最优的。长度正好等于一个计数器块的长度而且不会增加碰撞概率。加密和解密必须使用同一个 IV。这个看起来废话但当我遇到“后端随便生成一个新 IV 去解前端数据”的 bug 时真无语了。IV 随手改解密必炸。4.3 前端密钥在内存中的生命周期拿到前端密钥之后最怕的是被什么全局错误上报、日志系统打印出来。所以我写了一个小技巧把密钥放进一个闭包变量里然后用依赖注入的方式传给需要的模块不在 window 上挂任何属性。同时如果业务允许我建议在pagehide或者visibilitychange事件里主动清空这个闭包变量减少密钥在内存中的残留时间。说实话这些措施不能阻止一个已经拿到浏览器上下文的攻击者毕竟他可以直接 hook 掉加解密函数接口但至少能防止粗心大意的日志泄漏。安全是分层的能加一层是一层。5. 实操案例注册登录场景中加密用户手机号你可能觉得封装一个类库太抽象那我上一个完整的业务案例用户注册时前端要把手机号加密后传给后端防止在传输过程中被运营商日志或者中间层看到明文。下面就是我实际调试通过的关键步骤。5.1 场景假设和密钥获取逻辑假设我们有一个后端接口/api/crypto/key登录后返回一个会话密钥的 base64 字符串。前端在注册页加载时通过 fetch 获取密钥然后明文手机号加密将密文放入请求 JSON。关键步骤获取密钥const resp await fetch(/api/crypto/key, { credentials: include }); const { keyBase64 } await resp.json(); window.__cipher await AesGcmCipher.fromBase64Key(keyBase64);加密用户输入const encryptedPhone await window.__cipher.encrypt(userInputPhone);发送到后端const payload { encryptedPhone, // 其他明文数据 }; await fetch(/api/register, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload), });这里我把window.__cipher挂到了全局严格来说不够讲究但为了演示方便你可以在模块里用一个let cipher变量。生产环境我推荐放在模块作用域里不要暴露到 window。5.2 后端解密的样子伪代码后端拿到encryptedPhone后凭自己保存的同一把密钥进行解密。因为你用的是 AES-GCM后端解密时只需要把 payload 里的 base64 解码截取前 12 字节作 IV其余部分作密文然后调用 OpenSSL 或者你所用语言的内置解密函数即可。例如 Node 后端可以这样const crypto require(crypto); function decryptPayload(payloadBase64, keyBuffer) { const combined Buffer.from(payloadBase64, base64); const iv combined.subarray(0, 12); const ciphertext combined.subarray(12); const decipher crypto.createDecipheriv(aes-256-gcm, keyBuffer, iv); // GCM 需要传入 authentication tag也就是密文最后 16 字节 const tag ciphertext.subarray(ciphertext.length - 16); const data ciphertext.subarray(0, ciphertext.length - 16); decipher.setAuthTag(tag); const decrypted Buffer.concat([decipher.update(data), decipher.final()]); return decrypted.toString(utf8); }注意这里有个“坑”必须要对齐Web Crypto API 返回的ciphertext末尾已经带了 16 字节 tag而 Node 的createDecipheriv默认把 tag 当成独立参数。所以在上面的 Node 代码里我手动把末尾 16 字节抠出来通过setAuthTag传了进去。如果你用别的语言也要留意对应 API 是否将 tag 附加在密文尾部。前后端加解密两端如果对这个细节没有共识必然各种“解密失败”。5.3 调试阶段我踩过的三个坑坑一用错了密钥编码密钥字符串是 base64 的结果我在前端导入时直接用TextEncoder转了一下把“base64 字符本身”当成了密钥字节。这种 bug 很难发现因为有时候还能解出来一旦密钥里出现特殊字符结果全乱我排查了两个多小时才发现是编码不对。我的建议是任何密钥接口都在文档里明确标注是“base64 编码的原始字节”并在测试环境打印前 8 字节的 hex 值做核对。坑二AAD 两边不一致前端加 AAD 时用了字符串api:register后端校验时却用了api/register差一个斜杠tag 校验直接失败数据被判定为篡改。这个好理解因为 AAD 是参与运算的差一个字节都不行。解决方案把 AAD 的定义写进接口文档甚至形成一个常量文件前后端共用同一个版本号来控制。坑三误把密文当 IV还有一个签名方式上的混乱我一度把 IV 做成密文前缀后来又改成 base64 JSON{iv, data}结构结果迭代了两次之后老版本缓存中的密文还在新代码却用新的格式去解析自然解不出来。后来我在版本升级时加了缓存数据版本号比如统一用v1:${payloadBase64}这种前缀升级时兼容旧格式并且在解密成功后主动迁移。这个做法现在成了团队里的标准。6. 性能与兼容性一次加密应该控制在什么体量有人会担心 Web Crypto API 能不能支撑高并发、大量数据加密。根据我的实测在普通办公级笔记本的 Chrome 上进行 AES-GCM 加密256 位密钥加密一个 1KB 的字符串大约只需要不到 10 微秒。即便你的业务里每次请求要加密几十 KB 的数据几百毫秒内也能完成。所以大多数业务场景下性能根本不会是瓶颈。真正要担心的是兼容性。Web Crypto API 只能跑在“安全上下文”中也就是 HTTPS 或者 localhost。如果你们公司的测试环境是 HTTP那window.crypto.subtle是undefined的这一点特别坑我第一次碰到的时候还以为是浏览器不支持后来查文档才发现是因为域名不是 HTTPS。所以千万别忘了给测试环境配上 HTTPS或者用localhost绕过限制。另外如果你的项目还有小程序、React Native 之类的跨端场景Web Crypto API 的暴露方式各不相同需要做环境探测并封装适配层。我一般是写一个isWebCryptoAvailable()的判断函数如果不可用再回退到纯 JS 库但加起来逻辑很简单不会太影响运行速度。7. 常见问题速查表与应急排查清单我在整理这套方案的最后一章把团队里踩过的所有问题浓缩成一张速查表方便你现场排查。症状可能原因排查方法crypto.subtle为 undefined页面不是 HTTPS 或不是 localhost查看浏览器地址栏协议切换 HTTPS 环境加密时报OperationError密钥位数不符或算法不匹配确认导入算法精确为AES-GCM密钥长度为 16/24/32 字节解密时报OperationError密文被篡改、IV 错误、AAD 不一致先检查 IV 是否取对再检查 AAD 前后端是否一致key.deriveBits报错PBKDF2 迭代次数过高或环境限制降低到 60 万左右并确认浏览器版本花屏或乱码密钥编码不一致打印密钥 base64 前 8 字节和脚本生成值对比同步代码拿不到返回值Web Crypto API 是异步的不要指望同步获得结果全部用 await加密后长度异常GCM 自带 tag 被误以为多余数据允许密文长度增加 16 字节这是正常现象排查的时候有个技巧把问题拆成三段——密钥导入、加密过程、解密过程。你可以分别写一个极小的 demo 来复现单段问题比如只测“导入后能否成功加密一个空串”。如果空串都失败问题一定在密钥或环境如果空串成功、长文本失败再检查数据编码和长度边界。8. 最后再分享一个实战小技巧接口防重放既然主打企业级 AES我再分享一个很多教科书不会提到的小技巧在 GCM 的 AAD 里注入自增序列号或者时间戳。具体做法是前端每次加密时生成一个毫秒级时间戳或者随机的 8 字节数字放进 AAD。后端解密成功后检查这个时间戳和当前时间差是否在 5 分钟以内并且记录一段滑动窗口内已经用过的序列号。如果同一个序列号出现两次就认为是重放攻击。这样做的好处是你不需要再为加密本身增加野代码仅仅借助 GCM 已有的认证机制就顺带实现了防重放。我有个项目就是这么干的把用户 ID 时间戳 接口路径拼成一个 AAD既防止了密文被复制到另一个请求里重放又防止了跨用户挪动数据包。一个字段撬两重安全何乐而不为。如果你也想深挖下一步可以去看看 Web Crypto API 的crypto.subtle.deriveKey和 ECDH 密钥交换搞一个前服务器端临时密钥握手这样一来连密钥下发环节都省了。不过那又是一个比较大的话题了有机会我再单独写一篇。
RELATED

相关推荐

Drive-JEPA解析:让自动驾驶学会在特征空间里预演未来

Drive-JEPA解析:让自动驾驶学会在特征空间里预演未来

最近被一个概念勾起了兴趣:Drive-JEPA。乍一看像是学术界又造了个新名词,但仔细顺着Video JEPA加多模态轨迹蒸馏的思路走了一遍,发现它对端到端驾驶的痛点抓得相当准。做自动驾驶感知和规划这几年,我最深的感受是:模型…

📅 2026/10/18 15:08:42
OPC Client 64位安装配置全攻略:从DCOM调试到数据读写验证

OPC Client 64位安装配置全攻略:从DCOM调试到数据读写验证

简介:这份资源是面向工业自动化领域开发者的OPC DA客户端开发包,基于COM/DCOM技术实现,适用于64位Windows环境下使用Visual Studio 2013构建与OPC服务器通信的应用程序。包内包含OPC DA客户端的核心接口实现与示例工程,涉及IOPCSe…

📅 2026/10/18 15:08:42
AI搜索优化:企业知识管理到数字化转型的关键跳板

AI搜索优化:企业知识管理到数字化转型的关键跳板

1. 为什么AI搜索优化能撬动数字化转型1.1 先厘清定义:AI搜索优化不是给网站做SEO很多企业管理者一看到“AI搜索优化”这几个字,下意识以为这是建个搜索框、调一调算法排名之类的事。我接触过的数字化转型项目里,有相当一部分前期沟通都要花不…

📅 2026/10/18 15:08:42
MORE NEWS

更多资讯

📰

CoCa模型深度解析:对比学习与生成式预训练的融合之路

今年以来多模态这块最不缺的就是新模型,但每次有朋友问我“想快速跟上CoCa这条路子该看什么”,我都会把这篇列为首选。倒不是因为它参数最大或者刷榜最猛,而是因为CoCa把对比学习和生成式学习这两条原本有点“互相对着干”的技术路线&#xf…

📰

原生双端音视频社交源码:架构、难点与部署避坑指南

简介:这套资源定位于快速搭建一对一音视频社交应用的原生双端源码方案,覆盖Android(Java)与iOS(Objective-C)客户端及ThinkPHP后台,适合有移动端开发基础、希望直接获得可扩展通信框架的开发者。…

📰

十月AI前端岗面试策略:从被动应答到主动引导

1. 十月AI前端岗面试的真实生态:为什么“老实”反而成了减分项十月这个时间节点,在招聘市场里有个很微妙的位置。金九银十的说法流传了很多年,但真正在一线做技术招聘的人都知道,十月其实是“补录季”和“捡漏季”的叠加态。大厂的…

📰

JavaScript计算器入门:解决input值类型转换与四则运算陷阱

1. 这个看似简单的计算器&#xff0c;为什么90%的初学者第一次写就卡在“值没传进去”&#xff1f;你有没有试过&#xff0c;在HTML里放三个<input>&#xff0c;两个用来输数字&#xff0c;一个用来显示结果&#xff0c;然后信心满满地写上document.getElementById(a).va…

📰

ChatGPT智能UI逆向拆解:协议驱动的流式组件渲染与前端架构

1. 项目概述&#xff1a;为什么我要拆解一个智能UI如果你经常跟各类AI对话产品打交道&#xff0c;会发现界面里有一套很少被单独讨论的“智能UI层”&#xff1a;对话还在流式输出时&#xff0c;底部已经冒出一个可点击的“运行代码”按钮&#xff1b;你问天气&#xff0c;回答还…

📰

大模型Agent记忆系统实战指南:上下文管理、向量检索与遗忘机制

我不太喜欢用“记忆”这个词来描述大模型应用里的缓存机制&#xff0c;因为大部分人第一次给自己的Agent加记忆时&#xff0c;都把问题想得太简单了——不就是把聊天记录存起来&#xff0c;下次再塞回去吗&#xff1f;我一开始也是这么干的&#xff0c;结果做出来的东西不仅回答…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬