前端加密实战:从接口签名到数据加密,防篡改防重放全攻略

发布时间:2026/10/21 5:43:37
前端加密实战:从接口签名到数据加密,防篡改防重放全攻略 做前端时间久了几乎每个人都会被问到这么一句这个参数能不能加密一下我第一次认真面对这个问题是在一个后台管理项目里运营反馈订单接口被人用脚本狂刷数据被爬走一大半后端甩过来一句“前端加个签名吧”。说实话前端加密这个概念听着唬人落地拆开以后就是三件事防偷窥、防篡改、防重放。把这三件事认清楚绝大多数项目的加密需求都能被切成很多小块一块一块做掉就完事了。这篇文章主要写给被接口联调和安全测试折腾过、想搞懂前端到底能怎么加密的前端开发者也顺带让零基础的朋友知道密码学在前端世界里到底有哪几种基础玩法。1. 前端加密到底在防什么1.1 别指望 HTTPS 包打天下HTTPS 解决的是数据在传输管道里被偷看、被篡改的问题它保护的是“管道”不是“管道两端的人”。现实里的接口链路远不止一条 HTTPS 管道那么简单很多内网部署的业务系统至今还是 HTTP网关层会把 URL query 记录到访问日志里日志一多就可能被人拉走反向代理、CDN 节点可能缓存响应敏感字段以明文躺在缓存文件里更常见的一种情况请求到了服务端之后服务端只信任参数本身完全没有机制去验证这个请求到底是不是客户端发的、参数有没有被改过、请求是不是几分钟前被重放过来的。就算全站都上了 HTTPS一个能抓到包的内部人员照样可以把请求报文原样复制下来改掉金额再重放一遍。服务端如果不做任何身份和完整性校验它根本分不清这一份“合法请求”是从真实页面还是脚本里来的。所以前端加密和签名真正补的是服务端这一层信任缺失。HTTPS 负责路途安全前端加密负责确认“来的人是谁、递来的货有没有被人动过”。1.2 三个核心目标防偷窥、防篡改、防重放防偷窥让敏感字段不以明文出现在请求日志、浏览器开发者工具、缓存文件里。最常见的是手机号、身份证号、银行卡号这类数据传输和落地都要做处理。防篡改加一个基于密钥和参与参数的摘要任何参数改动都会让摘要校验失败。这就是验签。防重放在签名里加入时间戳和随机数服务端检查时间窗口并给随机数记一次“已使用”同一个请求不能重复生效。这三个目标往往是一套组合拳而不是单选。我在实际项目中最大的感受是对普通业务系统来说防篡改和防重放的优先级其实比防偷窥更靠前。数据泄露的常见形式不是“被人看了一眼”而是“被人改了数据”和“被人拿同一个请求反复刷”。接口被刷这种问题签名防重放几乎能一口气解决。1.3 前端加密的边界它是一道门槛不是保险柜这一点必须放在最前面讲清楚。前端代码运行在用户浏览器里所有代码和密钥理论上都能被有心人扒出来这是前端安全的一条铁律。所以前端加密的正确姿势是把它当门槛提高攻击成本把脚本小子和批量抓取挡在门外而不是把它当保险柜存放核心机密。如果你的业务场景里“密钥一旦泄露就完蛋”那这个密钥就不应该出现在前端代码里而应该由后端下发的短期凭证来驱动前端的签名逻辑。下文讲到的每一种方案我都会同步提醒它在哪个位置是可信的、哪个位置只是增加成本、哪个位置其实是表演式加密。2. 密码学基础散列、对称、非对称到底怎么选2.1 散列碎纸机散列算法前端最熟悉的是 MD5、SHA-1、SHA-256。它的特点可以用碎纸机来理解无论你丢进去一本小说还是一张便签出来的都是大约同样大小的碎纸条而且碎片没法还原成原稿。更关键的是哪怕你只改原文里的一个标点出来的碎纸条形态会完全不同这就是“雪崩效应”。散列是单向的你不能从摘要反推原文。它适合做完整性校验、密码摘要存储这类场景。但单靠散列解决不了“防止重放”的问题因为算法公开、没有秘密任何人都能对同一份参数算出同样的摘要直接把摘要连参数一起原样提交即可。所以在实际接口防篡改场景里我们几乎不会用裸的散列而是用带密钥的散列变体也就是后面要说的 HMAC。2.2 对称加密一把钥匙的保险箱对称加密前端最常见的是 AES 系列。它的逻辑是一把钥匙同时负责锁和开想用同一个保险箱存东西、取东西都得拿同一把钥匙。因为计算量小、速度快AES 适合加密大数据量比如一整段用户信息、一大段报文。对称加密有两个要点会在实战里反复踩坑一个是密钥长度AES 常用 128 位或 256 位另一个是工作模式CBC 模式需要随机初始向量IVGCM 模式自带认证标签能发现密文是否被篡改。我现在凡是能用 GCM 的地方都不会选 CBC因为 CBC 只保证机密性不保证完整性密文被改动时经常解出来才发现数据坏了还不好定位。对称加密的短板也显而易见——钥匙怎么安全地给到对方如果钥匙和密文走同一条路等于把保险箱和钥匙一起交给快递员安全性就无从谈起。2.3 非对称加密人人都能投递、只有你能打开的信箱非对称加密的代表是 RSA 和椭圆曲线系列。它的模型是公共信箱任何人都知道邮箱地址能往里投信但只有拿着专属钥匙的你能打开箱门取信。公钥用于加密私钥用于解密公钥可以随便分发私钥必须自己藏好。由于涉及大数运算非对称加密比对称加密慢得多而且加密长度也有限。它天生不适合加密一大段业务数据但非常适合解决对称加密的密钥传递问题客户端用服务端公钥把一个临时对称密钥加密后传给服务端服务端用私钥解开拿到对称密钥接下来大数据量就用这个对称密钥去通信。这正是 TLS 握手的核心思路。2.4 混合使用才是常态前端加密项目里很少有只用一种算法解决的。合理的组合方式通常是非对称加密负责交换密钥或加密极短的关键字段比如登录密码、身份证号对称加密负责加密实际传输或存储的大段内容HMAC 签名负责对参与请求的全部参数做完整性校验时间戳和 nonce一次性随机数负责抗重放。把它们组合在一个方案里才是一套比较完整的前端加密实践。记住一个原则算法永远公开要保密的是密钥。任何把“算法本身藏着掖着”当作保密手段的做法都是自欺欺人。3. 工具选型解析主流前端加密方案横向对比3.1 优先用 Web Crypto API新项目我强烈建议优先用浏览器原生提供的 Web Crypto API也就是window.crypto.subtle。它支持 AES-GCM、RSA-OAEP、HMAC、PBKDF2 这些核心算法既有浏览器原生实现还有硬件层面的随机数支持。Web Crypto 有两个“性格”需要适应。第一它几乎全是异步 API调用方式是await一串 promise写起来没有同步代码那么顺手第二它要求运行在安全上下文中HTTPS 或 localhost如果你在本地用file://或者 HTTP 打开页面crypto.subtle会是 undefined一上来就报错。大多数团队会因为异步 API 而不太愿意在新项目里用它。但我反而觉得异步是优势因为它天然不会阻塞 UI 主线程尤其加密批量数据的时候体验差别很明显。3.2 crypto-js 的取舍crypto-js 是非常老牌的加密库支持 MD5、SHA、AES、HMAC、PBKDF2 等一大堆算法API 是同步的写起来很直接const hash CryptoJS.SHA256(输入内容).toString(); const encrypted CryptoJS.AES.encrypt(明文, password).toString();同步 API 是它最大的便利性也是它在老项目里长期存在的原因。但便利背后有几个问题要注意。早期某些版本的随机数生成有过质量问题所以要用就必须用相对新的版本。另外CryptoJS.AES.encrypt(明文, password)这种传字符串密钥的写法底层会走 OpenSSL 的 KDF 从密码派生密钥导致你很难用 Node 的createCipheriv去对接同一个密文经常出现“前端加密后端解不开”的坑。我的建议是crypto-js 适合做快速签名、计算哈希以及在一些无法使用 Web Crypto 的老环境里做兜底。但做 AES 对接时不要用传密码的偷懒写法要显式传入 key 和 IV用CryptoJS.AES.encrypt(明文, key, { iv })的形式。3.3 jsencryptRSA 专用的小工具jsencrypt 是专门做 RSA 加解密的库最大的特点是直接支持 PEM 格式的密钥字符串开箱即用const encryptor new JSEncrypt(); encryptor.setPublicKey(publicKeyPem); const result encryptor.encrypt(待加密内容);它在前端登录密码加密场景里非常常用。但要注意jsencrypt 默认用的填充是 PKCS#1 v1.5虽然能用但比 RSA-OAEP 要老安全性上也弱一些。而且 rs encryption 本身就不适合加密长文本它加密长度受密钥位数限制2048 位密钥最多也就加密 245 字节左右。所以 jsencrypt 的定位很明确登录密码、身份证号这类短字段的 RSA 加密。真正要加密长文本请回去用对称加密。3.4 选型对比方案支持算法异步适用场景注意事项Web Crypto APIAES-GCM/CBC、RSA-OAEP、HMAC、PBKDF2是现代浏览器新项目必须在 HTTPS 或 localhost 下使用crypto-jsMD5/SHA/AES/HMAC/PBKDF2否快速签名、老环境兜底AES 用字符串密钥时注意 KDF 与 Node 不兼容jsencryptRSA否登录密码等短字段加密默认 PKCS#1 v1.5 padding长文本无法加密node:crypto全套同步异步服务端验签、加解密对拍前端联调时最常使用的对照工具选型的最终建议就一句话新项目优先 Web Crypto API接口签名用 HMAC-SHA256短字段机密用 RSA-OAEP长文本先 AES 生成密文再把 AES 密钥用 RSA 加密传过去。4. 典型场景实战从密码登录到本地存储4.1 登录密码传输RSA-OAEP而不是前端哈希很多前端同学处理登录密码时第一反应是“前端先把密码 SHA-256 哈希一下再传”。这个做法在大多数场景下没有太大意义。原因很简单哈希算法是公开的没有密钥抓包的人不需要知道密码原文直接把哈希值重放就能通过校验服务端如果拿不到密码原文就没法用bcrypt这类慢哈希算法做安全的密码存储前端哈希唯一的好处是避免密码原文出现在浏览器日志或开发者工具面板里但这点收益远不如直接交给 HTTPS。正确的做法是前端用服务端下发的公钥用 RSA-OAEP 把密码加密后传过去服务端私钥解密拿到密码原文再用服务端的密码哈希算法比如 bcrypt处理后存储。Web Crypto 的 RSA-OAEP 实现如下async function encryptWithPublicKey(plainText, publicKeyPem) { const pemBody publicKeyPem .replace(-----BEGIN PUBLIC KEY-----, ) .replace(-----END PUBLIC KEY-----, ) .replace(/\s/g, ); const binary Uint8Array.from(atob(pemBody), (c) c.charCodeAt(0)); const publicKey await crypto.subtle.importKey( spki, binary, { name: RSA-OAEP, hash: SHA-256 }, false, [encrypt] ); const encrypted await crypto.subtle.encrypt( { name: RSA-OAEP }, publicKey, new TextEncoder().encode(plainText) ); let binaryStr ; new Uint8Array(encrypted).forEach((b) { binaryStr String.fromCharCode(b); }); return btoa(binaryStr); }几个关键点密钥格式是 SPKI这是公钥的标准格式后端 Java 或 Node 生成的 RSA 公钥都能转换hash: SHA-256对应的是 OAEP 内部的 MGF1 哈希比默认的 SHA-1 更稳妥加密结果要转 Base64 再传输避免二进制内容被 JSON 序列化搞坏。这套流程里公钥可以放心给前端真正保密的是服务端的私钥。私钥只存在于服务端任何人都不可能从前端代码里翻出来。4.2 接口参数防篡改HMAC-SHA256 签名这是我最推荐优先落地的方案也是投入产出比最高的一个。整个思路是前端把请求参数、时间戳、随机数拼接成一段原始字符串再用一个 appSecret 作为 HMAC 密钥算出签名服务端用同一个密钥按同样的拼接规则重新计算签名比对一致就放行。用 Web Crypto 实现 HMAC-SHA256async function hmacSha256(key, message) { const enc new TextEncoder(); const importedKey await crypto.subtle.importKey( raw, enc.encode(key), { name: HMAC, hash: SHA-256 }, false, [sign] ); const signature await crypto.subtle.sign(HMAC, importedKey, enc.encode(message)); return Array.from(new Uint8Array(signature)) .map((b) b.toString(16).padStart(2, 0)) .join(); }签名生成函数async function buildSign(params, appSecret) { const timestamp Math.floor(Date.now() / 1000); const nonce crypto.randomUUID().replace(/-/g, ); const keys Object.keys(params).sort(); const query keys .map((k) ${encodeURIComponent(k)}${encodeURIComponent(params[k])}) .join(); const rawString timestamp${timestamp}nonce${nonce}${query}secret${appSecret}; const sign await hmacSha256(appSecret, rawString); return { timestamp, nonce, sign }; }签名里必须包含 timestamp 和 nonce这两个字段承担了防重放的职责timestamp 让服务端可以判断请求是否在允许的时间窗内超时直接拒绝nonce 让服务端在时间窗内对随机数做一次判重同一个随机数用第二次就拒绝。注意appSecret不要塞进 URL 参数里否则会被网关日志记下来等于把密钥写在明面上。标准做法是放进请求头比如X-App-Id、X-Timestamp、X-Nonce、X-Sign。4.3 本地敏感数据存储AES-GCM 加密localStorage 里存 token、用户资料、偏好配置太常见了但明文存储等于把隐私数据摆在一个任何人都能打开的地方。哪怕是同一个浏览器里不同的网站脚本在 XSS 面前也能直接读到这些数据。对需要落地的敏感数据我一般用 AES-GCM 加密。GCM 模式自带认证标签密文被篡改时解密会直接失败比 CBC 安全一个层次async function generateAesKey() { const key await crypto.subtle.generateKey( { name: AES-GCM, length: 256 }, true, [encrypt, decrypt] ); const rawKey await crypto.subtle.exportKey(raw, key); return arrayBufferToBase64(rawKey); } async function encryptWithAesGcm(keyBase64, data) { const key await crypto.subtle.importKey( raw, base64ToArrayBuffer(keyBase64), AES-GCM, false, [encrypt] ); const iv crypto.getRandomValues(new Uint8Array(12)); const ciphertext await crypto.subtle.encrypt( { name: AES-GCM, iv }, key, new TextEncoder().encode(data) ); const result new Uint8Array(iv.length ciphertext.byteLength); result.set(iv, 0); result.set(ciphertext, iv.length); return arrayBufferToBase64(result); }这里我把 IV 直接拼在密文前面一起存解密时先截出前 12 个字节当 IV剩下的当密文省去单独管理 IV 的麻烦。AES-GCM 的 IV 不要求绝对保密但必须保证每次加密都不同。要格外警惕一个反模式把密钥和密文一起存到 localStorage。那就等于把保险箱和钥匙放在同一个床头柜里除了防防误操作起不了真正的保密效果。如果数据必须长期保存密钥应该由服务端下发或者放在会话级的内存变量里刷新即失效。4.4 混合加密用 RSA 保护 AES 密钥如果需要加密一段较长的请求体又想用 RSA 参与最合适的做法是混合加密前端生成一个临时的 AES-256 密钥用这个 AES 密钥加密请求内容用服务端公钥通过 RSA-OAEP 加密这个临时 AES 密钥本身把 AES 密文和 RSA 加密后的 AES 密钥一起发给服务端服务端先解 RSA 拿到 AES 密钥再用它解内容。这套流程里RSA 只加密几十字节的密钥AES 负责加密任意长度的内容两头都占了。这也是几乎所有真实加密通信都在使用的基本思路。5. 完整案例给下单接口加签名防刷5.1 场景还原与方案设计之前的后台项目里接口被脚本刷的典型特征是脚本固定死了请求参数反复提交同一个请求后台订单表刷出一堆垃圾数据。服务端没有鉴权没有验签没有重复提交控制。方案定下来是这样客户端请求头带四个字段X-App-Id、X-Timestamp、X-Nonce、X-SignSign的计算规则是参数名按字典序排列拼成keyvaluekey2value2然后加上 timestamp、nonce 和 appSecret整串做 HMAC-SHA256服务端校验三步走时间窗检查、nonce 判重、签名比对。为什么参数要排序因为服务端重新拼接时必须保证顺序一致。如果客户端是a1b2服务端却按b2a1拼签名必然对不上。排序之后双方对同一组参数的拼接结果就是确定性的。5.2 前端实现签名我贴一个可以直接用的完整请求示例async function requestWithSign(url, params, appId, appSecret) { const { timestamp, nonce, sign } await buildSign(params, appSecret); const response await fetch(url, { method: POST, headers: { Content-Type: application/json, X-App-Id: appId, X-Timestamp: String(timestamp), X-Nonce: nonce, X-Sign: sign, }, body: JSON.stringify(params), }); return response.json(); }这里有一个容易被忽略的细节签名用的原始字符串和实际请求体里的参数必须严格一致。如果你body里用的是原始 params但签名时对 params 做了排序和 URL 编码那服务端校验时也要按相同的逻辑处理。最稳妥的办法是请求体里的字段顺序不敏感但签名原始串的构造规则要固化成文档前后端各留一份。5.3 服务端校验逻辑服务端用 Node 做参考实现const crypto require(crypto); function verifySign(req, appSecretMap) { const appId req.headers[x-app-id]; const timestamp Number(req.headers[x-timestamp]); const nonce req.headers[x-nonce]; const sign req.headers[x-sign]; const secret appSecretMap[appId]; if (!secret || !sign || !timestamp || !nonce) return { ok: false, msg: 缺少签名参数 }; // 时间窗 5 分钟 const now Math.floor(Date.now() / 1000); if (Math.abs(now - timestamp) 300) { return { ok: false, msg: 请求已过期 }; } // nonce 判重这里用 Redis 或者内存 Set if (nonceCache.has(nonce)) { return { ok: false, msg: 请求重复 }; } const rawString buildRawString(req.body, timestamp, nonce, secret); const expected crypto.createHmac(sha256, secret).update(rawString).digest(hex); if (expected ! sign) { return { ok: false, msg: 签名不匹配 }; } nonceCache.add(nonce); return { ok: true }; }初看逻辑不复杂但真正上线后bug 往往不在加密本身而在“服务端拿到的 body 和前端签名的参数是否一致”上。Node 里req.body是 JSON 解析后的对象排序、编码都容易出偏差。我的经验是签名原始串的生成最好前后端共用一份伪代码文档甚至可以直接把前端buildSign的逻辑原样翻译到后端避免理解偏差。5.4 上线后要注意的事第一时间戳务必统一用秒级别混用毫秒和秒否则服务端和客户端差了 1000 倍时间窗永远不成立。第二nonce 判重如果漏了防重放就只剩时间戳一层而时间戳范围内的重放依然有效所以 Redis 缓存 nonce 的时间至少要和时间窗一样长。第三服务端日志不要打签名原始字符串里面带了 secret打进去等于自我泄露。这套签名方案上线后脚本直接改参数重放的路子基本就堵死了。攻击者如果真想继续刷必须逆向出 appSecret那就进入了对抗更深一层的范畴已经超出了普通业务防刷的预期。6. 常见问题与排查技巧实录6.1 前端加密后端解不开这个问题的排名绝对第一。先说 RSA 解不开的常见原因前后端使用的填充方式不一致。前端 jsencrypt 默认 PKCS#1 v1.5后端却用 OAEP 解密必挂。要统一且推荐都用 OAEPBase64 解码环节出错字符串里有换行符、空格解出的二进制就已经错了前端把加密结果交给了 JSON 序列化被转成了空格后端还原不出来。传输前先做统一处理比如encodeURIComponent或者换用 URL-safe Base64。AES 解不开最常见的就是 crypto-js 的坑。CryptoJS.AES.encrypt(Hello, 123456)这个写法和 Node 的aes-256-cbc完全对不上因为前者用密码字符串做了一个基于 OpenSSL 的密钥派生。解决方式是显式指定 key 和 iv并明确加密模式const key CryptoJS.enc.Utf8.parse(16位或32位的密钥字符串); const iv CryptoJS.enc.Utf8.parse(16位IV字符串); const encrypted CryptoJS.AES.encrypt(Hello, key, { iv, mode: CryptoJS.mode.CBC }).toString();Node 端用createCipheriv(aes-256-cbc, key, iv)来对应不要用createCipher那种废弃写法。6.2 签名总是校验失败签名对不上的第一直觉别去怀疑 HMAC 算法顺序不统一才是最大的坑。具体表现前后端对参数排序规则理解不一致一个排序一个没排URL 编码的差异空格在前端编码成了%20后端还原出来变成了拼接原始字符串时有的字段被遗漏比如 timestamp、nonce 没加请求体在中间被网关改写比如被加了签名参数才真正发送但服务端拿到的是加完之后的东西。排查这类问题最有效的办法不是盯代码而是让前后端把各自生成签名时的原始字符串分别打印出来对比。两行字一摆差异立刻现形。我之前有两次踩坑都是这样五分钟内解决的。6.3 密钥和编码的常见误区密钥长度不匹配是 AES 高频问题。前端的密钥是 16 字节后端用 32 字节去解直接报错。所以密钥长度必须在方案里写明AES-128 用 16 字节AES-256 用 32 字节IV 固定 16 字节CBC或 12 字节GCM。再就是 hex 和 Base64 混用。前端把哈希结果输出成了 hex后端却按 Base64 去解码或者反过来都会得到一堆乱码字符串。建议全链路统一用 hex因为 HMAC 摘要输出 hex 最直观可读性最好也方便排查。6.4 防重放失效时间戳对不齐、nonce 没判重上线一段时间后偶尔会听到“加了防重放还是被刷”的反馈。先查三件事时间戳是不是被客户端手动改成当前时间每次请求都重新生成时间窗永远通过——这种情况说明脚本已经逆向逻辑了nonce 缓存没做持久化服务端重启后清空攻击者拿历史请求就能再次通过并发场景下同一 nonce 被两个请求同时带过来检查与写入之间有竞态短时间窗内出现“漏网之鱼”。前面两点属于运维层面问题第三点需要用 Redis 原子操作或者加唯一索引去兜底。对绝大多数项目来说能把这些查完改完防刷效果已经非常可观了。7. 一点个人心得前端加密做了几年我最大的体会是它更像一道门槛和一层滤网而不是一个绝对安全的保险柜。设计任何前端加密方案之前先自己问一句如果这个密钥被公开我会不会慌会慌就说明它不应该出现在前端代码里。能把这个边界想清楚方案基本不会走偏。我也越来越倾向于把加密分成三档来处理普通接口参数只做 HMAC 签名防篡改和防重放密码、身份证号这类不能再短的机密字段用 RSA-OAEP本地要存的敏感数据用 AES-GCM 并避免密钥与密文共存。顺序也很有讲究先全站 HTTPS再做接口验签然后才考虑字段加密和本地存储加密。步子迈大了容易给自己挖坑一步步来反而稳定。文章里的代码片段都是我实际项目里跑过的方案你可以直接抄去改一改。真遇到排查不出来的加解密问题记住先把原始拼接串打出来把那行字符串对齐了百分之七八十的坑都埋在那里。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询