1. “后端加密、前端解密”:需求看着别扭,却是真实存在的
我第一次接到“用php后端做AES加密,前端js再去解密”这个需求时,第一反应不是“不会吧”,而是“为什么会有这种倒着走的设计”。后来聊下来才明白,业务方的真实诉求往往不是防黑客,而是防“随手可读”。
事情是这样的。模拟项目里要做一个客户对账导出功能,接口返回包括手机号、身份证号、银行卡号这类敏感字段。产品经理要求这些字段不能以纯文本形式出现在浏览器开发者工具里,也不希望在网关日志、抓包工具里一眼就能看到。对前后端同学来说,最直接的做法就是后端把关键字段加密后再返回,前端拿到密文按约定解密展示。于是就有了这个看似别扭、实际很常见的组合。
先泼一盆冷水:如果用户能打开浏览器控制台、能看到前端JS逻辑,那他同样能让断点停在解密函数后面,直接读取解密结果。所以这个方案的核心价值不是“用户看不到数据”,而是“数据在链路中的明文暴露窗口被压缩”。你要防的是无意间看到数据的人、随便翻日志的运维、以及不规范的第三方系统,而不是真正有对抗能力的高级攻击者。
1.1 先确认:你加密的内容到底要防谁
我习惯在动手写代码前先问三个问题:
- 密文会经过哪些链路?比如接口响应体、网关日志、数据库记录、第三方回调。
- 谁拥有合法解密能力?只有目标前端,还是多个端都要解?
- 如果前端密钥被拿到,会造成什么损失?这个问题决定了你要不要做多级密钥、要不要做权限校验。
很多开发一开始直接选AES-128-ECB,觉得简单,一个密钥一个密文完事。但ECB模式下相同的明文块会生成完全相同的密文块,字段重复性在视觉上都很明显。加上没有IV参与,同一用户、同一手机号多次请求返回的加密串可能完全一样,这在做数据脱敏的场景里反而容易暴露规律。所以后面我们统一改成AES-256-CBC,配合每次随机的IV,并且把IV放在密文前面一起返回,既方便前端解密,又避免每次请求密文完全一致。
1.2 典型场景:不是所有字段都要加密
有一次需求方说要“把响应里所有字段全加密”。我直接建议别这么做。全加密的问题在于:前端解密成本高、动态联调不好排查、接口字段含义全被抹掉。实际项目里我们只挑需要保护的字段加密,比如mobile、idCardNo、accountNo这几个。像用户昵称、订单号这种不太敏感的数据保持明文,这样前端看到大部分逻辑,少数敏感字段单独处理,产品和运营也更容易理解。
这条经验后面帮了很大忙。因为全字段加密后,前端团队每次调试都要先跑一遍解密流程,效率下降非常明显,而且一旦密文里混入特殊字符,还会引发URL参数、JSON序列化等问题。所以“按字段加密”比“全量加密”更适合大多数业务。
2. 模式、密钥、补齐与编码:前后端联调前的必懂清单
写代码之前,有几个基础概念需要先说清楚。因为AES这个方案真正难的不是算法本身,而是参数没对齐。后端和前端只要有一个参数不一致,就会出现“能加密但解不开”的尴尬局面。
2.1 AES-128还是AES-256:先看密钥长度
AES支持128、192、256位密钥,对应16、24、32字节。选择取决于你的安全要求,也取决于你们团队的密钥管理习惯。
我在模拟项目里选的是AES-256-CBC,密钥固定为32字节。理由很简单:PHP侧用openssl_encrypt可以直接指定aes-256-cbc,前端JavaScript配合CryptoJS也非常成熟,两边生态都不缺这个能力。你如果坚持128位也不是不行,但很多公司安全评审时会对“加密强度”有倾向性,256位能少一些解释成本。
这里有个最容易踩的坑:密钥长度凑不齐32字节时,不同语言的处理方式不同。PHP里openssl_encrypt遇到密钥长度不对会直接报错,但前端某些库可能会自动做填充或截断,导致两边行为不一致。我的做法是后端统一做一个规范化函数:先把密钥字符串截断到32字节,不够32字节就补\0,前端用同样逻辑处理。这样做的好处是,即使配置密钥时多打了一个空格或者少了一位字符,两端结果也一致。
2.2 CBC模式和IV:为什么ECB要躲着走
CBC(Cipher Block Chaining)会在加密每个明文块之前,把前一个块的密文和当前明文做异或运算。第一个块没有“前一个密文”,所以需要IV来充当随机种子。这意味着:
- 即使两条明文完全相同,只要IV不同,最终密文也不同。
- 密文中的每个块都会“咬住”前一个块,改动任意一个密文块,后续解密结果都会受影响。
我建议把IV理解成“每次请求的一把临时钥匙”。这个IV不需要保密,通常直接和密文一起发给前端,但它必须在每次加密时随机生成。很多人偷懒把IV写死,结果每次请求手机号加密出来的密文都一样,和ECB几乎没有区别。
PHP生成随机IV很简单:
$iv = random_bytes(16);前端解密时拿到这个IV,按16字节拆出来即可。CBC模式的IV长度必须固定为16字节,多写一位、少写一位都会报错或者解出来乱码。
2.3 PKCS7补齐和Base64编码的连带关系
AES是按块处理的,每个块16字节。如果明文长度不是16的倍数,就需要补满。PHP的openssl_encrypt在设置OPENSSL_RAW_DATA时默认使用PKCS7补齐,前端CryptoJS默认也是Pkcs7,这一对是天然匹配的。真正出问题的是部分开发在PHP里误用OPENSSL_ZERO_PADDING,或者前端手动改成了ZeroPadding,两边补齐算法不一致,解密出来要么报错要么末尾多出一堆\0。
我在项目里会刻意把“补齐算法”写进联调文档里:
| 参数项 | 后端PHP设置 | 前端JavaScript设置 |
|---|---|---|
| 算法 | aes-256-cbc | AES/CBC/PKCS7 |
| 密钥长度 | 32字节 | 32字节 |
| IV | random_bytes(16) | 按16字节截取 |
| 输出编码 | base64_encode | CryptoJS.enc.Base64.parse |
| 补齐 | OPENSSL_RAW_DATA 默认PKCS7 | CryptoJS.pad.Pkcs7 |
Base64编码也要提前沟通好。PHP的base64_encode默认不带换行符,但如果你的代码里做了分段处理,或者密钥本身经过手敲后多了一个空格,解密时很容易出现“长度不是16的倍数”的报错。我建议在接口联调阶段让前端拿到密文后,先打一条日志确认Base64字符串完整,再做解码。
3. 完整可复现的实现:PHP加密直出Base64,前端分平台解密
基础概念定了之后,代码就相对清爽了。下面给出一套我实际用过的完整链路,包含PHP后端加密接口和前端解密函数。
3.1 PHP后端加密函数
我的方案选择了“密文自带IV”的结构。后端生成长度为16字节的随机IV,把IV和AES加密结果拼接起来,最后整体做一次Base64。这样前端只需要做一次Base64解码,就能同时拿到IV和密文。
<?php /** * AES-256-CBC 加密,输出 base64(iv + ciphertext) */ function aesEncrypt(string $plaintext, string $key): string { // 统一密钥规范化:截断到32字节,不足补 \0 $key = substr($key, 0, 32); $key = str_pad($key, 32, "\0"); // 16字节随机IV $iv = random_bytes(16); // OPENSSL_RAW_DATA 表示输出原始二进制,补齐使用默认PKCS7 $ciphertext = openssl_encrypt( $plaintext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); return base64_encode($iv . $ciphertext); } /** * AES-256-CBC 解密,入参为 aesEncrypt 的返回值 */ function aesDecrypt(string $payload, string $key): string { $key = substr($key, 0, 32); $key = str_pad($key, 32, "\0"); $raw = base64_decode($payload, true); if ($raw === false || strlen($raw) < 17) { throw new InvalidArgumentException('payload格式错误'); } // 前16字节是IV,后面是密文 $iv = substr($raw, 0, 16); $ciphertext = substr($raw, 16); $plaintext = openssl_decrypt( $ciphertext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); if ($plaintext === false) { throw new RuntimeException('AES解密失败,请检查密钥、IV或密文格式'); } return $plaintext; }几个关键点说明一下:
random_bytes(16)生成的是二进制数据,不是字符串文本。不要用mb_substr对二进制做截断,直接substr处理。base64_decode($payload, true)第二个参数传true可以启用严格模式,能提前发现密文中的非法字符,减少后端静默失败的情况。- 解密失败时
openssl_decrypt返回false,不会自动抛异常,建议显式判断并抛出业务异常。
这套函数我在本地写过对应的单元测试:用同一密钥加密一段中英文混合文本,再解密回来比对原文,能确保两边基础逻辑没问题。联调时后端先把单测跑通,再给前端提供样例密文,能省大量排查时间。
3.2 前端CryptoJS解密
前端如果项目已经在用npm管理依赖,直接安装和使用crypto-js是比较省事的方式:
npm install crypto-js解密代码:
import CryptoJS from 'crypto-js'; const SECRET_KEY = 'your-32-bytes-secret-key-here'; function aesDecrypt(payloadBase64, keyStr) { // 密钥规范化:和PHP保持一致,UTF-8解析后截断或补齐到32字节 let keyWordArray = CryptoJS.enc.Utf8.parse(keyStr); const keyBytes = CryptoJS.enc.Hex.parse( CryptoJS.enc.Hex.stringify(keyWordArray).padEnd(64, '0').slice(0, 64) ); // 先整体Base64解码 const merged = CryptoJS.enc.Base64.parse(payloadBase64); // 前16字节(4个Word)是IV,后面是密文 // WordArray里一个Word占4字节,所以16字节等于4个Word const iv = CryptoJS.lib.WordArray.create(merged.words.slice(0, 4)); const ciphertext = CryptoJS.lib.WordArray.create(merged.words.slice(4)); const decrypted = CryptoJS.AES.decrypt( { ciphertext: ciphertext }, keyBytes, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } ); return decrypted.toString(CryptoJS.enc.Utf8); }这里有一个非常容易犯的错误:直接把keyStr字符串传给CryptoJS.AES.decrypt,而不是先转成WordArray。因为CryptoJS 3.x版本对“字符串形式的密钥”和“WordArray形式的密钥”处理逻辑完全不同——字符串会被当成口令(passphrase),内部走密钥派生函数,而不是直接作为原始密钥使用。结果就是后端用32字节密钥加密的数据,前端用同一个字符串却解不开,各报各的错。
提示:前端必须用
CryptoJS.enc.Utf8.parse()或CryptoJS.enc.Hex.parse()把密钥转成WordArray,才能让CryptoJS把它当作原始密钥。
“密文自带IV”的解包逻辑也不难理解。PHP输出的Base64是先IV后密文,Base64解码后是一个二进制串。前16字节是IV,对应到WordArray里就是前4个Word(32位乘4等于128位,正好16字节)。所以我用slice(0, 4)取出IV,剩下的就是真正的密文。
初次尝试的开发者如果对这段拆包代码不顺眼,也可以让后端接口分开返回两个字段:
{ "iv": "base64字符串", "data": "base64字符串" }前端代码会更直观:
const decrypted = CryptoJS.AES.decrypt( { ciphertext: CryptoJS.enc.Base64.parse(json.data) }, keyBytes, { iv: CryptoJS.enc.Base64.parse(json.iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 } );两种方式各有好处。单字段方案接口结构简单,双字段方案排错直观。如果你是第一次做这个功能,我建议选双字段,看到IV和密文分开后,排查问题时心理压力小很多。
3.3 不想引第三方库?原生Web Crypto API的做法
如果前端项目很在意包体积,或者不想引入额外依赖,浏览器自带Web Crypto API也能做同样的AES-CBC解密。新版浏览器和微信内置浏览器普遍支持,不过需要运行在HTTPS或localhost环境下。
async function aesDecryptWithWebCrypto(payloadBase64, keyStr) { // Base64转Uint8Array const binary = atob(payloadBase64); const merged = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { merged[i] = binary.charCodeAt(i); } // 前16字节是IV const iv = merged.slice(0, 16); const ciphertext = merged.slice(16); // 密钥统一为32字节UTF-8字节流 const encoder = new TextEncoder(); const keyBytes = encoder.encode(keyStr.padEnd(32, '\0').slice(0, 32)); const cryptoKey = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'AES-CBC' }, false, ['decrypt'] ); const plainBuffer = await crypto.subtle.decrypt( { name: 'AES-CBC', iv: iv }, cryptoKey, ciphertext ); return new TextDecoder().decode(plainBuffer); }原生API在crypto.subtle.decrypt时会自动处理PKCS7补齐,解密后拿到的是明文Uint8Array,再通过TextDecoder转成字符串。这个方案省去crypto-js依赖,但如果浏览器环境较旧,或有兼容性要求,还是CryptoJS更稳。
使用原生API时也要注意:crypto.subtle只在安全上下文(secure context)里存在。你如果用http://内网地址联调,某些浏览器会直接给你undefined,这时会让人误以为代码写错了。我建议联调前先确认浏览器环境支持情况,避免一开始就陷入环境问题。
4. 联调排错记录:同样密文为什么有的环境解不开
即使代码写得很规范,前后端联调还是可能出现各种诡异问题。下面是我在模拟项目里实际排查过的几个案例。
4.1 密钥“看起来相同”,实际上根本不同
有一次前端反馈说“后端返回的密文解出来是乱码”。我让前端把密钥打印出来对比,发现密钥一模一样。后来让前端把密钥转成十六进制再对比,这才发现问题:前端密钥字符串经过CryptoJS.enc.Utf8.parse()之后,和我后端用的字符串根本不是一个字节序列。
原因很简单:我们配置密钥时写的是带中文字符的字符串,一个中文字符在UTF-8里占3个字节。PHP端按字符数截断到32字节时截在了某个中文的中间位置,前端按自己的方式补零截断,两边得到的字节序列完全不同。从那以后,我定了一条规矩:所有跨端共享的AES密钥一律用纯ASCII字符,长度必须是16或32字节。比如:
Sim-Project-Demo-Key-2024-ABCD这是一个32字节的例子。前端和后端都只允许处理这个字符串,不允许在中间加空格、注释或特殊符号。
4.2 Base64换行、Padding和URL参数的陷阱
PHP默认的base64_encode不会自动换行,但如果你把密文复制到聊天工具或者从日志里截取了一段,某些编辑器会自动插入换行符。前端解析时如果没做去除空白处理,CryptoJS.enc.Base64.parse()可能解析出错误的数据。
另一个坑是Base64字符串里的+和/。如果密文通过GET请求参数传递,没有经过encodeURIComponent编码,+会被服务端解析成空格,导致后端收到的密文和前端发出去的不一致。这个问题的典型表现是:前端看到的数据完整,但解密时报“输入不是有效的Base64”或“数据长度不符合要求”。
我的处理非常朴素:凡是密文要走URL、Query参数或JSON字段,统一在前端做encodeURIComponent,后端对应做urldecode,并且在进入解密函数前先trim一次。不要小看这几步,曾经有同事因为多了一个看不见的换行符,排查了整整一下午。
4.3 密文落库之后解不开:数据库编码问题
还有一个联调阶段很少遇到、但上线后一定会遇到的问题是:加密后的二进制数据直接存数据库,再读取出来解密时报错。
原因在于数据库字段如果是utf8mb4,它只能存放合法的UTF-8文本,而AES加密后的密文是任意二进制值,可能包含非法的UTF-8字节。数据库在写入时会对这些字节做转义或者替换,存进去的内容和你当时生成的内容已经不是一个二进制串了。
解决办法有两个:
- 不要把
iv . ciphertext的原始二进制直接入库,入库前先做Base64,字段类型用VARCHAR或TEXT。 - 如果必须存BLOB,也要确认读取时没有经过字符集转换。
我建议优先选Base64后存储。虽然会让存储空间增加约33%,但能避开大量字符集陷阱,而且前端本身拿到的也是Base64,读写链路天然一致。
4.4 一个可复用的排查路径
遇到“能加密但解不开”时,我习惯按下面的顺序排查,能快速缩小问题范围:
- 后端先用自己写的
aesDecrypt解自己生成的密文,确认后端链路没问题。 - 固定一组 IV 和密钥,手工构造一条带注释的样例密文,发给前端做单元测试。
- 前端单独写一个解密测试函数,输入固定IV和密文,看输出是否等于原始明文。
- 对比前后端密钥的十六进制字节序列,确认不是“看起来相同但字节完全不同”。
- 检查两端算法模式、补齐方式和编码是否一致,重点看CBC、Pkcs7、Base64这三个配置。
这套流程我已经用了很多次。每次都能在半小时内定位出问题,最大概率出错的就是密钥字节序列和前端CryptoJS的密钥处理方式。
5. 别把AES用错地方:加密边界与更稳的替代设计
到这一步,前后端AES加解密的链路已经能跑通了。但我想认真说一句:这个方案有它的边界,不要什么事都往里面塞。
5.1 加密不能替代HTTPS
我见过有人做完前端解密后,觉得“反正接口数据都加密了,不上HTTPS也没关系”。这是非常危险的误解。
AES加密只能保证数据以密文形式存在,但它不能防止中间人篡改请求路径,也不能验证服务端身份。没有HTTPS的完整传输链路,攻击者完全可以替换前端JS代码,把解密逻辑改成把明文直接发到第三方服务器去。所以无论前端做不做解密,HTTPS都是底线。AES在这里解决的是“日志可读性”和“拦截图可读性”,而不是“传输安全”。
5.2 对数据完整性要求高的场景,加上HMAC或换GCM
CBC模式还有一个先天的弱项:不提供完整性校验。攻击者如果只篡改密文中的一个字节,解密后的明文会相应出现一块乱码,但程序不会主动感知到“数据被改过”。所以如果业务对数据完整性有要求,比如金额、积分这类字段,我会建议做两层保障。
老方案是密文后面附带一个HMAC签名,前端解密前先验签。更现代的做法是直接用AES-256-GCM模式。GCM同时提供加密和认证,PHP的openssl_encrypt支持GCM,前端Web Crypto也支持。不过GCM对IV复用非常敏感,同一个密钥下IV绝不能重复,否则认证保护几乎失效。所以如果你的团队还处于“先跑通AES-CBC再进阶”阶段,我建议先把CBC链路跑稳,再考虑升级GCM。
5.3 如果允许我重新设计一次
站在实战角度,如果业务方允许,我其实更倾向于不要求前端解密,而是采用另一种分工:
- 后端返回密文,但前端根本不负责解密。
- 敏感字段放在一个独立的、更有权限控制的接口里,前端通过按钮触发访问,后端校验登录态和角色后,在浏览器端直接渲染脱敏或局部明文。
- 日志和网关侧对敏感字段做统一过滤。
这种方式把密钥完全留在服务端,技术上更干净,也少了一大块前后端联调成本。但现实中很多项目已经有历史接口,不可能为了安全重构整条链路。所以在“PHP后端加密、前端解密”方案里,把密钥规范、模式统一、异常处理提前约定清楚,反而比纠结“是否完全安全”更实际。
最后说一点实际感受
这个需求做下来,我最大的体会是:AES加解密本身不难,难的是前后端对“同一套规则”的理解。只要把密钥长度、CBC模式、IV位置、Base64编码、PKCS7补齐这五项全部对齐,代码跑通是很快的。但如果有一项没对齐,密文就像一道锁错钥匙的门,怎么都打不开。
我现在的习惯是:每次接这种需求,先写一个前后端通用的联调文档,把上面的参数表发出去,再让后端先跑单测,最后才进入正式联调。这样能在配置阶段就拦下一大半问题。你如果也正在做类似功能,可以参考这套方案,把密钥单独放在环境变量里,前端不要硬编码在组件里,后续轮换密钥时也会省很多事。