做过接口对接的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码升序排序。
- 第二步,按
key1=value1&key2=value2的格式拼接成字符串,注意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. 拼接 key=value&key=value $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扩展(phpredis),set方法的['NX', 'EX' => 300]写法就能保证原子性,这比先exists再set两步操作安全得多。Redis的setnx到set nx ex的演进,主要就是为了解决这种“先查后设”的竞态问题。
3.3 数组参数与嵌套参数的处理策略
很多接口少不了数组参数,比如批量查询订单时传入一组订单号:order_no[]=A001&order_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%5D=A001这种URL编码形式,跟手工拼接的结果不一样,尽量不要混用。如果非要用,客户端和服务端都得统一用同一套函数,否则就是给自己埋雷。
3.4 响应端的签名返回
有些场景下,服务端给客户端的响应也需要签名,防止响应内容被第三方篡改后骗过客户端。我通常在开放接口里会把响应体也加个sign字段,规则和请求签名一样:把返回的业务参数排序拼接,用服务端的密钥算HMAC-SHA256,客户端可以用同样的方式验签。
响应签名在下行报文被网关代理或中间人篡改的场景下很有意义,比如一些第三方支付回调、金融类接口,强制要求响应签名。但要注意:响应签名会带来额外的计算开销和联调成本,不是所有接口都需要。如果只是普通的业务查询,客户端信任HTTPS和服务器来源,不验响应签名问题也不大;但如果涉及资金、状态变更,强烈建议加上。
4. 常见问题与排查实录
4.1 签名不一致的高频原因清单
签名校验失败是接口联调时最高频的问题,我把这些年遇到的真实原因整理了一张表:
| 原因分类 | 具体表现 | 解决办法 |
|---|---|---|
| 参数排序不一致 | 客户端用字典序,服务端用自定义顺序 | 统一规定ASCII升序 |
| 拼接格式不一致 | 有人拼接key=value,有人拼接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。排查问题的时候我上面说要把拼接字符串打进日志,但拼接字符串里包含&key=xxxx的密钥尾巴,这种情况日志一定要脱敏——把密钥部分替换成掩码,或者干脆只打印不含密钥的参数字符串,密钥部分用***代替。否则日志一旦被拖库,密钥也泄露了。
第三,签名校验失败不能只返错,还要记失败次数并对客户端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也不麻烦。签名这个东西,看着不起眼,但它就是接口安全的门槛——门槛做好了,很多麻烦根本到不了你面前。