恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
前后端AES加密实战:敏感字段加密与解密方案全解析
首页
资讯中心
/
前后端AES加密实战:敏感字段加密与解密方案全解析
前后端AES加密实战:敏感字段加密与解密方案全解析
发布时间:2026/10/19 14:49:46
这次的项目需求就是在一个前后端分离的系统里把登录密码、手机号这类敏感字段用AES加密之后再传给后端接口后端拿到密文再解密整个实战过程我踩了不少坑也整理出了一套可以直接复用的代码。简单说就是把此前明文提交的敏感字段改成前端先加密、后端再解密的模式。不管你是刚接触加密的新手还是正在为“密码不能明文进接口”这种安全需求发愁这篇都值得看完再动手。事情源于我们内部的一次安全自查。管理后台的登录接口被指出存在风险请求体里的密码字段是明文虽然系统早就上了HTTPS可检查方明确指出“链路加密不代表应用层安全”。一开始我也觉得这是在挑刺但把落日志、网关抓包、问题排查这几个场景一摆就明白了——敏感字段在到达业务代码之前经过的中间环节太多了。今天这篇文章我把完整的参数约定、前端加密代码、Java后端解密代码、三端对齐验证方法以及联调时踩过的坑全部整理出来给同样被这个需求卡住的朋友作参考。1. 为什么前后端要自己做AES加密1.1 需求现场一条安全整改项引发的改造我们管理后台的登录请求原来长这样前端把用户名和密码塞进表单POST 到后端后端拿到后校验。整条链路有 HTTPS 保护浏览器到服务器之间的网络传输部分是加密的。但问题出在请求到达服务器之后。服务端项目普遍有访问日志、操作日志、链路追踪。当时我们排查发现如果日志框架把请求体完整打出来密码字段就是明晃晃的明文。抓包工具在网络层看不到密码但服务端一解包日志一记录密码就暴露了。还有网关、消息队列、数据同步工具只要中间某一环把请求内容打印了明文就会落到日志文件里而日志文件的访问权限往往比数据库宽得多。安全整改项的核心诉求就一句话敏感字段在请求体里不能是明文。这跟链路层是否加密没关系要求的是应用层再给数据加一层保护。1.2 应用层加密和 HTTPS 到底有什么不同拿快递打个比方。HTTPS 保证的是“包裹在运输途中不被拆开”从发出到接收中间人看到的是密封的包裹。但包裹到了驿站、仓库、门卫手里里面装的是什么链路上并不关心。应用层 AES 加密相当于在包裹里面又加了一道锁即使某个中间环节把箱子打开了看到的也是一堆密文拿不到真正的敏感信息。具体落到实现上就是前端在用户输入密码之后、把数据放进请求体之前先调用加密函数把“123456”变成一串类似F79q2cOd...的 Base64 字符串。后端接口收到这串密文后先调用解密工具类还原成明文再走原来业务逻辑。这样做的好处是日志里记录的、网关里转发的、第三方系统里落地的全都是密文明文只存在于前端用户输入的那一瞬间和后端业务处理的内存里。这个思路不只是密码能用身份证号、手机号、银行卡号、订单金额凡是需要在接口里传输的敏感字段都可以套同一套方案。1.3 对称加密选型AES 为什么是首选选定加密算法时我对比过几条路线。DES 和 3DES 属于上一个时代的产物。密钥长度短、运算性能一般行业安全标准里早把这类算法标记为不推荐。3DES 虽然比 DES 强但块大小、运算效率都不如 AES新项目完全没有理由再选它。RSA 是非对称加密公钥加密、私钥解密安全性没问题但用在接口加解密上有两个短板。一是性能比对称加密慢几个量级同样的数据量AES 加密耗时几乎可以忽略RSA 有明显的计算开销。二是明文长度受密钥长度限制比如 1024 位的 RSA 密钥一次最多只能加密 117 字节稍微长一点的业务字段就没法直接用。AES 是对称加密128 位密钥的强度已经足够加解密速度极快浏览器、Java、Python、Go 都有现成的实现几乎成了“前后端敏感字段加密”这个场景的标准答案。所以最后方案没有悬念算法选 AES接下来只需要把模式、填充、密钥长度这些细节定清楚。2. 方案整体设计与参数约定2.1 架构流程设计动手写代码之前我先把整个流程画了一遍前端封装统一的加密工具函数在提交表单前把加密结果放入请求体。后端接口收到密文字段调用工具类解密还原出明文后走原有校验逻辑。加密解密逻辑集中在各自端的工具类里业务代码不感知加解密细节。把加解密集中在工具类而不是散落在业务代码里是为了后续好维护。比如某天要换密钥、换加密模式只在工具类里改一行所有业务接口同时生效。如果每个接口都自己写一遍 AES 调用那后续排查和升级都是灾难。后端我再强调一个设计点解密动作尽量不要散在 Controller 里。更优雅的做法是在参数解析层统一处理比如自定义注解配合拦截器或者直接在一个解密服务里处理。我们团队当时没有选太重的方案因为涉及改造的接口就那几个集中在工具类里调就够了。接口数量多的话还是建议做一个切面统一解密。2.2 模式选型CBC 有它的理由AES 本身是分组加密算法一次处理 16 字节。多个分组怎么串联就是模式的事。我当时在 ECB、CBC、GCM 之间权衡了一下。ECB 模式最简单每个分组独立加密相同明文块永远得到相同密文块。问题就出在这个“相同”上数据规律性会反映到密文里攻击者可以通过观察重复块来猜测明文结构。比如加密一张带大色块的图片ECB 模式的密文肉眼就能看出图片轮廓。这种模式在安全性要求高的场景是不可接受的。CBC 模式引入了 IV每个明文分组先跟上一个密文分组异或再执行加密。第一个分组跟 IV 异或。只要 IV 不同相同明文加密出来的密文就完全不同规律性被打破。这是当时我们选它的主要原因。我也认真考虑过 GCM。GCM 是目前推荐度更高的模式自带认证能力密文被篡改后能发现。但当时前端用的是 CryptoJSGCM 在 CryptoJS 里的认证标签处理比较绕需要多写不少代码。考虑到团队熟悉度和整体改造成本老项目选择了 CBC。新项目我强烈建议直接上 GCM这个放到最后再细说。2.3 密钥、IV、填充最容易埋雷的“三件套”约定前后端能成功加解密秘密不在算法本身而在参数完全对齐。我把整张参数约定表贴在下面这条表就是我这次实战里最重要的产出参数约定值说明算法AES对称加密密钥长度128 位密钥字符串正好是 16 字节工作模式CBC需要 IV同一个明文在不同 IV 下密文不同填充前端 Pkcs7 / 后端 PKCS5PaddingAES 块大小为 16 字节二者实际等价明文编码UTF-8统一按 UTF-8 取字节防止中文乱码IV16 字节字符串演示用固定 IV生产环境建议随机生成密文编码Base64方便放进 JSON 和日志这里要解释一个常见疑惑PKCS5 和 PKCS7 到底是不是同一个东西。严格说PKCS5 是 8 字节块算法的填充标准PKCS7 是通用填充标准。但在 AES 场景下块大小固定 16 字节Java 里的PKCS5Padding底层实现和 PKCS7 完全一致差几个字节就补几个字节补的字节值就是补的个数。所以前端写CryptoJS.pad.Pkcs7后端写AES/CBC/PKCS5Padding两边能正常对上就是这个原因。密钥长度对应关系也得记牢AES-128 密钥 16 字节AES-192 密钥 24 字节AES-256 密钥 32 字节。前后端一旦有一端把密钥长度搞错比如前端用 16 字节、后端用 32 字节两边实际上在跑不同的 AES 变体解密出来必然是乱码或直接报错。IV 是另一个细节。CBC 模式里 IV 不需要保密但必须保证唯一性和两端一致。演示代码里我会用固定 IV方便复现。生产环境建议每次加密随机生成 IV随密文一起传给后端JSON 结构类似{ iv: xxxx, data: xxxx }。这样同一个密码每次加密出来的密文都不一样安全性会好很多。3. 前端加密实战CryptoJS 实现3.1 CryptoJS 的引入与按需加载前端选型没有太多悬念CryptoJS 是使用最广的 JavaScript 加密库。浏览器场景可以直接通过 CDN 引入脚本文件也可以在工程化项目里用 npm 安装npm install crypto-js如果在意打包体积可以用按需引入的方式import AES from crypto-js/aes; import CryptoJS from crypto-js/core; import modeCBC from crypto-js/mode-cbc; import padPkcs7 from crypto-js/pad-pkcs7;这种方式只把 AES 相关模块打进包里比整体引入小不少。项目里如果对包体积不敏感直接import CryptoJS from crypto-js也没问题。3.2 完整前端加解密工具函数直接上可以复用的代码。密钥和 IV 先用固定值演示方便和三端对齐验证const AES_KEY 2024BlogAesKey!; const AES_IV 2024BlogInitVec!; // AES-128-CBC 加密返回 Base64 密文 function aesEncrypt(plainText) { const key CryptoJS.enc.Utf8.parse(AES_KEY); const iv CryptoJS.enc.Utf8.parse(AES_IV); const encrypted CryptoJS.AES.encrypt( CryptoJS.enc.Utf8.parse(plainText), key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); // 关键取真正的密文部分再转 Base64 return encrypted.ciphertext.toString(CryptoJS.enc.Base64); } // AES-128-CBC 解密输入 Base64 密文返回明文字符串 function aesDecrypt(cipherBase64) { const key CryptoJS.enc.Utf8.parse(AES_KEY); const iv CryptoJS.enc.Utf8.parse(AES_IV); const decrypted CryptoJS.AES.decrypt( cipherBase64, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return decrypted.toString(CryptoJS.enc.Utf8); } // 使用示例 const cipher aesEncrypt(hello blog, AES is working!); console.log(cipher); // Base64 密文 console.log(aesDecrypt(cipher)); // 还原明文有几个点要特别说明。明文必须先用CryptoJS.enc.Utf8.parse()转成 WordArray这一步是为了确保中文字符按 UTF-8 编码。如果直接把字符串传进去在绝大多数浏览器里加密结果一致但为了严谨和跨端稳定显式转换是最稳妥的做法。3.3 CryptoJS 两个容易踩的大坑第一个坑是密钥字符串被当成“口令”而不是密钥。CryptoJS 的 API 有个隐藏逻辑如果传给AES.encrypt()的第二个参数是普通字符串它不会把这个字符串的字节直接当密钥而是把它当成一个 passphrase走 OpenSSL 的密钥派生算法 EVP_BytesToKey 去生成真正的密钥同时还会生成一个随机 salt。这意味着你写着“密钥是 abcdef123456”实际加密用的密钥根本不是这串字节而是拿它派生出来的另一组字节。后端如果用固定字节做 AES 解密永远对不上。解决办法就是前面代码里的写法用CryptoJS.enc.Utf8.parse()把密钥字符串转成 WordArray再传进去。第二个坑是默认toString()输出格式。CryptoJS.AES.encrypt()返回的是一个 CipherParams 对象直接对它调用toString()输出的是 OpenSSL 兼容格式的字符串开头带Salted__前缀和 salt 字节。如果直接把这份内容交给后端后端会把它当成标准密文去解解密时会报Given final block not properly padded因为这个格式多出来的头部根本不是密文的一部分。正确做法是取encrypted.ciphertext再用 Base64 编码输出也就是上面代码里encrypted.ciphertext.toString(CryptoJS.enc.Base64)这一步。顺带提一个容易被忽视的问题密文长度会膨胀。AES 是分组加密明文长度必须按 16 字节对齐不足的要补所以密文长度一定是 16 的倍数再经过 Base64 编码还会再增加约三分之一。比如 6 位数字密码明文只有 6 字节加密后密文是 16 字节Base64 之后变成 24 个字符数据库字段如果只给密码留 10 个字符解密没问题但存储会放不下。这个在表结构设计阶段就要想到。4. 后端解密实战Java 实现与三端对齐4.1 Java 后端解密工具类后端我用的是 JavaSpring Boot 环境。核心依赖是 JDK 自带的 JCE不需要额外引入加密库。工具类的完整代码如下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 AesUtils { private static final String AES_KEY 2024BlogAesKey!; private static final String AES_IV 2024BlogInitVec!; public static String decrypt(String cipherBase64) throws Exception { // 1. Base64 解码得到密文字节 byte[] cipherBytes Base64.getDecoder().decode(cipherBase64); // 2. 构造密钥对象 SecretKeySpec keySpec new SecretKeySpec( AES_KEY.getBytes(StandardCharsets.UTF_8), AES); // 3. 构造 IV 对象 IvParameterSpec ivSpec new IvParameterSpec( AES_IV.getBytes(StandardCharsets.UTF_8)); // 4. 初始化 Cipher算法/模式/填充 Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec); // 5. 解密 byte[] plainBytes cipher.doFinal(cipherBytes); return new String(plainBytes, StandardCharsets.UTF_8); } public static String encrypt(String plainText) throws Exception { Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); SecretKeySpec keySpec new SecretKeySpec( AES_KEY.getBytes(StandardCharsets.UTF_8), AES); IvParameterSpec ivSpec new IvParameterSpec( AES_IV.getBytes(StandardCharsets.UTF_8)); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] cipherBytes cipher.doFinal( plainText.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(cipherBytes); } }注意几个细节。密钥和 IV 取字节时必须指定 UTF-8 字符集直接调getBytes()会依赖服务器默认字符集一旦环境从 Windows 换成 Linux 可能就出问题。Java 的Base64.getDecoder()对应的是标准 Base64跟前端CryptoJS.enc.Base64一致。4.2 三端对齐的自测方法AES 算法是确定性的。同样的明文、密钥、IV、模式、填充在任何语言、任何实现下加密出来的字节完全一致。这个特性是做三端对齐验证的基础。我的自测方法是准备一组固定数据明文hello blog, AES is working! 密钥2024BlogAesKey! IV 2024BlogInitVec!在前端加密一次得到 Base64 密文然后拿到 Java 后端解一次能还原成原明文链路就通了。反过来再测一下Java 加密前端解密。后端可以写一个单元测试来固化这个结果Test void testFrontendCipherCanBeDecrypted() throws Exception { // 这里放前端加密后产生的 Base64 密文 String cipherFromFrontend 前端生成的密文; String plain AesUtils.decrypt(cipherFromFrontend); assertEquals(hello blog, AES is working!, plain); }要注意的是固定密钥和固定 IV 时同一个明文每次加密出来的密文是固定的所以测试用例里的断言值确定、可复现。如果改成随机 IV那就要改成先传 IV 再解密的模式。4.3 Python 辅助工具串场联调阶段我写了一个 Python 脚本用来独立验证加解密排查是哪一端的参数没对齐。项目中不一定需要 Python只是提一下这个思路当两端对不上时用第三个语言做基准判断往往很快能定位问题。from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad import base64 KEY b2024BlogAesKey! IV b2024BlogInitVec! class AesHelper: staticmethod def encrypt(plain_text: str) - str: raw pad(plain_text.encode(utf-8), AES.block_size) cipher AES.new(KEY, AES.MODE_CBC, IV) return base64.b64encode(cipher.encrypt(raw)).decode(ascii) staticmethod def decrypt(cipher_base64: str) - str: raw base64.b64decode(cipher_base64) cipher AES.new(KEY, AES.MODE_CBC, IV) plain unpad(cipher.decrypt(raw), AES.block_size) return plain.decode(utf-8)安装依赖用pip install pycryptodome。这个类只用于测试和造数据生产代码不需要。CBC 模式解密时unpad会校验填充值如果密文不对、填充解析失败它会直接抛异常这本身就是一种很好的错误信号。5. 实战踩坑记录与问题速查5.1 真实踩坑案例回顾这次联调我记录了一批典型问题每一个都对应一版真实的报错现场。第一个坑是 OpenSSL 格式密文。同事用的是前端默认的encrypted.toString()把结果传给我结果 Java 端报Given final block not properly padded。当时第一反应是密钥不对排查半天才发现密文 Base64 解码后开头是Salted__8 个字节这不是标准密文。这个就是第 3.3 节说的第二个坑非常隐蔽。第二个坑是密钥长度错位。有一次后端同事自行修改了密钥把 16 字节换成了 24 字节前端还是 16 字节。后端实际跑的是 AES-192前端跑的是 AES-128两边密文全都对不上。这种问题不会立刻报错表现出来就是“解密成功但全是乱码”或者“偶发解出乱码”。第三个坑是 IV 编码不一致。CBC 解密时IV 只影响第一个明文分组。如果 IV 错误解密过程不会报错但是第一块解出来是乱的后面的块正常。当时的场景是前端用 UTF-8 字符串做 IV后端某次重构误用了 Hex 编码的 IV结果就是密文开头 16 字节永远乱码。这种问题比整体乱码更迷惑人排查时要特别留意。第四个坑发生在 URL 传参场景。前端把 Base64 密文直接拼到 URL 上传输过程中被解析成空格后端收参后解密直接报last unit does not have enough valid bits。Base64 字符集里包含和/这两个字符在 URL 里属于特殊字符。解决方案是改用 URL-safe Base64或者在前端对密文做一次encodeURIComponent后端取参时做对应解码。5.2 问题速查表把典型现象和对应解法整理成速查表联调卡住时先对一遍现象大概率原因处理方式解密报Given final block not properly padded密文带 Salted__ 前缀 / 密文被截断 / 填充不一致确认前端取 ciphertext不取默认 toString解密成功但第一块乱码IV 不一致对比两端的 IV 编码方式解密成功但全部乱码密钥不一致或密钥长度不一致逐字节比对密钥确认 16/24/32 字节报Illegal key size密钥超过 128 位且 JDK 策略受限用 128 位或升级新版 JDKURL 传参后解密失败Base64 中被转义成空格用 URL-safe Base64 或 encodeURIComponent中文解密后乱码明文编码未统一全程指定 UTF-8同一个明文每次密文都相同固定 IV 的正常现象生产环境改用随机 IV排查这类问题的核心方法论是“逐项对齐”密钥字节、IV 字节、模式、填充、密文编码一个参数一个参数核对。如果还找不出来就用 Python 脚本造一组基准密文分别在前端和后端解密跑一遍哪边对不上就是哪边的参数问题。这个方法建议直接收藏能省大量联调时间。6. 生产环境的安全加固与建议6.1 密钥管理别高估前端静态密钥的局限用固定密钥写在 JavaScript 里的方案对日志和网关场景有效但要说清楚它的边界前端代码是会被用户拿到的打开开发者工具就能看到密钥字符串。因此前端的 AES 加密解决的不是“对抗黑客”而是“避免敏感字段在中间环节明文落地”。想清楚这个定位才能合理设计生产环境的密钥管理。我按安全级别把方案分成三档。低敏感业务比如内部系统的登录密码用静态 AES 密钥配合 HTTPS成本最低够用。中敏感业务密钥由后端配置中心下发支持版本化和定期轮换前端不直接硬编码密钥。高敏感业务就需要上非对称 对称的混合加密也就是下面这种方案。6.2 混合加密RSA AES的典型做法混合加密的思路是用 RSA 保护 AES 密钥用 AES 加密业务数据。典型流程是这样前端请求一个密钥接口后端生成 RSA 密钥对把公钥返回私钥留在内存中并设置短有效期。前端随机生成一次性的 AES 密钥和 IV用 RSA 公钥加密 AES 密钥和 IV得到信封密钥。前端用 AES 密钥加密敏感字段。请求体里同时携带信封密钥、IV、密文、时间戳。后端先用 RSA 私钥解开信封密钥再用 AES 解出明文。这个方案解决了 AES 密钥静态暴露的问题代价是每次会话多一组 RSA 加解密计算。适合登录、改密、支付这类低频敏感接口不适合每个请求都走一遍。再加一层防重放措施会更稳妥请求体里放时间戳和随机 nonce后端校验时间窗比如 5 分钟内有效并通过 nonce 判重。6.3 新项目我更推荐直接上 GCM如果是从零开始的新项目建议直接选 AES-GCM 而不是 CBC。GCM 属于 AEAD 认证加密除了保密性还提供完整性校验密文在传输过程中被篡改解密端会直接校验失败。CBC 模式本身没有认证能力密文被改了不一定能发现需要额外搭配 MAC 校验才能补上。Java 后端用 GCM 的写法很简洁Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); SecretKeySpec keySpec new SecretKeySpec(keyBytes, AES); GCMParameterSpec gcmSpec new GCMParameterSpec(128, ivBytes); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); byte[] plainBytes cipher.doFinal(encryptedBytes);这里GCMParameterSpec(128, ivBytes)里的 128 是认证标签长度单位是位。Java 的doFinal会自动校验认证标签加密时会把 tag 拼在密文后面解密时一起提交即可。前端这块新项目我不太建议再用 CryptoJS 做 GCM因为认证标签需要自己拼接处理容易出问题。浏览器原生 Web Crypto API 对 GCM 支持很好代码大概是这个形态const key await crypto.subtle.importKey( raw, keyBytes, { name: AES-GCM }, false, [encrypt] ); const iv crypto.getRandomValues(new Uint8Array(12)); const encrypted await crypto.subtle.encrypt( { name: AES-GCM, iv }, key, plainBytes ); // encrypted 的内容 密文 认证标签 // 需要把 iv 和 encrypted 一起传给后端有一点需要提前知道Web Crypto API 只在安全上下文中可用也就是 HTTPS 页面或localhost环境纯 HTTP 的内网页面调用crypto.subtle会拿不到对象。内网部署时要先确认能不能跑通这是我在验证时踩到的一个环境限制。如果你正在做类似的改造我的建议是别急着抄代码先把第 2 节的参数表逐项定死再动手写功能。参数对齐这件事真的是一行字符都不能差。密钥写死在前端只能算入门方案生产环境至少要走到配置中心下发敏感业务再上混合加密。加解密本身不难难的是两端把每一项约定都落实到位。