
两年前我接一个老项目后台接口直接裸奔客户端传一个order_id服务端就返回整单数据连最基本的参数签名都没有。结果就是服务器日志里天天有陌生人拿别人的订单号试来试去上游渠道还抱怨说我们的回调地址被人恶意刷了。后来我花了两个晚上给所有API补上签名校验从那以后这类奇妙请求基本绝迹。今天就把这套 PHP方案 API签名 的完整设计思路、代码实现和我在实际项目中踩过的坑一次性整理出来给同样用PHP做接口层的同学一个可以直接参考的落地版本。这篇内容适合谁后端用PHP需要给App、小程序、前后端分离项目写接口的开发者。无论你是刚接触签名的新手还是已经在用但经常出现客户端和服务端签名对不上的老手下面这些内容应该都能帮上忙。1. 先想清楚API签名到底在防什么很多同学一上来就找代码、复制签名函数结果签名接上了却说不清楚自己在防什么。这是典型的本末倒置。签名不是装修不是别人都做了所以我也要做它解决的是几个非常具体的安全问题。1.1 明文参数请求到底有多脆弱假设你有一个查询接口前端把用户ID、商品ID、数量直接以明文GET参数发过来服务端拿到参数后就去查数据库并返回结果。这种接口看起来简单实际上至少有三种风险参数被篡改。比如交易接口里的金额、数量、状态字段攻击者用抓包工具改掉参数后再发给服务端服务端没有任何手段发现数据被动过。请求被伪造。攻击者只需要知道接口地址和参数格式不需要任何凭证就能构造出一模一样的合法请求。请求被重放。攻击者把你真正的客户端发出去的请求原样保存下来过一会儿再发一遍。如果接口没有幂等处理那等于让用户被重复下单被重复扣款。签名解决的核心问题就是第一点和第二点它能证明这段参数确实来自持有密钥的调用方并且在传输过程中没有被改动过。1.2 签名不是权限控制的替代品这里必须说清楚签名防的是接口被乱调但签名本身不等于权限控制。很多开发者以为加了签名用户就不需要登录态了这是大错特错。签名只能证明调用方拥有这个app_secret不能证明当前操作的用户就是订单的主人。换句话说签名解决的是请求来源是否可信而权限解决的是这个用户能不能操作这笔数据。所以实际项目里签名和登录态/用户鉴权通常是叠加使用的谁也替代不了谁。还有一种常见误区认为签名能加密参数。签名生成的是一个固定长度的摘要字符串它只是指纹不是密文。只要参数是明文传输的中间人依然能读取内容。签名只能保证内容没被改过如果连内容都不能被人看到那就该上HTTPS了。1.3 签名和HTTPS不是二选一说一个我经常遇到的疑问我都已经上了HTTPS为什么还要做签名HTTPS解决的是传输过程中别人看不见、改不了但实际项目里请求会经过多层环节手机端代理、企业防火墙、CDN节点、网关日志、服务端入口代理……任何一个环节都可能记录参数原文。如果团队里有同事把生产环境请求日志打到ELK里参数又从日志流出去HTTPS一点儿忙也帮不上。签名和HTTPS的关系是HTTPS负责传输层的加密和完整性。签名负责应用层的参数完整性和调用方身份核验。两者缺一不可。即便有了HTTPS我依然强烈建议做签名。尤其是开放平台类接口、支付回调、状态回写这类敏感接口签名是底线。2. 签名协议设计参数、密钥、时间戳和随机数签名实现本身不复杂复杂的是双方约定。你需要先设计出一套规则然后让服务端和客户端严格按同一套规则执行。规则不统一签名永远对不上。下面是我在实际项目里沉淀的一套方案兼容性和可维护性都比较好。2.1 一个最小可用的签名流程在PHP里做API签名最常见也最稳妥的方式是基于HMAC的签名。整体流程用一张关系图就能讲清楚调用方准备业务参数如user_id1001amount99.5。调用方额外加入app_id、timestamp、nonce三个公共参数。将所有参数按规则排序拼接成一个字符串。用app_secret作为密钥对拼接字符串做HMAC-SHA256计算得到sign。请求中携带全部参数和sign。服务端根据app_id查到对应的app_secret用同样的规则重新计算sign。比对服务端计算出来的sign和客户端传过来的sign是否一致。这套流程里app_id用于标识调用方身份app_secret是双方共有的密钥timestamp用于防止很久以前的请求被重放nonce用于防止同一个时间窗口内的重放。timestamp和nonce的组合就好比给每个请求贴了一个短时有效的唯一编号。服务端只需要记住这个编号我用过了下次再见到就直接拒绝。2.2 参数排序和拼接规则签名对不上的问题十有八九出在排序和拼接上。我们项目里的规则是这样定的建议你直接照用剔除签名参数本身也就是sign。剔除值为空的参数约定好空字符串和null都剔除但0要保留。对剩余参数按照参数名的ASCII码升序排序。将参数名和参数值用连接参数之间用连接。如果参数值是数组先递归排序再json_encode。拼接完成后在字符串末尾追加key你的app_secret再去做HMAC运算。为什么一定要排序因为服务端和客户端接收参数的顺序可能是不同的如果不排序同样的参数按不同顺序拼出来就是不同的字符串签名自然对不上。ASCII升序是一种最简单的、跨语言通用的约定。这里有一个细节参数值不要做URL编码。我见过很多项目在拼接时对参数值调用urlencode()结果请求到了服务端PHP的$_GET会自动解码一次两侧算出来的字符串就不一致了。要统一规则签名基于的是参数原始值不是URL编码后的值。2.3 HMAC-SHA256和MD5怎么选我在一些老项目里见过直接用MD5做签名就是把参数拼接后再拼上密钥直接md5()。这种方式不是不能用但有几个问题没有密钥混合过程的MD5本质上只是一个哈希很容易被彩虹表离线碰撞。MD5已经被学术界证明存在碰撞攻击虽然构造两个同名MD5值的难度不低但对安全要求高的系统不应该再依赖它。MD5、HMAC-MD5、HMAC-SHA256的核心区别在于HMAC引入了密钥参与运算安全性远高于单纯的哈希。下面这个表格可以很直观地看出差异算法是否有独立密钥推荐程度PHP实现MD5否密钥直接拼在字符串里不推荐md5($str)HMAC-MD5是密钥单独参与运算一般hash_hmac(md5, $str, $secret)HMAC-SHA256是密钥单独参与运算摘要更长推荐hash_hmac(sha256, $str, $secret)PHP 7.1及以上版本都默认内置了hash_hmac函数不需要额外安装扩展。所以没什么理由不选HMAC-SHA256。如果你的项目还在用老版本PHP建议先升一下版本老版本PHP连基础安全都保证不了签名做得再漂亮也是白搭。2.4 timestamp nonce的具体防重放策略光有签名还不够因为签名是可重复计算的同样的参数、同样的密钥任何时候算出来的签名都一样。这意味着攻击者把一个合法请求保存下来过几天原样重发服务端依然校验通过。所以还要引入时效性校验。我常用的策略服务端校验收到的timestamp与当前服务器时间的差值的绝对值是否大于5分钟。大于5分钟直接拒绝。在5分钟有效期内同一时间戳配合同一nonce随机数只能使用一次。服务端将用过的nonce存入Redis以api:nonce:{app_id}:{nonce}为key有效期设置为10分钟。为什么有效期设5分钟、nonce的有效期设10分钟因为nonce的有效期要略长于时间戳允许的范围否则会出现时间戳刚过5分钟、但nonce恰好到期的边界问题把合法的超时请求给放行或者误杀。时钟漂移怎么处理如果服务器时间不准客户端时间与服务端时间差异过大会出现正常用户的请求被误判为过期。分布式环境下这个问题尤其明显。处理办法是允许前后一定的容差值比如当前时间前后60秒内都算有效。容差不能太大否则就给重放攻击留了窗口。我自己一般控制在60秒到5分钟之间具体看业务容忍度。3. PHP实现签名生成与校验的完整代码理论说完了直接上代码。下面这套代码是我在多个正式项目里用过的复制过去改改app_id、secret的存储方式就能跑。3.1 服务端生成签名的标准方法先提供一个签名生成函数。这个函数既可以给服务端自己用来签名回调通知也可以作为参考让客户端PHP脚本去生成签名。/** * 生成API签名 * * param array $params 待签名参数不含sign * param string $secret 应用密钥 * return string */ function generateSign(array $params, string $secret): string { // 1. 剔除签名字段 unset($params[sign]); // 2. 过滤空字符串和null保留0 $params array_filter($params, function ($value) { return $value ! $value ! null; }); // 3. 递归排序保证数组参数内部顺序一致 ksort($params); array_walk_recursive($params, function ($value, $key) { if (is_array($value)) { ksort($value); } }); // 4. 拼接 keyvalue 字符串 $pairs []; foreach ($params as $key $value) { if (is_array($value)) { // 数组参数统一转JSON注意不要转义中文和斜杠 $value json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } $pairs[] $key . . $value; } $str implode(, $pairs); // 5. 追加密钥 $str . key . $secret; // 6. 生成HMAC-SHA256签名转大写便于比较 return strtoupper(hash_hmac(sha256, $str, $secret)); }这里有两个我踩过坑的细节第3步的array_walk_recursive只对多级数组有用普通一维数组其实只需要ksort就够了。之所以还要递归是因为订单里的商品列表、选项参数这类结构经常是嵌套数组如果只排外层内层顺序一变签名就废了。第4步的JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES一定要加。否则中文会被转成\uXXXXURL路径里的/会被转成\/这两个东西在不同语言、不同版本下的表现可能不一致签名就极容易对不上。3.2 客户端如何生成签名并携带请求客户端可能是PHP、Java、小程序或者前端JS核心逻辑一致。这里用PHP模拟一个客户端请求方便你在本地马上跑通。?php function generateSign(array $params, string $secret): string { // 实现见上一节这里省略 } // 模拟调用方参数 $params [ app_id 100001, user_id 888, amount 99.50, timestamp time(), nonce md5(uniqid(mt_rand(), true)), // 这是数组参数示例 items [ [id 1, num 2], [id 3, num 1], ], ]; $secret 你的app_secret; $params[sign] generateSign($params, $secret); // 发起请求这里用curl $ch curl_init(https://api.example.com/v1/create_order); curl_setopt_array($ch, [ CURLOPT_POST true, CURLOPT_RETURNTRANSFER true, CURLOPT_POSTFIELDS json_encode($params), CURLOPT_HTTPHEADER [ Content-Type: application/json, ], ]); $response curl_exec($ch); $error curl_error($ch); curl_close($ch); if ($error) { exit(请求失败: . $error); } echo $response;注意这里发送的是JSON体服务端解析时要用json_decode(file_get_contents(php://input), true)来拿参数数组不要用$_POST。$_POST只对application/x-www-form-urlencoded和multipart/form-data有效绝大多数新项目已经在用JSON传参了。3.3 服务端校验的完整逻辑与统一错误码服务端校验分为四步拿参数、验时间戳、查nonce、验签名。顺序也很重要不要一上来就查数据库浪费性能。?php /** * 校验API签名 * * param array $params 请求参数全量数组 * param string $appSecret 调用方密钥根据app_id查出 * param int $timeRange 时间戳容差秒 * return array [是否通过, 错误信息] */ function verifyApiSign(array $params, string $appSecret, int $timeRange 300): array { // 1. 基础字段检查 if (empty($params[app_id]) || empty($params[timestamp]) || empty($params[nonce]) || empty($params[sign])) { return [false, missing_required_fields]; } // 2. 时间戳校验 $timestamp (int)$params[timestamp]; if (abs(time() - $timestamp) $timeRange) { return [false, timestamp_expired]; } // 3. nonce去重Redis实现见下文 $nonceKey api:nonce: . $params[app_id] . : . $params[nonce]; $redis getRedisInstance(); // 使用SETNX实现原子操作设置成功说明之前没出现过 $isFirst $redis-set($nonceKey, 1, [NX, EX 600]); if (!$isFirst) { return [false, nonce_reused]; } // 4. 重新计算签名并比对 $localSign generateSign($params, $appSecret); // 用hash_equals防止时序攻击 if (!hash_equals($localSign, strtoupper($params[sign]))) { return [false, sign_invalid]; } return [true, ok]; } // 使用示例 $rawBody file_get_contents(php://input); $params json_decode($rawBody, true); if (!is_array($params)) { http_response_code(400); echo json_encode([code 400, message invalid_request_body]); exit; } // app_id和secret的映射关系从配置或数据库读取 $appSecrets [ 100001 a1b2c3d4e5f6..., 100002 f6e5d4c3b2a1..., ]; if (!isset($appSecrets[$params[app_id]])) { echo json_encode([code 403, message invalid_app_id]); exit; } $result verifyApiSign($params, $appSecrets[$params[app_id]]); if (!$result[0]) { echo json_encode([code 401, message $result[1]]); exit; } // 签名通过继续业务处理 echo json_encode([code 0, data success]);第4步用hash_equals而不是比较很多新手不知道这个细节。hash_equals是PHP 5.6.0引入的专门用于比较哈希字符串它的执行时间与字符串内容无关能有效防止时序侧信道攻击。别小看这一点签名比对本身就是安全环节安全环节的每个细节都要抠。3.4 Redis中nonce去重的实现要点nonce的去重逻辑我单独拿出来说是因为这里有几个容易出问题的点。首先操作必须是原子的。如果用先查询、再插入两步走在高并发下两个请求可能同时查到nonce不存在然后同时写入导致同一个nonce被放行两次。其次Redis的set命令要带NX参数。以PHP的Redis扩展为例推荐写法$isFirst $redis-set($nonceKey, 1, [NX, EX 600]);NX表示仅当key不存在时才设置EX表示过期时间。这个写法在Redis 2.6.12版本之后即可使用。如果你的$redis-set()方法不支持数组选项可以用低版本的替代写法$isFirst $redis-setnx($nonceKey, 1); if ($isFirst) { $redis-expire($nonceKey, 600); }从5.0版本开始Redis官方更推荐用set命令的NX选项因为原子性更好。如果是老项目用了setnxexpire两步只要执行顺序正确也可以但要注意如果第一步成功了第二步没执行key就会永久存在必须加个兜底清理任务。最后是过期时间的设定。nonce的过期时间应该比时间戳容差长比如时间戳容差是5分钟nonce有效期就设10分钟。这样即使请求在最后几秒通过时间戳校验nonce还有足够的余量防止重放。4. 调试签名接口时最容易踩的五个坑签名逻辑本身不难真正让开发者崩溃的是客户端算出来的签名到了服务端怎么都对不上。下面这几个坑每一个都是我或者我同事在真实项目里花过不少时间才排查出来的。提前写给你能省很多加班时间。4.1 编码不一致导致签名对不上这是出镜率最高的问题。最典型的表现是同样的参数在本地一个Postman脚本里能通过签名校验复制到代码里就不行。常见原因有这几类中文被转成\uXXXX。比如{name:张三}在有些语言里会被编码成{name:\u5f20\u4e09}。处理方案就是在JSON编码时显式指定不转义Unicode。URL编码与解码的差异。有的语言库在发送GET请求时会把中文、空格、加号做URL编码服务端拿到$_GET后又自动解码一次。如果签名时用的是解码前或解码后的字符串两次结果必然不一致。空格变成加号。URL编码的规则里空格会被编码成但如果某个环节把原生的空格直接拼在了签名串里两边就差了一个字符。我自己的习惯是签名拼接阶段一律使用原始参数值不做任何转码。如果需要传输URL编码后的参数签名阶段使用解码后的值。这个规则要写进接口文档里前后端都严格遵守。4.2 空值、0和null的处理PHP的empty()函数有一个很坑的行为0和0都会被判定为空。如果签名过滤时用了empty()那么金额刚好是0的请求会被剔除参数字段签名就废了。我见过某项目里用户下单金额是0客户端签名的字符串里带着amount0服务端过滤空值时把amount给干掉了两边签名永远不一致。排查很久才找到问题。解决方案就是用严格比较$params array_filter($params, function ($value) { return $value ! $value ! null; });注意false要不要保留如果参数里可能传布尔值建议也约定清楚。我一般约定业务参数里不允许出现false一律用0和1代替否则还得单独处理false和0的边界。4.3 嵌套数组的排序和序列化当参数里有数组时问题就更复杂了。还是以items为例$params [ items [ [id 3, num 1], [id 1, num 2], ], ];如果客户端是JavaList的顺序和服务端PHP数组的顺序不可能永远一致。排序规则需要递归到数组内部但即使如此数组元素的顺序仍然是人为决定的。我在项目里的约定是数组参数内部如果是关联数组递归排序后再JSON编码。数组参数如果是索引数组顺序即业务语义不做排序由客户端保证严格按照业务顺序输出。还有一种更省事的方案预先将数组参数转成JSON字符串当作一个普通字符串参数参与签名。这样只需要保证整个JSON字符串本身一致就行。很多开放平台就是这么做的比如微信支付的回调参数里部分结构就是先序列化再签名。4.4 从日志调试时复制粘贴导致“”号丢失这个是纯经验问题。当你把日志里的签名串或者参数串复制出来放进Postman里重新测试时字符串中的号有时候会被自动替换成空格导致签名算不出来。尤其是时间戳、nonce这类参数里偶尔会出现。解决方法是调试阶段不要直接从网页里复制用专门的日志工具或者直接查看原始请求体。如果非要从日志里复制可以把日志输出格式调整为Base64编码的请求体等排查完再改回来。另外如果你用Burp Suite或Charles这类工具抓包调试HTTPS请求要特别留意请求体在工具里显示的是解码后的格式还是原始格式一不小心就会把原始数据和显示数据混着用。4.5 服务端时间不准导致合法请求被误拒这个坑比较隐蔽。如果你的服务部署在云服务器上时间一般没问题但如果是公司内网的老机器时间漂移几十秒甚至几分钟很常见。当客户端时间比服务端快了5分10秒请求就会被当成过期拒绝。排查思路是先在服务端打印一下time()和客户端传上来的timestamp做对比。如果是时间漂移用NTP同步或者容器时区配置修复。如果机器比较多建议在签名校验时适当放宽容差值比如前后60秒。但注意别放太宽重放窗口会跟着变大。5. 签名服务上线后的运营密钥管理、版本兼容与监控签名校验上线只是开始。真正让签名体系稳定跑下去还需要在密钥、版本、监控上做功课。这一节我分享一些实际运营经验。5.1 app_id和app_secret的分配与存储为每个客户端分配独立的app_id和app_secret是签名体系的基本功。不要所有端共用一个密钥否则一旦某个端泄露所有端都要跟着换。具体分法App端一个app_id小程序端一个app_idH5端一个app_id服务端到服务端的内部调用再单独分配。如果App还有多个版本同时在线可以在一个app_id下维护多把密钥通过版本号选择。app_secret的存储也要注意。不要明文写在配置文件里再提交到Git仓库否则一旦仓库泄露所有密钥就全废了。建议生产环境密钥放到环境变量或密钥管理服务里代码库只保留app_id与密钥的映射关系。数据库里存储密钥时用可逆加密如AES加密后再保存读取时解密。虽然理论上有人能拿到解密密钥但总比明文裸奔好。密钥定期轮换。轮换时保留两把密钥的缓冲期新密钥上线后一周老密钥才彻底下线避免客户端升级不及时导致接口突然全部报错。5.2 签名版本兼容和升级策略签名算法一旦上线改起来牵连面很大。如果哪天你想从MD5升级到HMAC-SHA256或者想调整参数拼接规则总不能要求所有客户端隔天就发新版。所以我在协议设计时习惯预留一个sign_version字段默认不传就是v1传了就按对应版本的规则校验。服务端保留多套验签逻辑根据版本分发到不同函数。不过要注意不要无限期支持旧版本。我会在文档里写明每个签名版本的支持截止时间到期后强制升级。否则维护成本会越来越高旧算法万一出漏洞你还得继续兜着。5.3 签名失败监控与临时封禁签名校验失败的情况如果只体现在返回码上你是发现不了问题的。因为攻击者会不断变换app_id、nonce、timestamp单个请求的报错毫无感知。上线后至少要监控以下几项每分钟签名失败总数。正常业务下失败率应该极低突然飙升大概率是有人在不怀好意地试。单个app_id的失败次数。连续多次失败说明该密钥可能泄露或者客户端签名逻辑写错了。异常失败类型分布。比如大量timestamp_expired可能是服务器时间问题大量sign_invalid可能是算法变更导致客户端没跟上。监控数据出来后再配合封禁策略对某个IP或某个app_id的连续失败次数做阈值告警超过阈值临时封禁一段时间比如封5分钟。这个封禁不需要人工介入直接在网关或者入口中间件里做就行。我在生产环境还习惯给签名校验开启独立的慢日志。每次验签耗时超过50毫秒都记录下来正常情况下HMAC-SHA256的计算耗时都在1毫秒以内如果频繁出现高耗时那多半是Redis连接出了问题或者有人用超大的参数体在刷接口需要排查是不是存在拒绝服务攻击。签名这套东西做到上面这个程度已经足够应对绝大多数业务场景了。别一上来就整对称加密、非对称加密、数字证书先把手里的HMAC-SHA256用扎实该防的都能防住。我在实际项目里的体会是签名方案的关键不在算法多高深而在双方约定是否清晰、规则是否严格一致。把排序、编码、空值、超时这几个边界条件跟客户端团队对齐你就能少接一半的签名不对工单。最后再分享一个小技巧给签名校验的返回信息加上一个独立的错误码字段比如10001表示签名不匹配10002表示时间戳超时这样你在排查问题的时候不需要翻请求日志光看客户端返回的code就能定位到具体是哪一步出了问题。