做前端时间长了,你就会发现一个很现实的问题:接口返回的敏感数据,比如用户手机号、身份证、订单金额,一旦在传输层被拦截,基本就是明文裸奔。虽然业内默认 HTTPS 是标配,但标配不等于万能,尤其是内网系统、第三方回调、网关日志这类场景,很多团队会额外约定在应用层再做一层加密。我最近在维护一个管理后台项目时,就把前端接口请求和响应的加解密统一收拢到底层封装里,用的就是 crypto-js 这个库做 AES 加解密,整体落地效果很稳。
这篇内容不是纯理论,是我把 AES 加解密完整封装进前端项目后的一套实践沉淀。适合正在做前端安全加固、需要对接后端加密接口、或者面试被问到“前端怎么做加密”时不知道怎么答的朋友。看完你可以直接拿走封装代码,也能搞清楚每个参数为什么这么设置,后端对接时哪些细节最容易踩坑。
1. 先搞清楚:前端做 AES 加密到底在防什么
1.1 我们防的不是“用户”,而是“链路”和“日志”
很多刚入行的同学一听到前端加密,第一反应是“这不是脱裤子放屁吗?密钥就在前端代码里,用户一翻就能看到”。这个质疑有一定道理,但是混淆了威胁模型。
前端做 AES 加密,防的不是一个拿着 DevTools 盯着你看的极客用户,而是防三类很常见但不那么“高深”的问题:
- 防明文出现在网关日志和 Nginx access log 里。很多团队把请求体打到日志平台做排障,一旦接口字段里有手机号、身份证这种敏感信息,日志泄露等于数据泄露。加密后日志里只有一串 Base64,敏感数据至少不会以明文形式落盘。
- 防止第三方渠道抓包后直接“抄作业”。在一些 B 端系统里,请求结构被爬虫抓下来之后,别人不需要破解你的业务,只需要照着参数格式重放一次就能薅数据。加密后,重放的成本高很多。
- 防止公共 WiFi 或者内网镜像端口下的被动嗅探。虽然 HTTPS 能解决大部分问题,但总有些历史系统证书配置不规范、内网 HTTP 服务没收敛,应用层加密是一道额外的保险,不是替代品。
理解了这三个目标,你就能明白:前端加密不是要做成“绝对无法破解”,而是要把破解成本和数据价值拉到不成比例,让攻击者觉得不划算。这个定位很重要,否则你会在密钥管理上陷入无休止的内耗。
1.2 为什么选 crypto-js 而不是 Web Crypto API 或自研
选型的时候,我对比过三条路:浏览器自带的 Web Crypto API、自研加密函数、crypto-js。
Web Crypto API 性能好、原生支持、没有依赖体积,但它的 API 设计偏底层,异步 Promise 满天飞,而且不同浏览器的实现细节有差异,老项目要兼容 IE 的话直接 pass。另外 Web Crypto 的 GCM 模式虽然安全,但封装成本对普通业务项目来说略高。
自研加密函数这个选项我直接排除了。AES 加解密涉及密钥扩展、S 盒替换、列混合这一整套分组密码学,自己写既容易出致命漏洞,又浪费时间。我见过有人为了“省一个依赖”自己手写了一个异或加密,结果把用户手机号编码成 Base64 就当加密了,这种属于自己骗自己。
crypto-js 的优势很明显:API 简单、同步执行、体积小(压缩后大概 40KB 左右)、支持多种算法,而且对老浏览器兼容性好。虽然它的维护节奏不如原生 API 积极,但 AES 加解密这种底层标准算法已经被验证得非常充分,用起来很放心。对普通后台管理系统和业务前端来说,crypto-js 是性价比最高的选择。
1.3 AES 要懂的三个核心参数:密钥、IV、模式
AES 本身是一个分组加密算法,但实际使用中,你还需要组合几个参数,任何一个对不上,后端就解不开。这也是前端和后端联调时最容易扯皮的地方。
第一个是密钥长度。AES 支持 128 位、192 位、256 位三种密钥长度,对应 16 字节、24 字节、32 字节。现在主流推荐 AES-256,也就是 32 字节密钥。不要为了“省事”用 8 位数字当密钥,AES 的密钥必须按字节长度严格对齐。
第二个是分组模式。crypto-js 里常用的是 ECB 和 CBC。ECB 模式相同明文会得到相同密文,容易泄漏数据模式,能不用就别用。CBC 模式引入了 IV(初始化向量),相同明文配合不同 IV 会得到不同密文,安全性更好,是默认的实践选择。如果后端要求更高级的 GCM 模式,crypto-js 原生不支持,需要扩展或走 Web Crypto API,这个后面单独说。
第三个是填充方式。AES 分组长度 16 字节,明文最后一组不足 16 字节时需要填充。crypto-js 默认用 Pkcs7,而 Java 的 PKCS5Padding 和 PKCS7 在 AES 场景下是同一个东西,这块后端小伙伴通常会写成AES/CBC/PKCS5Padding,前端对应CBC + Pkcs7就行。
2. 动手前先把环境和参数备齐
2.1 安装与模块化引入(Vue3 / React / 原生都适用)
先装依赖,一条命令:
npm install crypto-js # 如果你用的是 yarn # yarn add crypto-js装完之后,在 Vue3 或 React 项目里建议单独建一个src/utils/crypto.js,不要在每个页面里散落引入 CryptoJS,这样后续换算法或换库时只需要改一个文件。
crypto-js 支持两种引入方式,按需导入和全量导入:
// 按需引入,只引入 AES 相关模块,打包体积更小 import CryptoJS from 'crypto-js/core' import 'crypto-js/cipher-core' import 'crypto-js/aes' import 'crypto-js/mode-cbc' import 'crypto-js/pad-pkcs7' // 或者简单粗暴,直接全量引入 // import CryptoJS from 'crypto-js'如果你用的是 Vite 或 Webpack,全量引入也不会有什么问题,就是打包体积会多几十 KB。对于对包体积敏感的项目,推荐按需引入。但我个人在实际项目中,一般直接全量引入,因为 crypto-js 的 Tree Shaking 在部分构建工具里并不总是生效,按需引用的路径有时候反而会踩坑,得不偿失。
2.2 密钥和 IV 怎么定,才不会被后端当“外行”
这是前后端联调时最容易“掐架”的地方。密钥和 IV 本质上是一串字节序列,但大家写代码时的表达方式五花八门,关于怎么统一,我总结了一个能用的方案:
- 密钥由后端生成一个 32 字节的字符串,推荐用 Base64 或 Hex 格式传输给前端。你拿到的可能是
a2V5LWZvci1mcm9udGVuZC0yMDI0LTEy这样的字符串,也可能是6d797365637265742d6b65792d616573323536这种十六进制串。 - 无论后端给你哪种格式,在前端拿到之后,都要用 CryptoJS 的 enc 方法先转成 WordArray 再传给 AES 函数。这一点非常重要,因为 CryptoJS.AES.encrypt 的第一个参数如果直接传普通字符串,CryptoJS 会先对密钥做一次 MD5 散列处理,生成一个固定长度的 Key,这会让你的密钥被悄悄“改掉”,而后端不知道这个逻辑,解密必然失败。
- IV 固定 16 字节,一般由前后端约定一个固定字符串,或者每次请求随机生成后随密文一起传到后端。前者简单,后者更安全。考虑到调试方便,我通常建议固定 IV,业务上如果对安全性要求更高,再升级为动态 IV。
下面是一份可以直接对齐的示例参数:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 算法 | AES-256 | 密钥 32 字节 |
| 模式 | CBC | 需要 16 字节 IV |
| 填充 | Pkcs7 | 对应 Java 的 PKCS5Padding |
| 密钥编码 | Utf8 / Hex / Base64 | 前后端必须一致 |
| 输出编码 | Base64 | 方便 JSON 传输 |
2.3 后端配合的事项:先对齐协议再写代码
我踩过一个比较深的坑:前端加密做完了,后端 Java 接口怎么都解不出正确明文,最后排查了两小时,发现是后端用getBytes()获取密钥字节时,默认字符集和前端约定不一致。所以,写代码之前一定先把协议对齐,不要上来就闷头写。
需要对齐的点有这几个:
- 字符集统一用 UTF-8,不要用平台默认字符集。
- 密钥和 IV 的格式要明确,是用字符串还是 Base64,后端解密时用同一个 decode 方法。
- 密文传输格式统一成 Base64,后端拿到的字符串里不能有多余的换行符或空格。
- 填充算法对齐:前端 Pkcs7 对应 Java PKCS5Padding,名字不同,实际是同一个机制。
- 加密数据的范围要约定清楚:是整个 body 加密,还是只加密里面的某几个字段。这会影响前端的封装方式和后端的解析逻辑。
建议直接写进接口文档的“安全约定”章节,两端照着同一份文档开发,能少吵很多架。
3. 核心实现:一套可以直接抄的加解密封装
3.1 加密函数封装(含对象序列化与 Base64 输出)
我一般在项目里封装一个encrypt.js,对外只暴露 encrypt 和 decrypt 两个方法,业务代码不感知加密细节。加密函数的完整实现如下:
import CryptoJS from 'crypto-js' // 密钥和 IV 从环境变量读取 // Vite 项目使用 import.meta.env.VITE_APP_CRYPTO_KEY // Vue CLI / Webpack 项目使用 process.env.VUE_APP_CRYPTO_KEY const KEY = CryptoJS.enc.Utf8.parse('请从环境变量读取32字节密钥字符串') const IV = CryptoJS.enc.Utf8.parse('16字节固定IV值') /** * AES-CBC-Pkcs7 加密 * @param {*} data 支持任意 JSON 可序列化数据 * @returns {string} Base64 密文 */ export function aesEncrypt(data) { // 统一转成 JSON 字符串,避免传对象时出现 [object Object] const payload = typeof data === 'string' ? data : JSON.stringify(data) const encrypted = CryptoJS.AES.encrypt(payload, KEY, { iv: IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) return encrypted.toString() }这里有几个细节值得展开。toString() 方法返回的是 OpenSSL 格式的 Base64 字符串,其中包含加密数据本身,可以直接传给后端。后端收到后一般会用 Base64 decode,然后取出密文部分来做 AES 解密,所以前端不用额外拼接 Salt 等元信息。
另一个细节是序列化时机。我见过一些代码直接把对象传给 CryptoJS.AES.encrypt,虽然 CryptoJS 内部会尝试把对象转成字符串,但嵌套对象或者带特殊符号的内容很容易搞出怪问题。统一JSON.stringify之后再加密,最大的好处是后端解密后可以直接用 JSON.parse 还原成对象,少一层沟通成本。
3.2 解密函数封装(含异常兜底与空值处理)
解密函数比加密函数更需要做防御性处理,因为加密是自己写的,解密要面对的是后端返回的各种意外状况。我的实现是这样的:
/** * AES-CBC-Pkcs7 解密 * @param {string} ciphertext Base64 密文 * @returns {*} 解密后 JSON 解析的结果 */ export function aesDecrypt(ciphertext) { if (!ciphertext) return null try { const bytes = CryptoJS.AES.decrypt(ciphertext, KEY, { iv: IV, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) const result = bytes.toString(CryptoJS.enc.Utf8) if (!result) { console.warn('解密结果为空,请检查密钥或密文是否有效') return null } // 如果明文是 JSON 结构,转成对象;不是则直接返回字符串 try { return JSON.parse(result) } catch (e) { return result } } catch (error) { console.error('解密失败:', error) return null } }解密失败的时候,CryptoJS.AES.decrypt 不一定会抛异常,更多时候是返回一个空对象,toString 出来是空字符串。如果发现解密结果是空的,第一反应应该是去核对密钥、IV、密文三者的格式是否一致,而不是怀疑加密算法写错了。
JSON.parse的双层 try 结构也是一个小技巧。后端有些响应字段本身就是普通字符串,强行 JSON.parse 会抛错,所以第一次解析失败时直接返回原始字符串,这样调用方不用关心底层数据形态。
3.3 与 Java 后端的互通验证全流程
前端封装写得再好,如果和后端对不上,一切白搭。我整理了一个完整的前后端互通方案,两边照着写就能跑通。
后端 Java 核心代码大致是这样:
import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Base64; public class AesUtil { // 与前端的 KEY_STRING 保持一致 private static final String KEY_STRING = "32字节的密钥字符串"; private static final String IV_STRING = "16字节的IV字符串"; public static String encrypt(String plainText) throws Exception { SecretKeySpec keySpec = new SecretKeySpec( KEY_STRING.getBytes(StandardCharsets.UTF_8), "AES"); IvParameterSpec ivSpec = new IvParameterSpec( IV_STRING.getBytes(StandardCharsets.UTF_8)); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(encrypted); } public static String decrypt(String cipherText) throws Exception { SecretKeySpec keySpec = new SecretKeySpec( KEY_STRING.getBytes(StandardCharsets.UTF_8), "AES"); IvParameterSpec ivSpec = new IvParameterSpec( IV_STRING.getBytes(StandardCharsets.UTF_8)); Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding"); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText)); return new String(decrypted, StandardCharsets.UTF_8); } }前端对应:
const key = CryptoJS.enc.Utf8.parse('32字节的密钥字符串') const iv = CryptoJS.enc.Utf8.parse('16字节的IV字符串') // 加密 "hello world" const ciphertext = CryptoJS.AES.encrypt('hello world', key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }).toString() // 解密后应该得到 "hello world" const bytes = CryptoJS.AES.decrypt(ciphertext, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) const plaintext = bytes.toString(CryptoJS.enc.Utf8)很多前端在联调时反复失败,最大的原因就是忽略了CryptoJS.enc.Utf8.parse。如果直接把密钥字符串传进 AES 方法,CryptoJS 会自作主张对密钥做 MD5 派生,而后端完全没有这个机制。所以我在代码里特意把 parse 这一步拎出来,就是为了防止这种隐蔽的坑。
4. 上线后踩过的坑:几个高频问题实录
4.1 解密出来全是空字符串,大概率是 IV 没对齐
这个可以说是 AES 联调中排名第一的经典问题。前端明明加密成功,后端也能拿到密文,但解密结果就是空字符串。我排查了三次这类问题,最后定位到的基本都是 IV 或者密钥的字节没对齐。
比如前端定义 IV 是"0123456789abcdef",后端同学可能图省事直接IV = "1234567890abcdef",一个字符的差异就会导致解密结果乱码或为空。更隐蔽的是,有些后端代码用了CryptoJS.enc.Base64.parse()来解析密钥,而前端用的是Utf8.parse(),两边字节内容完全不同,但代码看起来还都是同一个字符串。
排查建议是:先分别在前端和后端打印密钥和 IV 的十六进制字节内容,人工比对。如果字节对不上,不要再看代码逻辑,直接协商统一格式。
4.2 中文乱码,问题出在字符串编码
加密英文和数字没什么问题,一遇到中文就解密成火星文,这是字符集没有统一的典型症状。前端JSON.stringify出来的字符串是 UTF-8 编码,后端如果用 GBK 或其他字符集来 getBytes,就会造成乱码。
解决方式没有悬念:前后端统一使用 UTF-8。前端在序列化和解析时明确使用 UTF-8 编码,后端拿到密文后,解密出的字节流用new String(decrypted, StandardCharsets.UTF_8)转换,而不是用服务器默认字符集。
另外提醒一下,有些 HTTP 框架会在响应头里自动做字符集转换,如果网关层出现过转码,密文被改动了,也会导致乱码。这时候可以在后端接口加一个针对加密字段的 Base64 解码日志,看密文实时到达网关时是否和前端发送时一致。
4.3 场景提醒:不要把 Token 塞进明文里一起加密
有一个安全问题值得单独提出来。有些团队为了省事,把登录 Token、用户 ID、时间戳等全部塞进加密明文里交给后端。这种做法的风险在于:一旦密钥泄露,Token 跟着一起泄露,前面所有的加密工作都白费了。
更好的拆分方式是:Token 放在 HTTP Header 里走标准鉴权流程,AES 加密只负责保护 body 中的业务数据。这样做一方面符合前后端职责分离的惯例,另一方面也方便后端在网关上做统一的 Token 校验,不需要先解密 body 才能鉴权,性能更好。
4.4 面试被追问的:前端加密是不是“脱裤子放屁”
最后聊一个技术之外但很容易被问到的问题。前端密钥暴露在代码里,所以前端加密不是“绝对安全”,这种说法对不对?对,但不完整。
加密的本质是提高攻击成本。如果你的系统里,敏感数据可能以明文形式出现在日志、抓包工具、浏览器插件里,那么 AES 加密至少能让这些常见泄露面变得无内容可看。真正的安全体系应该包括 HTTPS、权限控制、服务端加密存储、风控策略等多层防护,前端加密是其中一层,而不是全部。
我个人的体会是:不要神话前端加密,也不要不屑于做前端加密。了解它的边界,把它用在合适的位置,比如敏感字段加密、防明文落日志、防参数篡改,这就是它存在的价值。
5. 还可以再进一步:动态 IV 和 GCM 模式
5.1 固定 IV 够用,但动态 IV 更稳
前面的封装里,我用的固定 IV。固定 IV 的优点是简单、易于排查问题,但同一个密钥配合固定 IV 加密,相同明文会产生相同密文,这在某些数据模式比较固定的场景中会暴露信息。
如果你对安全性要求更高,可以采用动态 IV 方案:每次加密前生成一个随机的 16 字节 IV,把 IV 拼接在密文前面,然后一起 Base64 编码发送给后端。后端取出前 16 字节作为 IV,剩下的部分作为密文去解密。这样相同的明文每次都生成不同的密文,安全性提升一个档次。
前端实现思路参考:
export function aesEncryptWithRandomIV(data) { const payload = typeof data === 'string' ? data : JSON.stringify(data) // 生成随机 16 字节 IV const randomIv = CryptoJS.lib.WordArray.random(16) const encrypted = CryptoJS.AES.encrypt(payload, KEY, { iv: randomIv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) // 把 IV 和密文拼一起,统一 Base64 const combined = randomIv.concat(encrypted.ciphertext) return CryptoJS.enc.Base64.stringify(combined) }后端对应处理:Base64 解码后,前 16 字节是 IV,后面是密文。这种方案的缺点是需要后端多写一段拆分逻辑,但只要习惯了,收益很明显。
5.2 后端要求 GCM 模式时怎么办
有些安全团队会强制要求使用 AES-GCM 模式,因为它自带完整性校验,能防止密文被篡改。但 crypto-js 没有内置 GCM 模式,这时候有两个选择:
一是使用 Web Crypto API。浏览器原生支持 AES-GCM,性能比 crypto-js 更好,但需要注意它是异步的。对于不支持 Web Crypto API 的老浏览器,要做降级方案或直接拦截。
二是使用@stablelib/aes-gcm这类第三方库。它的体积更小,API 设计也比较现代。不过需要后端的解密逻辑配合,两边都要实现 GCM 模式的认证标签(auth tag)处理。
我的建议是:如果项目不强制要求 GCM,CBC 模式加固定 IV 已经能满足绝大多数业务场景。如果一定要 GCM,优先考虑 Web Crypto API,因为它是浏览器标准能力,未来兼容性更有保障。
写在最后的几点实操心得
把 crypto-js 的 AES 加解密落地到项目里,不复杂,但坑都在细节里。我踩过几次坑之后,现在总结了一套自己的习惯:密钥和 IV 统一从环境变量读取,不写死在代码里;加解密封装成独立工具函数,业务层只调用 encrypt 和 decrypt;项目启动时先写一个自测用例,加密一段固定字符串,解密回来能对得上,再继续做其他功能。这种做法每次新建项目时都能节省大量联调时间。
另外一个小技巧是,在开发环境加一个日志开关,把加密前的明文和解密后的明文都打印出来。正式环境关掉,开发环境开着。一旦后端说解密失败,你能立刻判断问题出在发送端还是接收端,定位速度快很多。
前端加密不是银弹,但它确实能在很多实际场景中发挥作用。希望这篇文章能让你少走一些弯路,把时间留给更有价值的事情。