MD5+UTF-8 API签名机制详解:从原理到代码实现

📅 发布时间:2026/10/21 14:15:53
MD5+UTF-8 API签名机制详解:从原理到代码实现
做接口开发的这几年我最常被问到的不是“怎么写接口”而是“为什么我调接口老报签名错误”。就拿这个api请求加密MD5UTF-8来说听起来是三个词拼在一起实际落地时涉及编码规则、参数排序、密钥约定、时间戳防重放一整套逻辑。你百度一圈看到一堆代码抄过来还是验签失败不是你笨是你在动手前缺了一张完整的“签名生成地图”。这篇文章我就围绕“MD5UTF-8”这套最常用的API签名方案把为什么需要签名、签名算法怎么设计、客户端怎么生成、服务端怎么校验、失败怎么排查一次讲透。适合正在写对外接口的后端开发、刚接手支付或开放平台项目的同学也适合前端调用接口时总被签名问题搞心态的人。看完你至少能手写出一个能稳跑业务的签名模块并且知道哪些地方容易踩坑。1. 为什么要给API请求加签名先搞懂它解决什么问题1.1 裸参数请求的三个致命问题假设你设计了一个用户余额查询接口参数只有userId1001客户端直接GET /getBalance?userId1001就能拿到余额。在公司内网、私有协议下这问题不大但一旦接口暴露在公网比如你做的是一个微信小程序后端那问题就出来了。第一个问题是参数篡改。攻击者把userId1001改成userId1002如果服务端只依赖这个参数做查询他就能看到别人的余额。有人会说“我用HTTPS数据是加密传输的”HTTPS保护的是传输过程中的保密性和完整性但服务端和客户端各有一把钥匙到了服务端之后参数变成明文服务端无法区分这段请求是来自你的App还是来自攻击者伪造的请求。这里的本质是“服务端无法验证参数是否被第三方修改过”。第二个问题是身份伪装。攻击者不需要登录态直接把你的请求参数复制一份换个IP重放一遍服务端还是照单处理。如果你的接口是查询类问题不大但如果接口是“转账”“改密码”“核销券码”重放就是灾难。第三个问题是明文参数暴露。URL里写着amount1000account6222xxxx不仅日志会记录浏览器的历史记录、CDN的访问日志都会留下完整参数。就算服务端加了权限校验参数本身也属于敏感信息最好让它不可直接读取。这时候就需要一套机制让服务端能判断“这段请求确实是客户端发出的、参数在传输中没有被改过、请求本身足够新鲜”。这就是API签名的价值。1.2 签名机制是怎么工作的一次请求的完整信任链签名机制可以类比成你寄一个快递时在封口处贴的防拆标签。你把所有要寄的东西参数放在盒子里在封口处用一团印泥盖一个专属于你的图章收件人收到后先看封口处图章是否完整再打开箱子核对物品。如果封口图章破损说明运输途中有人动过手脚如果图章对不上说明这盒子不是你的原装货。API签名也是这样工作的客户端把所有业务参数、密钥、时间戳按约定规则拼成一个字符串计算出一个固定长度的签名值sign这个sign随请求一起发给服务端。服务端收到请求后用同样的参数、同样的密钥、同样的规则自己算一遍sign。如果两边算出来完全一致说明请求参数没被改过、来源可信如果不一致直接拒绝请求并把请求丢弃。这里的核心逻辑是密钥只有客户端和服务端知道第三方不知道所以第三方即使拿到全部参数也拼不出正确的签名。MD5在这个环节就是那个“图章生成器”它的特点是同样的输入一定得到同样的输出而输入中任何一个字符变了输出就会完全变样。这套机制解决上一节提到的三个问题参数被篡改后重新计算出的MD5值对不上服务端直接拒绝第三方伪造参数时不知道密钥无法生成合法签名因为签名不含明文敏感信息日志泄露参数不会导致直接安全风险当然敏感字段还是建议单独加密1.3 为什么偏偏选MD5不是因为它最强而是因为它够简单可靠有人一听到MD5就觉得过时了确实MD5在碰撞性上已经被学术界证明不再安全但在API请求签名这个场景里它依然有不可替代的优势。首先是实现普及度。几乎每种编程语言原生就能算MD5不需要装额外依赖Java、Python、Go、Node.js、PHP、Objective-C、Kotlin全部内置移动端和服务端没有技术代沟。你新接手一个老项目随手一翻工具类就能找到MD5计算函数。其次是计算性能。一次MD5计算约几十微秒级别单机每秒可以算几万甚至十几万次在签名验签这种高频操作上不会成为瓶颈。如果你选用非对称加密RSA做签名单次耗时是毫秒级性能差了几十倍而且密钥管理复杂度高。第三是长度固定、拼接方便。MD5输出固定32位十六进制字符串适合放在signxxxxxxxx这样一个参数里日志里也好识别数据库里也好存储和比对。作为从业者我还是要诚实说一句如果你做的是金融级或极重要数据的接口且请求量不大建议优先用HMAC-SHA256或RSA签名。MD5适合的业务场景是“防止参数被简单篡改、防止接口被无聊脚本刷”它能挡住99%的菜鸟攻击但挡不住专业级攻击。后面第5章我会专门展开这个边界。2. 签名之前必须做对的三件事UTF-8编码、参数排序、密钥约定2.1 UTF-8是签名的隐形地基大多数验签失败的问题根源不在签名算法本身而是编码问题。项目标题里单独把UTF-8列出来不是没有原因的。MD5计算的对象是字节序列不是字符串。同一个字符串“你好”在UTF-8编码下是E4 BD A0 E5 A5 BD6个字节在GBK编码下是C4 E3 BA C34个字节在UTF-16下又是另一种字节序。如果客户端用UTF-8把“你好”转成了字节数组后计算MD5服务端却用系统默认编码GBK解析了请求参数再算MD5两个结果必然不一样签名就对不上。这个问题的隐蔽之处在于很多开发者平时写的接口参数全是英文和数字用哪种编码计算都一模一样所以一直没暴露过问题。一旦有用户把昵称、地址、备注等中文参数放进请求里签名就开始随机性失败而且是在线上才会暴露。正确的做法是在拼接完原始签名字符串之后所有语言里都必须显式声明用UTF-8编码去获取字节数组再送入MD5函数。以Java为例String rawString amount100name张三keyabcd1234; MessageDigest md MessageDigest.getInstance(MD5); byte[] digest md.digest(rawString.getBytes(StandardCharsets.UTF_8));以Go为例rawString : amount100name张三keyabcd1234 hash : md5.Sum([]byte(rawString))如果你写的是getBytes()而没有指定字符集JVM在Linux服务器上默认是UTF-8在Windows默认可能是GBK同一个代码在两地部署结果完全不同。Python里也要注意字符串和字节串的转换Python3的md5()函数必须传入bytes类型你需要先s.encode(utf-8)直接传字符串会直接报错而在Python2里你还得小心默认编码不一致。服务端在接收参数时同样要注意URL解码的编码方式。比如HTTP请求体里name%E5%BC%A0%E4%B8%89是UTF-8百分号编码你的容器用ISO-8859-1去解码就变成了乱码签名自然对不上。Spring系框架getParameter()默认按request.setCharacterEncoding(UTF-8)处理有的老项目没设置就会出问题。2.2 ASCII码升序排序规则字典序背后的防混淆逻辑签名的原始字符串由多个参数拼接而成这里关键的一个设计是参数必须按固定顺序拼接。如果前后端约定不一样比如客户端按a1b2拼服务端按b2a1拼两边算出来的MD5完全不同。最常用的约定是按参数名ASCII码升序排列也就是字典序。这样做的好处有三个第一服务端不需要知道客户端当初是怎么排序的只要拿到参数后自己按所有常见语言排序函数排一遍就能还原出同样的拼接串。比如a、b、c在ASCII码里就是字母序Java的Arrays.sort()、Python的sorted()、Go的sort.Strings()默认行为都是字典序。第二避免人为沟通时搞错顺序。如果约定“按照文档里表格从上到下的顺序拼接”文档更新后新旧顺序对不上就会大面积报错。而“按key排序”是客观规则不依赖文档代码解析出来几个参数就排几个。第三防止参数被增加后混乱。客户端新增一个timestamp参数后排序规则自动把它放到正确的位置不需要人工指定插到哪。下面给出一个最典型的拼接过程假设业务参数是amount100 orderId20240606001 name张三 timestamp1717600000密钥约定为abc123secert生产环境请随机生成一个足够长的字符串。第一步剔除签名本身参数sign以及请求中用于传输签名的辅助参数将剩余参数按key名做ASCII升序排序amount name orderId timestamp第二步拼成key1value1key2value2...格式amount100name张三orderId20240606001timestamp1717600000第三步把密钥拼到末尾也可以拼到开头但头尾都要约定好amount100name张三orderId20240606001timestamp1717600000keyabc123secert第四步对上面的字符串做UTF-8编码后计算MD5d41f8c7f0d2cf9d9a2e5a0b6e3c03b91这里有一个小细节排序是对参数名排序不是对value排序。参数的值保持原样不要动。还要注意参数名必须是全小写或全大写并且大小写敏感——Amount和amount是两个不同的key排序位置也完全不同一旦混用就会验签失败。我在实际项目里要求所有参数名强制定义为小写避免这种低级错误。2.3 时间戳与随机数nonce防重放的两个关键设计光有参数学MD5只能证明参数没被改过但无法防止攻击者把一整段合法请求保存下来过几分钟、过几天再重放一次。比如一个“领取优惠券”的接口攻击者截获你领券的请求重放100次服务端可能就会发100张券给他。解决方案是给签名加入时效性和一次性两个概念。时效性用timestamp实现。客户端请求时把当前Unix秒级时间戳作为参数发给服务端参与签名计算。服务端拿到请求先看时间戳与当前时间差一般约定5分钟内为有效超过这个时间窗口直接拒绝不再做签名比对。这个设计使得攻击者保存的请求最多只有5分钟的生命周期过期自动失效。一次性用nonce实现。nonce是一个客户端生成的随机字符串每次请求都不同同样参与签名计算。服务端在校验签名通过之后把这个nonce记录下来在有效时间窗口内如果再次收到相同nonce的请求判定为重放直接拒绝。这样即使攻击者在5分钟内重放原始请求服务端也已经记住了那个nonce第二次来就过不了。有人会问如果攻击者同时又改了timestamp和nonce是不是就能绕过了不行因为timestamp和nonce都参与签名计算攻击者改了它们就要重新计算签名但签名计算需要密钥他不知道密钥就生不成合法签名。所以time5分钟、nonce一次性这两道关卡和MD5签名是相互咬合在一起的一套整体缺一个都不安全。整个签名串从“参数防篡改”变成amount100name张三nonce6f4e2b1a9c8d4f2eorderId20240606001timestamp1717600000keyabc123secert这里的nonce只需要保证在5分钟内全局唯一即可不需要全局唯一。实际上即使两个请求用了相同的nonce由于上一节那个排序后的串内容完全一样、签名也一样服务端看到重复nonce就拒绝逻辑完全自洽。3. 手把手实现MD5UTF-8签名客户端生成与服务端校验完整代码3.1 先定规矩签名算法的统一约定文档实现代码前先要有一份可以发给前后端所有人的约定文档。以我自己常用的约定为例项目约定编码格式UTF-8摘要算法MD5排序规则对参数名ASCII升序排拼接为k1v1k2v2密钥拼接在串末尾追加key你的私钥签名参数名sign传递时放在请求参数末尾时效性请求参数必带timestamp服务端允许偏差 ±5分钟重放防护请求参数必带nonce服务端在时间窗口内缓存nonce参与签名参数除sign外所有参数均参与有些框架把sign之外的辅助参数也排除务必明确这份约定是所有代码的实现依据前后端、客户端、团队新人都要照着一份文档开发而不是各写各的。我见过太多部署后联调失败最后发现“客户端把accessToken也参与签了名服务端把accessToken排除了”整个签名体系瞬间失效。3.2 服务端MD5签名工具函数Java实现大部分后端项目是Java或Spring技术栈我把服务端核心工具写好其他语言照着翻译即可。public class SignUtil { /** * 生成签名 * * param params 参与签名的参数不包含sign本身 * param secretKey 双方约定的密钥 */ public static String sign(SortedMapString, String params, String secretKey) throws UnsupportedEncodingException { StringBuilder sb new StringBuilder(); for (Map.EntryString, String entry : params.entrySet()) { String key entry.getKey(); String value entry.getValue(); if (key null || key.equals(sign) || value null || value.isEmpty()) { continue; } sb.append(key).append().append(value).append(); } sb.append(key).append(secretKey); return md5(sb.toString()); } private static String md5(String data) throws UnsupportedEncodingException { try { byte[] bytes data.getBytes(StandardCharsets.UTF_8); MessageDigest md MessageDigest.getInstance(MD5); byte[] digest md.digest(bytes); StringBuilder hexStr new StringBuilder(); for (byte b : digest) { String hex Integer.toHexString(0xff b); if (hex.length() 1) { hexStr.append(0); } hexStr.append(hex); } return hexStr.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(MD5 algorithm not found); } } }这里的SortedMap传入的时候就按key排好序不要到函数里再排一次。实际开发里建议用TreeMap因为HashMap的顺序是随机的必须用TreeMap。3.3 客户端签名计算Java / Android / iOS风格客户端和服务端用同一套规则下面给出一个典型的客户端生成签名的代码。注意在Android开发中MessageDigest的用法和服务端完全一致。public static String createSign(MapString, String params, String secretKey) { TreeMapString, String sortedParams new TreeMap(params); sortedParams.remove(sign); // 签名参数本身不参与签名 StringBuilder sb new StringBuilder(); for (Map.EntryString, String entry : sortedParams.entrySet()) { String value entry.getValue(); if (value ! null !value.isEmpty()) { sb.append(entry.getKey()).append().append(value).append(); } } sb.append(key).append(secretKey); String raw sb.toString(); // 关键getBytes必须指定UTF-8 String sign md5(raw.getBytes(StandardCharsets.UTF_8)); return sign.toUpperCase(); }有些团队约定MD5结果大写有些约定小写关键是前后端保持一致。常见的做法是统一转成大写或统一小写后比较我习惯用小写并显式转换。3.4 服务端完整验签流程含时间戳和nonce校验一个可靠的验签流程不能只比签名必须按以下顺序走public boolean verifySign(MapString, String params, String secretKey) { String sign params.get(sign); if (StringUtils.isEmpty(sign)) { return false; // 没有签名的请求直接拒绝 } String timestampStr params.get(timestamp); long timestamp Long.parseLong(timestampStr); long now System.currentTimeMillis() / 1000; if (Math.abs(now - timestamp) 300) { // 5分钟 return false; // 过期请求拒绝 } String nonce params.get(nonce); if (nonceExistsInWindow(nonce)) { return false; // 重复nonce判定为重放 } String serverSign SignUtil.sign(params, secretKey); boolean ok serverSign.equalsIgnoreCase(sign); if (ok) { saveNonce(nonce, timestamp); // 记录nonce过期自动清理 } return ok; }nonce的存储可以简单用一个Redis Setkey为nonce:{timestamp的一半窗口}过期时间设为10分钟让Redis自动清理不必做得太重。如果是单机小项目用一个带过期时间的ConcurrentHashMap也行。3.5 客户端工具类自动带timestamp和nonce的请求封装签名不能靠业务代码里手动拼参数那样太容易漏。封装一个SignedRequestHelper业务代码只需传业务参数工具类自动附加时间戳、nonce和签名。public class SignedRequest { public static MapString, String buildSignedParams(MapString, String businessParams, String secretKey) { TreeMapString, String treeMap new TreeMap(); treeMap.putAll(businessParams); treeMap.put(timestamp, String.valueOf(System.currentTimeMillis() / 1000)); treeMap.put(nonce, UUID.randomUUID().toString().replace(-, )); String sign createSign(treeMap, secretKey); treeMap.put(sign, sign); return treeMap; } }这里有个细节buildSignedParams的入参是业务参数它内部新增的timestamp和nonce需要参与签名。如果把这两个参数放到createSign之后才加签名就会缺失这两个关键字段重放防护形同虚设。3.6 兼容多个密钥与多版本签名的方法演进过程中经常遇到老版本客户端还在用旧的密钥或旧签名规则不能一刀切下线。我的做法是在签名串中加入版本号参数version1服务端读取版本号来选择对应的密钥和校验规则。同时保留一份“兼容模式”的对比表新规则验签失败后再用旧规则尝试一次。兼容窗口不要开太久建议设定一个明确的deadline到期后强制客户端升级。这些内容看似琐碎但在真实联调中几乎每个项目都会遇到。生产级别稳定的签名系统不是网页上抄一段md5工具函数就能用的必须把时间戳、nonce、版本号、异常分支这些边界都补全。4. 排查实录签名验证失败的典型问题速查4.1 编码不一致同一条请求为什么两边算出来的sign不一样这是我在支持其他团队联调时遇到最多的一个问题占比接近四成。具体症状是客户端明明在本地用相同参数、相同密钥算出了正确签名服务端验签却怎么也对不上。有一次排查了很久最后发现客户端是Objective-C写的用NSString的dataUsingEncoding:NSUTF8StringEncoding转码看起来没问题但它在拼接签名字符串时对中文的value做了stringByAddingPercentEscapesUsingEncoding把张三变成了%E5%BC%A0%E4%B8%89而服务端拿到的是解码后的原始中文张三两边参与签名的字符串本质不同。这个排查过程也提示了规范的重要性签名阶段对参数值不做URL编码保持原始值拼接而在HTTP传输阶段做URL编码。一个参数两套状态一旦混淆就会出问题。另一个常见的编码坑是PHP环境老版本PHP的http_build_query默认把中文编码成urlencode格式用这个结果去计算MD5就会与服务端不一致。正确做法是先urldecode再参与签名。如果前后端签名不一致建议先在日志中打印“签名原文”就是拼接后的那个字符串对比两端原文是否完全一致。这个操作比猜哪里错了快得多。4.2 路由和反向代理对签名的干扰生产环境里前端请求往往不会直达业务服务器中间可能有Nginx、网关、负载均衡。这里有一个隐蔽问题如果你签名时用的参数来自于HTTP请求体中间经过网关转发时请求体可能被重新解析、重写。具体场景是客户端签名用的是a1b2c3这个原始顺序和编码但Nginx配置了proxy_set_header或网关做了参数重组服务端收到的请求体被改了顺序或增加了一些网关透传字段比如gw_timestamp、traceId。虽然你加的辅助参数如果不参与签名就不会影响最终验签但如果网关把原始body整体重新序列化比如把JSON里的key顺序打乱就会导致服务端重新拼签名的串与客户端不一致。解决思路配置网关时关闭body改写透明传递如果确实需要注入字段让这些字段不参与签名但要明确列入约定文档签名参数使用queryString传参而非body传参可以规避部分body重写问题这类问题非常难定位因为签名计算是对的但传输链路对数据动了手脚。建议在服务端打印收到的“原始请求体”再进行签名比对不要打印框架帮你解析成Map之后的东西。4.3 时间偏差过大造成的阴间报错如果你的服务器时间不准或者客户端设备时间被用户手动改偏了服务端时间窗口校验就会误杀正常请求。遇到过最夸张的一个case是测试机BIOS电池老化系统时间慢了一整天所有带timestamp的请求都被拒签名算法本身完全没问题。这类问题的排查思路很简单打开服务端日志打印请求的timestamp值和服务器当前时间差多少一眼就看得出来。真正要决策的是时间窗口设多大设得太短比如30秒网络抖动加服务器时钟偏差很容易误伤设得太长比如30分钟重放窗口也太宽。我一般设5分钟客户端的timestamp生成逻辑使用设备当前时间如果检测到设备时间与服务器时间差超过1分钟就自动校准一次避免用户改时间导致的边界问题。另外要注意时间戳单位统一Unix时间戳有的用秒有的用毫秒。十位的“1717600000”和十三位的“1717600000000”刚好差1000倍一旦混用签名串就不同这个错误在日志里特别难发现因为看起来只是数字长了一截。4.4 并发请求的nonce冲突与重复提交小流量时nonce用UUID基本不会重复但一旦服务端并发高可能会出现重放误判两个不同请求恰好用了相同nonce后到的被误判为重放。出现概率极低但并非零。严格的做法是nonce加入随机数生成时的熵源比如UUID本身就够用但如果用的随机算法较弱建议加前缀时间戳-业务号-随机数组合确保全局唯一且与业务维度绑定。同时服务端在保存nonce时不要设太短的过期时间至少覆盖时间窗口的时间长度否则窗口内nonce记录失效重放又会漏进来。常见问题整理成一个速查表问题现象可能原因排查与修复中文参数验签失败编码不是UTF-8或签名时做了URL编码统一UTF-8签名用原始值传输阶段再编码英文数字参数验签失败参数顺序不一致或value首尾空格未trim打印签名原文对比所有value先trim再拼接同一请求偶发失败时间戳偏差、网关改写body校准设备时间网关关闭body改写重放请求被放行nonce未参与签名、nonce未保存确认nonce参与拼接加Redis保存nonce老版本客户端报错新旧规则未兼容加版本号判断保留兼容窗口签名值大小写不一致MD5结果转大写/小写不统一统一转小写或大小写不敏感比较4.5 从日志里捞证据一条合格签名请求的日志长什么样在联调和线上排查中最忌讳的是“靠猜”。我的习惯是签名相关的请求必须打印完整链路日志。一条合格的日志至少包含以下信息[签名验签] path/api/order/create raw_queryamount100name张三orderId20240606001noncexxxtimestamp1717600000signabc123 signed_stringamount100name张三orderId20240606001noncexxxtimestamp1717600000key****** client_signabc123 server_signdef456 resultfail注意key字段必须打码不能把密钥明文打到日志里。如果发现client_sign和server_sign不一致先对比signed_string这一步能筛掉90%的问题。如果signed_string完全一致但MD5结果不同就检查两边是否是同一个UTF-8字节序列用Hex.encodeHexString(raw.getBytes(UTF_8).toHexString())打印十六进制对比。5. 从MD5UTF-8到更稳的进阶方案5.1 MD5的碰撞风险与适用边界前面我承认过MD5有理论上的碰撞风险学术界也早就有构造碰撞的方法。但要换个角度想MD5碰撞攻击的应用场景是“我预谋生成两个内容不同但MD5相同的文件用来做数字证书伪造”也就是攻击者是可以选择输入内容的。而API签名场景里参数值大部分是业务数据攻击者能控制的是他传入的value但他拿不到密钥连签名值都拼不出来“输入可控”换来还是算不出合法签名所以实际的利用难度极高。这也让很多业务系统在经历多年生产验证后仍然选择MD5做签名就是一种“够用就好”的工程权衡。但如果你的接口涉及资金、积分、大额交易或者有合规审计要求建议直接上HMAC-SHA256。5.2 HMAC-SHA256多一份安全性、代码改动又不大HMAC-SHA256是一种基于密钥的哈希消息认证码它的特点是把密钥和消息混合后多次迭代哈希输出256位摘要。它比MD5安全性高几个量级而且实现复杂度基本相当。以Java为例Mac mac Mac.getInstance(HmacSHA256); SecretKeySpec secretKeySpec new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), HmacSHA256); mac.init(secretKeySpec); byte[] raw mac.doFinal(originalString.getBytes(StandardCharsets.UTF_8)); String sign HexUtil.encodeHexString(raw);注意HMAC的原始串只需要把参数排序拼好不需要再手动拼keyxxx因为密钥已经传入SecretKeySpec。期待一下这个改动参与签名参数的排序规则、时间戳、nonce机制可以完全不变只有“拼接密钥方式”和“摘要函数”两处变化迁移成本很低。5.3 更完整的一层防护签名HTTPS鉴权限流签名这套机制解决的是“参数没有被篡改、来源基本可信”但完整的API安全不能只靠签名。我在实际项目中通常会配合下面几层传输安全强制HTTPS防止最基础的窃听身份认证OAuth2 Token、JWT等方式确认“是谁在调用”签名防篡改本文这套MD5/HMAC机制幂等性控制交易类接口要求requestId全局唯一限流按IP、按用户维度限制单时间窗口调用次数审计日志所有带敏感操作的请求记录完整链路每一层解决一个问题签名不是万能的它只是把最底层的“裸奔”状态解除掉。最后再分享一点经验签名联调失败的时候冷静下来第一步永远是对“签名原文”。两边日志打印出来左边拼好、右边拼好一个字符一个字符对比什么问题都藏不住。不要一上来就怀疑MD5算法实现有问题MD5在全世界跑了好几十年你不是那个例外。另外我踩过很深的坑是业务参数序列化顺序。如果你用JSON传参并要求签名服务端解析成Map再排序拼接而客户端用字符串直接拼接两边即使key一样value里如果有一层嵌套JSON那嵌套JSON内部key的顺序也可能会被框架重排。这种情况一定要约定“签名原文使用原始JSON字符串逐层拼接”或者干脆要求“参与签名的字段都是单层键值对拒绝嵌套对象”。我自己做开放平台API时直接规定参与签名的参数不允许为JSON对象一律打平成keyvalue的字符串彻底规避这类问题。这套MD5UTF-8方案虽然简单但把编码、排序、密钥、时间戳、nonce每个环节都做到位你就能交付一个在生产环境稳定跑几年的签名模块。下次有人再跟你说“MD5过时了不能用”你可以笑着回一句签名场景看的是密钥安全性不是摘要算法江湖排位。