签名验证必须放在中间件,控制器验签会导致恶意数据进入业务层;90%验签失败源于参数编码不一致,需统一用rawurlencode()逐键值编码并确保UTF-8;签名原文须严格按HTTP方法+路径+排序参数+timestamp+nonce+secret拼接,换行符为\n;必须使用hash_hmac('sha256', $string_to_sign, $secret, true)生成二进制签名并base64_encode;nonce去重须在验签最前执行且带业务前缀。

签名验证必须放在中间件里,控制器里做等于验签前恶意数据已进业务层——比如空参数触发数据库写入、日志打点、或调用支付回调钩子。
为什么验签失败90%是参数没对齐
客户端用 encodeURIComponent('张三') 得到 %E5%BC%A0%E4%B8%89,服务端却用 $this->request->param() 自动 urldecode() 成 张三,再拼字符串哈希,结果必然不一致。这不是算法错,是原始字节流被篡改了。
- 必须统一用
rawurlencode()对每个键和值单独编码,不能对整个字符串再urlencode() - 中文字段要确认前后端都用 UTF-8;PHP 内部设
mb_internal_encoding('UTF-8') - 空格、BOM(
\xEF\xBB\xBF)、\r\n、零宽空格(\xE2\x80\x8B)都会导致签名不一致,调试时可用bin2hex($str)逐字节比对 - 调试建议加
debug_sign=1参数,输出服务端构造的$string_to_sign原文,和客户端打印的完全比对
签名原文怎么拼才不会出错
签名原文不是“把所有参数连起来”,而是有严格上下文边界的字符串:HTTP 方法 + 请求路径(不含 query string)+ 排序后参数键值对(key=value 形式)+ timestamp + nonce + $secret。漏掉任意一项,防重放或防挪用就失效。
- 先取
$method = strtoupper($this->request->method()),再用parse_url($this->request->url(true), PHP_URL_PATH)拿路径 - GET/POST 参数合并后用
ksort($params, SORT_STRING),避免关联数组顺序不一致 - JSON 请求体需判断
$this->request->header('content-type')是否含application/json,再用$this->request->getContent()取原始 body 并参与排序拼接 - 拼接格式推荐:
$string_to_sign = $method . "\n" . $uri . "\n" . $query_string . "\n",换行符必须是\n(不是\r\n) - 密钥拼在最后,且必须是二进制安全字符串——从配置文件读取时注意是否被截断或转义
hash_hmac('sha256', ...) 的正确用法
别手写 md5($data . $secret) 或 base64_encode(sha1($data . $secret)),前者易受长度扩展攻击,后者 SHA-1 已被证明可碰撞,且无密钥混淆机制。
立即学习“PHP免费学习笔记(深入)”;
- 标准写法:
hash_hmac('sha256', $string_to_sign, $secret, true),第三个参数true返回二进制,再base64_encode()编码传给客户端 - 服务端验签时,也必须用同样方式生成比对值,不能直接比对 base64 字符串——因为客户端可能用不同编码生成
- 密钥
$secret必须服务端独有、强随机生成(如bin2hex(random_bytes(32))),不能硬编码在前端或日志中 - 别用
md5()或sha1()直接拼接——这是教科书级漏洞,线上环境已不被接受
最常被忽略的一点:nonce 去重必须在验签逻辑最开始做,且 Redis key 要带业务前缀(如 sign:nonce:{$nonce}),过期时间设为 600 秒;一旦查到已存在,立刻返回 401 并 exit,绝不往后走一步——否则重放请求仍可能执行到数据库写入层。



















