前端做 AES 加解密,绕不开 crypto-js 这个库。我最早接触它是在联调一个移动端 H5 项目的时候,后端要求把用户手机号、身份证这类敏感字段加密后再传,明文一率不收。当时时间紧、文档少,网上搜出来的代码十个有八个是能跑但说不清原理的,照抄完心里特别没底。后来我在好几个项目里反反复复用 crypto-js,踩了不少坑,也彻底把 AES 加解密这块的细节理顺了。这篇东西我不打算写成枯燥的 API 文档,就按实际项目里你会遇到的知识点、报错和取舍来聊,适合刚接触前端加解密、或者正在跟后端联调加密接口的同学参考。
先说清楚一件事:crypto-js 是什么、能做什么。它是一个纯 JavaScript 实现的加密算法库,支持 AES、DES、TripleDES、Rabbit、RC4、MD5、SHA 系列等常见算法,不需要依赖浏览器原生加密接口,也不依赖 Node.js 的 crypto 模块,所以在浏览器、Node、小程序、甚至一些老旧的 WebView 里都能跑。我们这篇重点讲其中应用最广的 AES 对称加解密:同一个密钥,前端加密传给后端,后端用同一个密钥解密,反过来也一样。AES 速度快、密文体积可控、实现简单,是目前前后端联调时最常见的选择之一。
但有一点我得先给你泼盆冷水:前端做 AES 加解密,不等于“安全”。密钥一旦写在 JS 里,浏览器一打开开发者工具就能被人拿到。所以它真正适合的场景是“防明文传输”和“防低水平的数据泄露”,比如抓包时不想让用户手机号直接暴露、数据库落库时不想留明文、或者给接口参数做个基础混淆。真正的高安全场景,必须配合 HTTPS、后端密钥管理、签名防篡改等措施一起上。这篇文章后面专门有一节讲安全边界,你先有个印象就行。
1. 内容整体设计与思路拆解
1.1 前端什么时候真的需要 AES 加解密
我在社区里经常看到两种极端,一种是觉得“前端加密就是脱裤子放屁”,另一种是“不管什么数据先加密再说”。实际项目里,前端做 AES 加解密通常逃不出这三类需求。
第一类是敏感字段的传输加密。用户登录密码、手机号、身份证号、银行卡号,这类字段前端拿到之后其实是可以做一层加密再传给后端的。密码加密尤其常见,就算后端那边还有 HTTPS,多一层加密也能避免密码在浏览器历史、代理日志、运营商缓存这些环节留下明文影子。注意我说的是“避免留明文”,不是“防止被黑客破解”,这个定位要摆正。
第二类是数据落盘保护。如果业务需要在 localStorage、IndexedDB、Cookie 里存一些用户资料、搜索记录、草稿内容,全部明文存储确实有点心大。这时候可以用 AES 加密后再存,读取时解密,至少能做到“本地存储不留明文字段”。当然这个保护强度也受密钥暴露的限制,后面我会专门说怎么尽量弱化这个问题。
第三类是纯前端产品内部的授权校验。比如某些 H5 页面是给内部人员用的,不想让别人看一眼源码、改个参数就绕过,前端会约定一个加密规则来生成访问凭据。这类场景本质是“提高破解成本”,不是“绝对防盗”,但要的就是这个效果。
我不建议把整份请求体都加密,也不建议把所有接口全部改成加密传输。过度加密会带来几个问题:请求调试困难、后端排查问题成本高、性能损耗、以及最尴尬的——一旦密钥泄露,前端所有加密形同虚设。我的原则是:核验类项目用 HTTPS 做传输安全,业务类敏感字段选择性加密,内容级保护才考虑整体加密。
1.2 为什么选 crypto-js,而不是 JSEncrypt 或 Web Crypto API
前端能做加解密的不止 crypto-js 一个,我列个对比你就明白为什么大多数项目绕不开它了。
从面试的角度说,这几个库的边界本身就是高频考点。crypto-js 是对称加密,JSEncrypt 是非对称加密,Web Crypto API 是浏览器原生的加解密接口。三者解决的问题不同,不是简单的替代关系。
crypto-js 的最大优势是“简单直接”,装完就能用,浏览器直接引入,Node 里也能跑。它支持的算法全面,AES、DES、MD5、SHA 全都有,而且加解密的写法高度统一。缺点也是明显的:它是纯 JS 实现,性能不如 Web Crypto API 的原生实现,在大量数据加密时会慢一些;另外它早期版本更新频率不高,一些新算法支持相对滞后。
Web Crypto API 是浏览器内置的,性能好、安全度高,但由于是原生接口,API 风格和 crypto-js 差别很大,而且必须依赖 window.crypto 环境,老版本浏览器和小程序里支持情况不统一。如果做的是纯浏览器端的强安全场景,我反而建议优先考虑它。
JSEncrypt 主要做 RSA 非对称加密,适合加密一些短数据、配合后端做密钥协商,不适合做大批量数据传输加密——RSA 加解密速度比 AES 慢不少,密文长度也会膨胀。
所以现实选择就是:要快、要省事、要跨端一致,用 crypto-js;要极致性能且只跑现代浏览器,考虑 Web Crypto API;要做非对称密钥交换,才上 JSEncrypt。大部分前后端联调场景,crypto-js 是最省心的那一个。
2. 核心细节解析与实操要点
2.1 AES 的分组、密钥、初始化向量和填充,一次说清
先扫盲几个 AES 最基础但最容易搞混的概念。AES 是一种分组加密算法,意思是你不能像加密字符串那样一个字符一个字符地处理,而是把数据切成固定大小的块,一块一块地加密。AES 的分组长度固定是 128 位,也就是 16 个字节。
密钥长度有三种:128 位(16 字节)、192 位(24 字节)、256 位(32 字节),对应我们常说的 AES-128、AES-192、AES-256。密钥越长,加密强度越高,但性能也会略降。前后端联调时你不需要自己纠结用哪个,后端接口文档里写什么就用什么,关键是字节数要对上。
初始化向量(IV,Initialization Vector)就更有意思了。同样一段明文、同一个密钥,如果每次加密结果都一样,攻击者就能通过观察大量密文找到规律。IV 的作用就是给加密过程引入一个随机扰动,让相同明文在不同时刻加密出的密文完全不同。IV 的长度固定等于分组长度,AES 里就是 16 字节。它不需要保密,但必须保证随机性,一般跟着密文一起传给后端。
填充(Padding)解决的是“明文长度不是 16 的倍数”的问题。AES 既然是按 16 字节一组加密,最后一块不满 16 字节怎么办?常见的模式是 PKCS7,末尾缺几个字节就补几个字节,补的数值就是缺的字节数。比如最后一块只剩 5 个字节,那就补 11 个 0x0B。解密的时候按同样的规则去掉填充。
再强调一下这是很多新手的理解误区:crypto-js 里你写密码的时候通常直接传字符串,库内部会用你给的方式去处理这个字符串,可能是 UTF-8 编码后直接当 key,也可能被当作口令去做密钥派生,这两种情况后端拿到的东西天差地别。跨语言联调时 key 和 IV 的处理方式是第一个要确认的点。
2.2 分组模式选择:CBC、ECB 与 GCM
AES 有了分组、密钥、IV、填充之后,还要确定把这些东西组合起来的方式,也就是“模式”。前端项目里最常见的模式是 CBC 和 ECB,偶尔会遇到 GCM。
CBC(Cipher Block Chaining,密文分组链接)模式会把上一组的密文和这一组的明文混合后再加密,这样每一组的密文都依赖之前所有分组,即使相同明文块在不同位置密文也不一样。它需要 IV,安全强度明显优于 ECB,只要是 2020 年之后的项目,我基本推荐用 CBC。
ECB(Electronic Codebook,电子密码本)模式最简单,每个分组独立加密,不需要 IV,同一明文永远得到同一密文,两条相同的数据在密文里一眼就能看出来。这种模式在金融和通信领域已经被广泛弃用了,但一些老旧的内部系统里仍然在用它,联调时你没法选,只能配合。如果真的没有要求,不要主动选 ECB。
GCM(Galois/Counter Mode)是一种认证加密模式,加密同时输出认证标签,能检测密文是否被篡改,比 CBC 要安全得多。但麻烦也在这里:crypto-js 官方并不原生支持 GCM,需要借用第三方扩展或自己实现,跨端联调复杂度会明显上升。如果项目对安全要求极高,或者后端明确要求 GCM 模式,我的建议是考虑用 Web Crypto API 替代 crypto-js。
这里给一个实操判断方法:后端接口文档里只要提到 AES/CBC/PKCS5Padding,那你前端对应的就是 AES-CBC + PKCS7 填充;如果看到 AES/ECB/PKCS5Padding,就是不要 IV 的 ECB 模式;如果看到 AES/GCM,多半得换个库或者让后端换成 CBC。
2.3 crypto-js 的 Parse 与 Stringify 是最大的坑
这是我想重点讲的内容。crypto-js 的使用其实分两层,一层是算法层,也就是 CryptoJS.AES.encrypt 这类方法;另一层是编码层,决定你传给算法的字符串到底怎么被理解。八成的前端联调失败,问题都出在编码层。
crypto-js 本身是拿 WordArray 这种数据结构来做运算的,一个 WordArray 可以看作是一串 32 位的整数字。你在用一段字符串当 key 或者明文时,必须先把字符串转成 WordArray。最常见的转换方式有这么几种:CryptoJS.enc.Utf8.parse()、CryptoJS.enc.Hex.parse()、CryptoJS.enc.Base64.parse()。
举个例子,假如后端给你一个 32 字节的 key,这个 key 是一个十六进制字符串,比如 “0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef”,你如果直接把它当字符串传给 CryptoJS.AES.encrypt,crypto-js 会把它当成 64 个字符的 UTF-8 数据,正好 64 字节,作为 AES-256 密钥显然就错了。正确做法是先用 CryptoJS.enc.Hex.parse() 把它解析成 32 字节的 WordArray。
反过来,加密结果也是一样。CryptoJS.AES.encrypt 返回的是一个 CipherParams 对象,你直接 toString() 得到的是 OpenSSL 格式的密文,包含了 salt 和 iv 的信息。但后端那边通常只要纯粹密文的 Base64,那你就得用 CryptoJS.enc.Base64.stringify(ciphertext.ciphertext) 去拿纯密文。
解密时对称的问题同样存在。你拿到后端返回的 Base64 密文,要先 CryptoJS.enc.Base64.parse() 成 WordArray,再传给 CryptoJS.AES.decrypt,最后把解出来的 WordArray 用 CryptoJS.enc.Utf8.stringify() 转回字符串。
我把这些关键转换点列成一个速查表,你写代码前先对照一遍:
| 操作 | 你手上有的是 | 你要做的转换 | 代码示例 |
|---|---|---|---|
| 构造 key | 字符串口令 | 按约定转 WordArray | CryptoJS.enc.Utf8.parse(key) |
| 构造 key | 十六进制字符串 | 按十六进制解析 | CryptoJS.enc.Hex.parse(keyHex) |
| 构造 key | Base64 字符串 | 按 Base64 解析 | CryptoJS.enc.Base64.parse(keyBase64) |
| 构造 IV | 16字节字符串/hex/Base64 | 同上 | CryptoJS.enc.Utf8.parse(iv) |
| 加密结果 | CipherParams 对象 | 取纯密文转 Base64 | CryptoJS.enc.Base64.stringify(ciphertext.ciphertext) |
| 解密输入 | Base64 密文 | 解析成 WordArray | CryptoJS.enc.Base64.parse(cipherBase64) |
| 解密结果 | WordArray | 转成明文字符串 | CryptoJS.enc.Utf8.stringify(decrypted) |
这一张表看明白,crypto-js 你已经通了八成。后面实战环节我会手把手把这套东西封成一个工具模块。
3. 实操过程与核心环节实现
3.1 安装与基础封装
先说安装。正常的前端工程,直接在项目根目录执行:
npm install crypto-js # 或者 yarn add crypto-js # 或者 pnpm add crypto-js如果你的项目还跑在老的 webpack 环境里,可能出现引入报错,这个时候用 import CryptoJS from 'crypto-js' 或者 require('crypto-js') 一般都能解决。有人在网上发过“crypto-js is not defined”的报错,多半是引入了但没挂到全局对象上,或者 script 标签引入了整个 dist 包但是内容和模块化环境冲突了。你按自己的打包工具选一种引入方式就行,别在配置文件里乱加全局挂载。
接着我给出一个可以直接复制到项目里用的 AES-CBC 加解密工具模块。这个模块是我在真实项目里的封装,做了一定简化但核心逻辑没动,你只要把 key 和 iv 的取值方式改成你项目里约定的即可。
import CryptoJS from 'crypto-js'; // 约定:AES-256-CBC,密钥为 32 字节,IV 为 16 字节 const SECRET_KEY = CryptoJS.enc.Utf8.parse('这里是你的32字节密钥字符串'); // 注意实际项目中不要写死在前端 const SECRET_IV = CryptoJS.enc.Utf8.parse('1234567890abcdef'); // 16字节IV /** * 加密方法:返回 Base64 字符串 * @param {string} plainText 明文 * @returns {string} 密文 */ export function encryptAES(plainText) { if (typeof plainText !== 'string' || plainText === '') { return ''; } const encrypted = CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(plainText), SECRET_KEY, { iv: SECRET_IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); // 这里取的是纯密文的 Base64,不是包含元数据的 OpenSSL 格式 return CryptoJS.enc.Base64.stringify(encrypted.ciphertext); }再看解密方法:
/** * 解密方法:从 Base64 密文还原明文 * @param {string} cipherTextBase64 密文 * @returns {string} 明文 */ export function decryptAES(cipherTextBase64) { if (typeof cipherTextBase64 !== 'string' || cipherTextBase64 === '') { return ''; } const decrypted = CryptoJS.AES.decrypt( { ciphertext: CryptoJS.enc.Base64.parse(cipherTextBase64) }, SECRET_KEY, { iv: SECRET_IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return CryptoJS.enc.Utf8.stringify(decrypted).toString(); }这里有几个细节我要重点解释。第一,加密的时候我传的明文是 CryptoJS.enc.Utf8.parse(plainText),而不是直接传 plainText。如果你直接传字符串,crypto-js 内部也会帮你做 UTF-8 解析,但显式转换会让代码意图更清晰,也避免后端那边对编码有微妙的差异。第二,解密的时候我传给 AES.decrypt 的是一个对象 { ciphertext: 解析出的 WordArray },而不是直接传 Base64 字符串。这样明确告诉 crypto-js 这个就是纯密文,而不是 OpenSSL 格式的字符串。这两种写法结果相同,但下面的写法排除了“密文里混着 salt/iv”的歧义。第三,mode 和 padding 我都显式写出来了,没有依赖默认值。这种手法在跨语言联调时能减少很多“我明明写的没错就是不对”的问题。
3.2 用 Java 后端对照验证参数
前端加解密永远脱离不了后端联调,所以我在这一节放一个非常典型的 Java 后端示例,方便你对照着看。很多前端同学看到 Java 代码就心理发怵,其实你只需要关注它用的算法名、填充方式、密钥和 IV 怎么来的,不用看懂整个类。
Java 后端常见的 AES-CBC 加密代码是这样的:
import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.util.Base64; public class AesUtil { private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; private static final String CHARSET = "UTF-8"; public static String encrypt(String plainText, String secretKey, String iv) throws Exception { SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(CHARSET), "AES"); IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes(CHARSET)); Cipher cipher = Cipher.getInstance(ALGORITHM); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted = cipher.doFinal(plainText.getBytes(CHARSET)); return Base64.getEncoder().encodeToString(encrypted); } public static String decrypt(String cipherText, String secretKey, String iv) throws Exception { SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(CHARSET), "AES"); IvParameterSpec ivSpec = new IvParameterSpec(iv.getBytes(CHARSET)); Cipher cipher = Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(decrypted, CHARSET); } }后端这里有几个关键点。密钥和 IV 都是 secretKey.getBytes() 和 iv.getBytes() 转过来的字节数组,如果你的前端传递的 key 是 Utf8.parse('32位的字符串'),那后端也要用同一个字符串再 getBytes() 才能对上。算法串写的是 AES/CBC/PKCS5Padding,对应前端 crypto-js 里的 CBC 模式 + Pkcs7 填充。这里有个很多人问的问题:PKCS5 和 PKCS7 不是不一样吗?对于块大小 16 字节的 AES 来说,PKCS5Padding 实际底层用的是 PKCS7 的一模一样的规则,所以在 Java 的 AES/CBC 场景里两者等价,前端用 Pkcs7 就能对齐。
我最早做联调的时候,卡在两个地方:一个是密文解密出来开头多了一堆乱码,后面才正常,那是因为前端把包含 salt 的 OpenSSL 格式整体传给了后端,而后端按纯密文解密;另一个是解密直接报“Given final block not properly padded”,这种通常是密钥或 IV 没对上,或者密文被截断了。
做个简单自测的办法:固定一个明文和密钥,先用前端加密,把结果 Base64 密文交给后端解密;后端解出的明文如果和原来一致,说明两端参数一致。反过来后端加密出的密文你用前端解密测试。一旦中线对齐,整个联调就稳了。
3.3 动态 IV 与消息结构设计
如果你在公司里负责和多个团队同时联调,或者要对接多个后端服务,会遇到一个更实际的问题:每个接口都要求同一个 IV 吗?IV 固定是不是不安全?
说实话,实际项目里很多团队图省事,确实把 IV 写死在前端代码里,密钥也是固定的。但稍微正规一点的系统,都会要求每次加密使用随机 IV,并把这个随机 IV 一起传给后端。理由很简单:如果 IV 固定,“相同明文产生相同密文”的问题就回来了,如果对方拿到大量密文,分析出原文的一些统计特征会容易很多。
前端如果要做随机 IV,加密流程就变成:生成一个 16 字节的随机字符串作为 IV,用这个 IV 去加密明文,然后把 IV 和纯密文一起传给后端。传的方式通常是在密文前面拼一段 IV,或者干脆放进请求参数里。我推荐后者,结构更清晰:
import CryptoJS from 'crypto-js'; // 生成 16 字节随机 IV,返回字符串 export function generateIv() { // 简易随机数方案,生产项目建议用 crypto.getRandomValues return CryptoJS.lib.WordArray.random(16).toString(CryptoJS.enc.Hex); } export function encryptAESWithRandomIv(plainText, secretKeyHex) { const key = CryptoJS.enc.Hex.parse(secretKeyHex); const iv = CryptoJS.lib.WordArray.random(16); const encrypted = CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(plainText), key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); const ivHex = iv.toString(CryptoJS.enc.Hex); const cipherTextBase64 = CryptoJS.enc.Base64.stringify(encrypted.ciphertext); // 把 IV 和密文都传给后端,后端按 base64 密文件 + hex IV 解析 return { iv: ivHex, cipherText: cipherTextBase64 }; }这里的核心思想是把“每次加密数据”时,把这次用到的随机因素(IV)显式交给接收方。后端拿到 iv 和 cipherText 之后,用同一个密钥、同一个 IV 去解密。注意:IV 是不需要加密的,它是公开的信息,可以被中间人看到,这和密钥不是一个层级的安全要求。
有一点要提醒你:上述方案里我用的是 CryptoJS.lib.WordArray.random(16),它底层用的还是 Math.random 或者浏览器的随机源,在密码学意义上不算强随机。如果项目安全评级高,建议换成 window.crypto.getRandomValues() 来生成 IV。我这个封装主要是演示思路,生产环境要按安全等级来调整。
3.4 模块化封装与 TypeScript 支持
在真实项目中,我一般不会只导出两个裸函数,而是会把工具方法封装到一个类或者命名空间下,统一管理密钥来源、IV 来源和日志。这里给个稍微工程化一点的思路。
比如我会建立一个 src/utils/crypto.ts:
import CryptoJS from 'crypto-js'; export interface CryptoOptions { mode?: 'CBC' | 'ECB'; padding?: 'Pkcs7' | 'NoPadding'; iv?: string; key: string; } export class AesCryptor { private key: CryptoJS.lib.WordArray; private iv?: CryptoJS.lib.WordArray; private mode: any; private padding: any; constructor(options: CryptoOptions) { this.key = CryptoJS.enc.Utf8.parse(options.key); if (options.iv) { this.iv = CryptoJS.enc.Utf8.parse(options.iv); } this.mode = options.mode === 'ECB' ? CryptoJS.mode.ECB : CryptoJS.mode.CBC; this.padding = options.padding === 'NoPadding' ? CryptoJS.pad.NoPadding : CryptoJS.pad.Pkcs7; } encrypt(plainText: string): string { const config: any = { mode: this.mode, padding: this.padding }; if (this.iv) { config.iv = this.iv; } const encrypted = CryptoJS.AES.encrypt(CryptoJS.enc.Utf8.parse(plainText), this.key, config); return CryptoJS.enc.Base64.stringify(encrypted.ciphertext); } decrypt(cipherText: string): string { const config: any = { mode: this.mode, padding: this.padding }; if (this.iv) { config.iv = this.iv; } const decrypted = CryptoJS.AES.decrypt( { ciphertext: CryptoJS.enc.Base64.parse(cipherText) }, this.key, config ); return CryptoJS.enc.Utf8.stringify(decrypted); } } export const aesCryptor = new AesCryptor({ key: import.meta.env.VITE_AES_KEY || '', iv: import.meta.env.VITE_AES_IV || '' });这个封装有个好处:key 和 iv 的取值从环境变量或者配置中心统一管理,不散落在业务代码里。虽然前端密钥无论如何都会暴露,但至少不会出现在每个页面文件的顶部,代码审查的时候也更清晰。
TypeScript 方面需要注意 crypto-js 有自己的类型声明,一般是内置的,如果你的项目报找不到模块,执行一下 npm install @types/crypto-js 就行。某些版本里 CryptoJS.mode.CBC 的类型推断可能比较弱,遇到类型报错可以用 any 处理,但别把整段代码都糊上 any,否则写成 TS 就没意义了。
4. 常见问题与排查技巧实录
4.1 解密报 malformed UTF-8 data 的原因与解决
这是我在知乎和论坛上被问得最多的一个报错。现象是解密函数不抛异常,但最后输出的字符串是空的,或者是一堆类似“�”的乱码。用 CryptoJS.enc.Utf8.stringify() 转换后如果遇到非法 UTF-8 序列,通常就表现为空字符串或乱码。
为什么会这样?几乎都是因为解出来的字节流本身就不对。可能原因有:这个项目用了非 UTF-8 编码的明文,比如 GBK;或者密文本身不是一个正确的 UTF-8 序列,中间被截断或篡改过;还有就是传入的 Base64 串不是真正的 AES 密文,而是包含了其他格式的数据。
我的排查方法很简单。先把密文解密后的 WordArray 转成 Hex 看一眼:
const parsed = CryptoJS.enc.Base64.parse(cipherTextBase64); const decrypted = CryptoJS.AES.decrypt({ ciphertext: parsed }, key, config); console.log(decrypted.toString(CryptoJS.enc.Hex));如果 Hex 是一串杂乱但长度正常的字节,那说明密钥、IV、填充都大概率没问题,只是编码出了问题。比如后端返回的是 GBK 加密、UTF-8 解密,那你前端想通过 crypto-js 解出来就得先和后端确认统一编码,或者想办法在解密后用 GBK 解码——但 crypto-js 本身不支持 GBK,这就需要借助其他解码库了。如果 Hex 里出现大段 0x00 或者反复重复的图案,则很可能密钥或 IV 没对齐。
4.2 同明文加密出不同密文,是 bug 还是特性
这个问题经常在测试同学那里被问傻了。测试说“我提交两次一模一样的表单,为什么接口里两个加密串长得不一样,是不是代码有随机行为?”其实只要你的 IV 是随机的,或者你用了 CBC 模式且 IV 每次不同,那这个现象就是设计如此。
如果测试非要两个密文完全一致才能对比,那你反而要警惕了——说明你用了 ECB 模式或者 IV 固定,这时候相同明文会产生相同密文,安全性指标上是减分的。
不过有一种情况例外:如果后端要求“对签名内容加密”,要求每次签名串稳定,那你前端就不能在加密环节引入随机 IV,否则后端每次验签都失败。这种情况一般建议改用固定 IV 或者干脆用哈希,而不是用随机 IV。你需要和后端确认清楚业务对“密文稳定性”有没有明确要求。
4.3 加密正常但解密结果和原文明文字节数对不上
有个很典型的“隐蔽坑”:前端加密明文 A,后端解出来变成 A 加了一串尾巴。举个例子,明文是“hello”,后端解出来是“hello”后面跟着 11 个空格或 11 个 \x0b 类似的控制字符。
这种情况几乎都是填充没去掉。Java 的 PKCS5Padding 解密后会自动去填充,但某些后端或者某些自定义实现只做了 AES 运算,没有做反填充。你要是引入一个工具类之后发现这个问题,先别急着改算法,直接跟后端确认解密后有没有做 unpad。如果是 Python 的 pycryptodome,有些模式需要你手动 unpad;如果是 Node 的 crypto 模块,默认也会要求你显式处理。
前端侧其实很少遇到这个问题,因为我们用 CryptoJS.enc.Utf8.stringify() 的时候,那些 0x0B 控制字符通常会被转成不可见字符,肉眼很难发现。要排查的话,可以对解密结果做一次 base64 或 hex 编码,看尾部是不是多了几个 0x0B。
4.4 常见报错与解决办法对照表
我把这些年见过的典型报错和对应解决方法整理成一个速查表,你可以直接贴到项目 wiki 里当排障手册。
| 报错或现象 | 根本原因 | 解决办法 |
|---|---|---|
| “Malformed UTF-8 data” | 解出来的字节不是合法 UTF-8 | 检查密钥、IV、编码是否和后端一致 |
| 解密结果为空字符串 | key 或 IV 类型不对,或密文解析失败 | 用 Hex 打印解密中间结果 |
| 尾部多出多余字符(\x0b) | 后端未做反填充 | 和后端确认 unpad 逻辑 |
| 相同明文产生相同密文 | 固定 IV / ECB 模式 | 改成随机 IV + CBC,除非业务要求稳定密文 |
| 两次解密结果完全不同 | 密钥字节长度不对 | 确认 AES-128/192/256 对应的字节数 |
| 密文 Base64 里带加号斜杠,请求 URL 解析失败 | Base64 的 URL 不安全字符 | 用 Base64URL 编码或 encodeURIComponent |
| 传输时密文被 URL 编码解码后大改变 | 特殊字符被转义 | 约定统一编码规则 |
| crypto-js 引入报错 | 模块化环境兼容问题 | 确认 import 方式与打包配置 |
这些坑我在真实项目里全部踩过一遍。尤其是“尾部多出字节”那次,我们前后端排查了一个下午,最后发现后端同学用的是 OpenSSL 命令行直接解密,根本没走 Java 的 Cipher.doFinal,keystore 也没有自动 unpad。类似的联调问题,大多数不是算法本身的问题,而是双方对“密文格式、编码方式、填充处理”三个层次的约定不一致。
5. 安全边界与进阶建议
5.1 前端密钥一定会暴露,这是宿命
谈到前端加解密,绕不开的灵魂拷问就是“密钥放前端安全吗?”答案是不安全,甚至可以说非常不安全。只要用户在浏览器里运行你的页面,他打开开发者工具、在 Sources 里搜索一下,你的密钥就暴露了。就算你做了代码压缩混淆,在关键词搜索面前也只能拖延几分钟。
但是,这不代表前端 AES 加解密没有存在价值。安全本来就是分层防御的。你做 AES 加密,至少把明文保护起来,防止网络抓包、防止服务器日志落地明文、防止数据库里的用户画像字段裸露。攻击者要拿到密钥还得翻你前端代码,这本身就是一层成本。
我在实际项目里会把前端加解密定位成“第一层防线”,配合 HTTPS 使用。HTTPS 解决的是传输过程中被第三方窃听或篡改的问题;前端 AES 解决的是应用层数据被采集、日志被日志平台导出、密文可能被静态分析等情况下的明文泄露问题。两层组合,覆盖的场景比单独用 HTTPS 更多。
5.2 密钥分发和动态密钥交换的工程化思路
真正想提高前端加密的安全性,核心不是“隐藏密钥”,而是“让每次会话的密钥动态化”。常见的做法是采用类似“握手”的流程:前端先请求后端获取一个临时密钥,这个密钥只在一段时间内有效,过期后重新获取。前端拿到这个临时密钥后再用它来做 AES 加密。
但前端请求临时密钥这个过程本身也需要保护,否则攻击者同样可以模拟。所以更完整的方案是配合非对称加密:前端生成一个随机 AES 密钥,用后端公钥(RSA)加密这个 AES 密钥,然后传给后端,后端用私钥解密出 AES 密钥,后续会话都用这个随机 AES 密钥加密通信。这样即使攻击者抓包拿到了加密后的 AES 密钥,也无法解开 RSA 密文,自然拿不到真正的 AES 密钥。
这套“RSA 加密 AES 密钥 + AES 加密业务数据”的混合加密方案,在前后端联调里偶尔会用到,但不是日常标配。它的问题是实现复杂度高、性能开销大、出错排查困难。除非你的项目有明确的合规要求或者安全审计要求,否则我建议先用“HTTPS + 固定密钥 + 敏感字段加密 + 弱随机 IV”把基础打牢,再考虑混合加密。
5.3 增强安全性的几个低成本手段
如果你的项目暂时不需要上混合加密,但你又想在现有 crypto-js 方案上稍微提升一点安全性,我有几个低成本手段可以分享。
第一个是给密钥做混淆。不要在前端代码里明文写“SECRET_KEY = 'abcdef123456'”这种东西,至少可以把它拆成几段字符串,运行时拼接,再做一个简单的编码转换。这个手段不能真正防御高手,但能阻止绝大多数复制粘贴的“脚本小子”。
第二个是敏感数据不落 localStorage。如果你用 AES 加密后把用户手机号、身份证号存到了 localStorage,虽然在某种程度上是密文存储,但攻击者拿到密钥后照样能解。我的建议是能不在前端存储就尽量不存,尤其是身份类信息。真要存,也建议优先使用 httpOnly 的 Cookie,避免 JS 直接读取。
第三个是日志脱敏。前端打印日志、上报监控时,要过滤掉加密前的明文。我见过太多项目加密做得好好的,结果 console.log 里直接打印了加密前的手机号,监控系统一接入,等于所有加密全部白做。这是一个常见的低级失误。
第四个是定期更新密钥和审查调用权限。如果你的前端项目有多个页面在用同一个 AES 密钥,一旦某个页面被恶意注入脚本,所有页面都会受影响。这背后其实牵涉到前端供应链安全问题,不仅是加解密本身了。
5.4 面试中常问的前端加解密衍生问题
这个主题是前端面试里出现频率比较高的考点,毕竟“前端面试题 2026”这类关键词最近非常火。我总结下面试里常被追问的几个方向,你如果是在准备面试,可以顺手把这些点都捋一遍。
第一个问题通常是:“前端为什么要做加密?你做过什么加密方案?”这个要结合业务场景回答,不要只背概念。比如可以说“我们项目里有用户手机号脱敏需求,接口传输时用 AES-CBC 加密,密钥是后端分发,部分接口用 RSA 做密钥协商”,这样比背一堆加密算法名更有画面感。
第二个问题是“对称加密和非对称加密的区别是什么,如何选型?”常考点是 AES 对称加密快但密钥分发难,RSA 非对称加密安全但速度慢,所以才有混合加密。要能说清楚 RSA 加密长度限制和为什么它不适合大批量数据。
第三个问题是“AES 加密的 key、iv、mode、padding 分别是什么?”这属于基础八股文,但很多人答不全。能把 IV 的作用、PKCS7 填充规则、CBC 和 ECB 的差异完整说清楚,面试官一般会满意。
第四个问题是“前端写的加密是不是安全的?密钥暴露了怎么办?”考的是工程思维。要表达“前端加密是分层防御的一环,不解决安全问题,只解决明文泄露问题”这个认知,然后能提出 HTTPS、密钥轮换、动态密钥、混合加密等改进方案。这个题答得好不好,往往能拉开档次。
6. 跨端场景与 CORS/请求传递细节
6.1 加密参数放进 GET 请求时的注意事项
实际开发里,有一次我把加密结果直接拼在 URL 查询参数里去做 GET 请求,结果后端一直说解密失败。排查到最后发现 Base64 密文里面有加号“+”,而 URL 解析的时候把加号解码成了空格。
这个问题不算 crypto-js 的问题,但特别常见。解决办法有两种:一是前端拼 URL 之前先对密文做 encodeURIComponent 编码,把加号和斜杠都转义掉;二是约定用 Base64URL 格式,也就是把 + 换成 -,把 / 换成 _,去掉末尾的 =。Base64URL 这个格式在很多 RESTful API 签名场景里是标准做法,如果你和后端合作频繁,建议直接约定用这个。
我这里给一段简单的 Base64URL 转换代码:
export function base64ToBase64URL(base64Str) { return base64Str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } export function base64URLToBase64(base64URLStr) { let base64 = base64URLStr.replace(/-/g, '+').replace(/_/g, '/'); while (base64.length % 4 !== 0) { base64 += '='; } return base64; }这样加密后的数据放到 URL 上就不会踩特殊字符的坑。
6.2 微前端环境下怎么统一管理加密模块
现在不少公司上了微前端架构,比如 qiankun 或者 wujie,各个子应用都要用到加解密工具。如果每个子应用各自装一份 crypto-js、写一套封装,密钥管理就乱了,万一某个子应用改了密钥,其他人不知道,线上联调直接炸。
我的建议是单独把一个 @shared/crypto 的包抽出来,作为公共依赖发布到内部 npm 仓库,所有子应用统一引用。密钥从全局配置中心下发,子应用启动时读取,关键调用加日志。这样至少能做到“同一个业务背后只有一套加密逻辑”,排查问题的时候不用在几十个子应用里翻来翻去。
当然,这样做的代价是公共包的升级会影响所有子应用。所以公共包里面尽量少放业务逻辑,只放加密、解密、密钥获取这些基础设施,保持接口稳定。
6.3 与 Web Worker 结合加密大数据的思路
如果你要加密的数据量很大,比如上传导出的大文件,前端主线程直接跑 crypto-js 的 AES 加密会卡住页面,这时候可以考虑把加密逻辑丢到 Web Worker 里。
思路很简单:主线程把文件的 ArrayBuffer 或 Blob 交给 Worker,Worker 里引入 crypto-js 按块加密,加密完成后把结果传回主线程。crypto-js 处理二进制数据时需要把 ArrayBuffer 转成 WordArray,处理完再转回来。但 crypto-js 的 WordArray 操作大文件时性能一般,如果真到了大文件加密这个级别,我更建议用 Web Crypto API 的 SubtleCrypto,它对 ArrayBuffer 的原生支持更好,性能也明显更强。
crypto-js 更适合“小数据量、需要和后端保持同一实现”的场景。大文件加密属于另一个赛道,这里不展开,只是提醒你选型的时候别搞混。
写在最后的个人实操心得
回头看,前端 AES 加解密这个主题,技术栈本身并不复杂,复杂的是前后端约定的一致性。我在实际项目里被坑得最多的永远不是算法本身,而是 key 的编码方式、IV 是否要带、密文是 Base64 还是 OpenSSL 格式、PKCS5 和 PKCS7 到底怎么对齐——这些全是“人跟人之间沟通的问题”。
我做联调的时候现在有一个习惯:先自己写一个“十字测试”,同一份明文、同一个密钥,前端加密后交给后端解,后端加密后交给前端解,四个方向全部对齐了才开始写业务代码。这个习惯至少帮我省了十几次加班排查的时间。
最后分享一个小技巧:调试加密代码时,别拿中文长文本试,先用一个 8 字节以内的英文短字符串,比如 “test123”,跑通一遍再切换成真实数据。因为英文字符在 UTF-8 下是单字节,落进分组里一眼能看出边界在哪儿;中文是三个字节一组,出错时你很难分辨是编码问题还是分组问题。
希望这篇东西能让你少踩一些坑。如果你在联调中也遇到过特别奇葩的加解密问题,欢迎在评论区里分享出来,我真的很想知道还有哪些坑是我没踩过的。