
做过接口对接的PHP开发者大概率都见过这种需求别人给你开放一个API你需要带上签名才能调反过来你给别人开放API也得校验对方的签名。API签名说白了就是给请求加一道数字手印保证请求是来自合法客户端、参数没被人动过手脚、同一条请求不能反复使用。我在几个电商和支付类项目里都自己写过整套签名方案也接手过别人留下的签名代码今天就把PHP环境下从设计到落地的那套东西完整捋一遍。这篇内容适合刚接触接口开发、需要在PHP项目中实现或对接签名API的读者也适合那些已经在用但总被签名不一致问题折磨、想系统搞懂原理的人。我会把签名方案的设计思路、核心代码实现、防重放机制以及我踩过的那些坑都讲清楚。1. API签名到底解决什么问题1.1 一个真实案例让你理解签名的必要性先说个我遇到过的场景。之前做一套给商户端用的开放接口提供一个查询订单的接口。最初接口只靠一个Token做身份认证Token是登录时发下去的固定字符串商户拿着Token就能查自己的订单。上线没几天就出事了有人把别人请求里的参数和Token抓下来把订单号改掉重放了一遍居然能查到别人的订单数据。更头疼的是同一个请求被抓包后可以反复提交接口完全没拦。那之后我重新设计了一套签名机制核心思路就三条第一确认调用方的身份只有持有正确密钥的人才算合法客户端第二确认请求参数在传输过程中没被篡改任何一处改动都会导致签名校验失败第三确认请求是“新鲜”的同一个带签名的请求不能重复生效。这三件事做扎实了上面那个问题基本就被堵死了。你可能会想HTTPS不是能加密吗对HTTPS解决的是传输过程中被窃听和篡改的问题但它管不了客户端本身拿到的数据被抓包重放这件事也管不了Token泄露后被冒用。签名机制是端到端的一种业务层安全手段和HTTPS不冲突两者配合才是常规做法。1.2 签名、Token与加密各自管哪一段这里有个容易混淆的地方。很多人觉得有了HTTPS、有了Token就不需要签名了其实这三个东西管的事不一样Token管“你是谁”身份凭证。但它是个静态值泄露了就完蛋而且无法证明请求里面的参数一定是你自己填的。加密HTTPS/RSA加密管“别人看不懂”主要防窃听。但明文变密文之后如果直接拿密文重放服务器仍然不知道这不是本人操作。签名管“你说的话有没有被改动”以及“这话是不是你说的”。签名把请求参数和密钥绑定在一起参数变一个字符签名就变密钥不泄露别人就伪造不了合法签名。打个比方Token是你的工牌HTTPS是押运车签名则是你亲手写在单据上的签名和骑缝章。单据内容改了一笔签名和骑缝章就对不上收单方就能识别出来。2. 签名方案设计动手前先想清楚三件事2.1 算法选型MD5、HMAC-SHA256、RSA怎么选签名算法首推HMAC-SHA256这是我在几个项目里横向对比后的结论。先把三者的区别说清楚算法密钥类型计算速度安全强度适用场景MD5简单拼接单一字符串快弱有已知碰撞风险内部接口、低安全要求场景HMAC-SHA256单一字符串较快强广泛认可绝大多数开放API、前后端接口RSA-SHA256公钥/私钥对慢强支持非对称开放平台、多方对接、需要防抵赖的场景MD5那种常见做法是md5($secret . $params)实现最简单但MD5本身已经不太适合作为安全散列函数用在签名上而且不带密钥的简单拼接容易被长度扩展攻击当然PHP里直接拿完整的secret拼上去会好一些但没必要冒着险去用一个被判“过时”的算法。HMAC-SHA256相当于把密钥和消息做了更紧密的混合标准成熟各语言都有现成实现PHP里一个hash_hmac就搞定没有任何额外依赖。什么时候用RSA如果是一个开放平台要给几十上百个第三方应用发密钥你用同一个secret给所有人都发一份那就很危险——任何一个商户泄露了密钥整个系统的签名体系就垮了。RSA非对称签名用私钥签、公钥验私钥只在你自己手里第三方泄露了公钥也无所谓顶多影响验签伪造不了合法签名。代价是性能比HMAC差一些而且密钥管理复杂。所以内部自用接口我建议直接用HMAC-SHA256多商户开放平台再考虑RSA。2.2 参与签名参数与拼接规则签名规则是整个方案最容易出幺蛾子的地方。我的习惯做法是第一步取出所有参与签名的参数不含sign本身把参数名按ASCII码升序排序。第二步按key1value1key2value2的格式拼接成字符串注意value不要做URL编码保持原始值。第三步在拼接结果后面追加上约定的密钥比如直接在尾部拼接key你的secret。第四步用hash_hmac(sha256, $str, $secret)得到最终的签名字符串。为什么要排序因为HTTP请求里参数的先后顺序是随意的如果不排序同一个逻辑请求会因为参数顺序不同而算出不同签名服务端就没法校验了。排序之后只要参数集合和值一样签名就一定一致。还有一个细节拼接规则里要不要排除空值我的一般做法是值为空的参数不参与签名但要保证客户端和服务端用的是同一套规则。这需要在接口文档里写死说清楚哪些参数参与、哪些不参与。最简单的做法是除了sign本身所有参数都要参与空字符串和null统统按空值处理并参与拼接。这个决策要提前定下来否则两边各算各的永远对不上。2.3 防重放时间戳与随机数配合使用签名本身防不了重放攻击——攻击者把整条请求原样抓下来签名是合法的服务器校验也通过那他再发一遍怎么办要在签名方案里加入时间戳timestamp和随机数nonce。时间戳解决“过期”问题。客户端把当前Unix时间戳作为一个参数参与签名服务端拿到请求后先看时间差超过比如5分钟就直接拒绝。这样抓包重放的有效窗口就被压缩到很小。但光有时间戳还不够5分钟窗口内依然可以重放而且如果攻击者抓包后马上重放时间戳根本没过期。所以还要加nonce随机数。每个请求生成一个唯一字符串参与签名服务端验签通过后把这个nonce记下来存Redis或数据库下次遇到相同的nonce就直接拒绝。这样同一条请求哪怕在有效期内也只能用一次。我见过有的团队嫌麻烦不存nonce只靠时间戳结果线上就出现了重复提交订单的投诉——用户手快点了两次两次请求的签名都合法时间差只有几秒服务器就处理了两遍。存nonce这步真不能省。3. PHP代码实现客户端签名与服务端验签全流程3.1 客户端请求方生成签名先看请求方怎么生成签名。假设我们要请求一个查询订单的接口?php function buildSign(array $params, string $secret): string { // 1. 过滤掉签名字段本身和空值字段项目约定 unset($params[sign]); $params array_filter($params, function ($v) { return $v ! $v ! null; }); // 2. 按键名字母ASCII升序排序 ksort($params); // 3. 拼接 keyvaluekeyvalue $str ; foreach ($params as $key $value) { $str . $key . . $value . ; } $str rtrim($str, ); // 4. 拼接密钥计算HMAC-SHA256 return hash_hmac(sha256, $str, $secret); } // 调用示例 $secret 你的应用密钥; $params [ app_id 10001, order_no 202506010001, timestamp time(), nonce md5(uniqid(mt_rand(), true)), ]; $params[sign] buildSign($params, $secret); // 发送请求curl示例略这里有个细节要注意排序时PHP的ksort按字节顺序排序也就是ASCII码顺序数字和字母混在一起时排序结果和Java、Python等语言的默认字典序是否一致多数情况下是一致的但如果你混入了中文参数名或者有人用strnatcmp做自然排序就会不一致。所以文档里最好写明用ASCII码升序代码里不要依赖PHP默认以外的排序函数。3.2 服务端验签完整流程服务端接收请求后按顺序做四件事参数校验、时间戳校验、nonce唯一性校验、签名比对。用PHP写出来?php /** * 验签入口 * param array $params 客户端提交的全部参数已包含sign * param string $secret 分配给该客户端的密钥 * param Redis $redis 用于nonce去重 * return array [bool, string] */ function verifySign(array $params, string $secret, $redis): array { // 1. 基础校验 if (empty($params[sign]) || empty($params[timestamp]) || empty($params[nonce])) { return [false, 缺少必要签名参数]; } // 2. 时间戳校验允许5分钟误差300秒 $now time(); if (abs($now - intval($params[timestamp])) 300) { return [false, 请求已过期]; } // 3. nonce去重同一nonce 5分钟内只能使用一次 $nonceKey api:nonce: . $params[nonce]; if ($redis-set($nonceKey, 1, [NX, EX 300])) { // 设置成功说明nonce第一次出现 } else { return [false, 重复请求]; } // 4. 取出客户端传来的sign重新计算签名比对 $clientSign $params[sign]; unset($params[sign]); $serverSign buildSign($params, $secret); // 用hash_equals避免时序攻击 if (!hash_equals($serverSign, $clientSign)) { return [false, 签名验证失败]; } return [true, ok]; } // 调用 [$ok, $msg] verifySign($params, $secret, $redis); if (!$ok) { // 返回错误信息HTTP状态码建议用400 exit(json_encode([code 400, msg $msg])); }hash_equals这个函数很多人不注意。如果用或比较签名攻击者可以通过测量响应时间来猜测签名内容——这就是时序攻击。hash_equals在PHP里是专门为哈希值比较设计的两个字符串长度不同也能安全返回建议无脑使用。还有一个细节nonce去重用Redis的NX参数不存在才设置和EX过期时间严格来说这一步不是原子操作但实际并发量下问题不大。如果你用的是Redis扩展phpredisset方法的[NX, EX 300]写法就能保证原子性这比先exists再set两步操作安全得多。Redis的setnx到set nx ex的演进主要就是为了解决这种“先查后设”的竞态问题。3.3 数组参数与嵌套参数的处理策略很多接口少不了数组参数比如批量查询订单时传入一组订单号order_no[]A001order_no[]A002。这种参数参与签名时很容易踩坑。我的处理原则是把数组值先做JSON编码再作为普通字符串参与排序和拼接。比如foreach ($params as $key $value) { if (is_array($value)) { $value json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $str . $key . . $value . ; }这里JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES两个选项很重要如果不加UNICODE转义中文会被编码成\uXXXX而不同语言、不同版本的JSON库转出来的格式可能有差异如果不加SLASHES转义/会变成\/同样可能造成不一致。虽然这属于“人为规定”但既然要定规则就要选一个跨语言最不容易出偏差的方案。这个经验是我在一次联调中被对方Java团队坑过后总结出来的——他们默认的JSON库不会把中文转成\uXXXX而PHP的json_encode默认会两边签名就对不上了。另外http_build_query这个函数也可以用来拼参数字符串但它在处理数组时生成的是order_no%5B0%5DA001这种URL编码形式跟手工拼接的结果不一样尽量不要混用。如果非要用客户端和服务端都得统一用同一套函数否则就是给自己埋雷。3.4 响应端的签名返回有些场景下服务端给客户端的响应也需要签名防止响应内容被第三方篡改后骗过客户端。我通常在开放接口里会把响应体也加个sign字段规则和请求签名一样把返回的业务参数排序拼接用服务端的密钥算HMAC-SHA256客户端可以用同样的方式验签。响应签名在下行报文被网关代理或中间人篡改的场景下很有意义比如一些第三方支付回调、金融类接口强制要求响应签名。但要注意响应签名会带来额外的计算开销和联调成本不是所有接口都需要。如果只是普通的业务查询客户端信任HTTPS和服务器来源不验响应签名问题也不大但如果涉及资金、状态变更强烈建议加上。4. 常见问题与排查实录4.1 签名不一致的高频原因清单签名校验失败是接口联调时最高频的问题我把这些年遇到的真实原因整理了一张表原因分类具体表现解决办法参数排序不一致客户端用字典序服务端用自定义顺序统一规定ASCII升序拼接格式不一致有人拼接keyvalue有人拼接keyvalue按文档统一格式双方对拍一次空值过滤规则不同客户端过滤空值服务端不过滤文档写死空值处理策略数组参数格式不同JSON编码选项导致中文被转义统一JSON_UNESCAPED_UNICODE和SLASHES时间戳不是秒级有的语言生成毫秒级时间戳服务端兼容两种格式或明确只用秒密钥有隐形字符复制密钥时带入换行或空格存密钥前做trim并检查hex长度URL编码偏差客户端对参数做了urlencode再拼接拼接用原始值不做URL编码还有一次比较隐蔽的某位同事在客户端用的是SORT_STRING排序而服务端用ksort默认的SORT_REGULAR当参数名是数字字符串时两者排序结果会不同。这种问题光看代码很难发现只能靠打日志对比。4.2 排查签名问题的方法论遇到签名校验失败我不会一上来就瞎猜而是按顺序排查第一步服务端把收到的原始参数完整打日志包括每个参数的key、value、数据类型。很多问题在日志面前立刻现形——比如你会看到某个值变成了null而客户端发的是空字符串。第二步把客户端生成签名前的待拼接字符串打印出来。和服务端拼接的字符串做逐字符比对。我一般会把两边的字符串都打到日志里肉眼扫一遍就知道哪个参数多、哪个值不对。第三步在服务端验签时输出“服务端算出的签名值”和客户端传上来的sign放到同一行日志里。如果两个签名值不同前面的步骤已经能定位到差异来源如果相同那说明问题在服务端逻辑比如时间戳校验或nonce去重误杀。有个小技巧在开发环境临时加一个调试接口接收两个待拼接字符串直接返回var_dump($str)两端对着看。联调环境用这个方法效率极高比在业务代码里翻日志快得多。4.3 安全加固密钥管理、日志脱敏与限流签名方案落地后还有几个安全细节容易被忽略。第一密钥不能硬编码在代码里。PHP项目里我看到最多的问题是define(APP_SECRET, xxx)写在配置文件中甚至直接写在控制器里。一旦代码仓泄露所有密钥跟着完蛋。正确做法是用环境变量或独立的密钥管理服务存储部署时注入代码里只读取getenv(APP_SECRET)。第二日志里不要打印sign和secret。排查问题的时候我上面说要把拼接字符串打进日志但拼接字符串里包含keyxxxx的密钥尾巴这种情况日志一定要脱敏——把密钥部分替换成掩码或者干脆只打印不含密钥的参数字符串密钥部分用***代替。否则日志一旦被拖库密钥也泄露了。第三签名校验失败不能只返错还要记失败次数并对客户端ID做限流。开放接口最容易遇到的就是有人拿抓到的请求反复重放试签名如果失败次数不做限制攻击者可以无限次尝试。我的方案是同一个app_id每分钟超过比如20次验签失败直接拉黑5分钟。4.4 密钥分发与管理给第三方分配密钥时一定要把密钥明文只展示一次比如在商户后台点击“生成密钥”后只弹出一回明文之后只能重置并且提供“重置密钥”功能——商户密钥疑似泄露时要能立即更换。这里有个容易忽视的点同一套系统的不同商户密钥必须互不相同。如果图省事大家共享一个secret任何一个商户泄露密钥所有接口全部暴露到时候想定位谁泄露的都没办法。密钥长度方面HMAC-SHA256的密钥建议至少32字节也就是64个十六进制字符有些团队用16字节的密钥安全性弱了不少。生成密钥可以用bin2hex(random_bytes(32))一次生成64个十六进制字符够用。密钥更新策略也要提前想清楚。线上接口换了密钥旧密钥是立即失效还是留一个过渡期我建议在数据库里给每个客户端存两个密钥当前密钥和备用密钥验签时任意一个通过都算合法这样换密钥时不需要两端同时切换可以平滑过渡。当然这个方案会稍微复杂一点看你的信任模型够不够简单——如果是一个自用小程序的后端接口最简单粗暴的密钥重置就够了。5. 签名方案的扩展RSA与第三方开放平台前文提到的HMAC方案里客户端和服务器共享同一个secret这要求我们完全信任这个客户端。但到了第三方开放平台场景几十个开发者各自接入你用同一个算法给所有人都发同一个secret风险就大了——任何一个第三方应用的安全防线被攻破整个系统的签名体系就失效了。这时候就需要RSA非对称签名。RSA签名流程和HMAC大同小异区别只在两点算签名的“材料”不同——私钥签、公钥验以及梯形图两端各持一把钥匙。PHP里用openssl_sign和openssl_verify就能实现?php // 客户端用私钥签名 $params [app_id 10001, order_no 202506010001]; ksort($params); $str http_build_query($params); openssl_sign($str, $signature, $privateKey, OPENSSL_ALGO_SHA256); $params[sign] base64_encode($signature); // 服务端用公钥验签 $clientSign $params[sign]; unset($params[sign]); ksort($params); $str http_build_query($params); $result openssl_verify($str, base64_decode($clientSign), $publicKey, OPENSSL_ALGO_SHA256); // $result 为 1 表示验签通过RSA这里注意三点第一私钥在客户端手里不能泄露公钥是公开的分发到服务端即可第二RSA对性能有要求大量验签会明显增加服务端CPU消耗我当时在一个日请求量百万的接口上压测过RSA验签QPS大概只有HMAC的三分之一业务量大的时候要注意网关层缓存第三RSA方案里时间戳和nonce依然要保留它解决的只是身份防伪和防抵赖重放攻击还是要靠老办法防。PHP里生成RSA密钥对的方式openssl genrsa -out private_key.pem 2048 openssl rsa -in private_key.pem -pubout -out public_key.pem生产环境里私钥通常放在独立密钥管理服务或用加密环境变量存储不建议放进版本库。6. 写在最后的实操体会这几年各种项目里和API签名纠缠下来我的总体感受是签名方案本身不复杂坑往往出在细节一致性上。客户端和服务端就像两个人对暗号哪怕暗号本身再简单两边只要有一个字符理解不一致就对不上。所以定方案时花点时间把文档写清楚把边界情况都写明比事后排查省事得多。另一个经验是签名逻辑尽量收敛成公共类或公共函数别散落在各个控制器里。我在一个老项目里见过每个接口都复制一段验签代码后来改签名规则的时候到处漏改线上直接炸了一片。把验签抽成一个中间件或前置过滤器这样后续要加白名单、加限流、升级算法只改一处就够了。最后想说的是如果你现在的接口还没有签名机制别等出事了再补。把本文这套HMAC-SHA256方案先在小范围用起来时间戳、nonce、密钥、日志这些环节都补齐后面再根据业务需要升级到RSA也不麻烦。签名这个东西看着不起眼但它就是接口安全的门槛——门槛做好了很多麻烦根本到不了你面前。