支付宝PHP版SDK支付签名及验签失败的影响因素
结论
文件编码和密钥是否匹配只是排查方向之一。PHP SDK 提示“验签出错”,常见原因包括:
- 应用私钥、应用公钥和支付宝公钥的配置位置混淆
sign_type与实际签名算法不一致,例如配置为RSA2,代码却使用 SHA-1- 待签名字符串在签名前后发生了变化
- 签名被重复进行 URL 编码,或者 Base64 中的
+在传输时变成了空格 - PHP 与 Java 对 JSON、数字和空值的处理不同
- 私钥的 PEM 格式、换行或 OpenSSL 环境有问题
- 应用、网关或环境配置错误
同一组业务参数在 Java SDK 中可以成功,并不能证明 PHP 端的配置也正确。排查时,应分别比较两端的“最终待签名字符串”和“原始 Base64 签名”,不要只比较最终请求 URL。
首先确认三类密钥没有混用
支付宝开放平台接口通常涉及三类密钥:
- 应用私钥:由商户保存,用来生成请求签名。
- 应用公钥:上传到支付宝开放平台,支付宝用它验证商户请求。
- 支付宝公钥:从开放平台获取,商户用它验证支付宝响应或异步通知。
PHP SDK 的常见配置如下:
$aop = new AopClient();
$aop->gatewayUrl = 'https://openapi.alipay.com/gateway.do';
$aop->appId = '你的应用ID';
$aop->rsaPrivateKey = $appPrivateKey;
$aop->alipayrsaPublicKey = $alipayPublicKey;
$aop->signType = 'RSA2';
$aop->charset = 'UTF-8';
$aop->format = 'json';
其中:
$aop->rsaPrivateKey
必须填写应用私钥。
$aop->alipayrsaPublicKey
必须填写支付宝公钥,不能填写应用公钥。
部分 SDK 版本支持通过文件路径设置私钥,例如 rsaPrivateKeyFilePath。具体属性名称应以当前使用的 SDK 代码为准。不要同时配置字符串私钥和文件私钥,否则很难判断 SDK 最终读取的是哪一项。
还要确认上传应用公钥的应用与当前 app_id 完全一致。沙箱应用、正式应用和不同应用之间的密钥不能混用。
确认 RSA2 和 SHA-256 完全一致
支付宝接口常用的 RSA2 对应:
SHA256WithRSA
在 PHP OpenSSL 中应使用:
OPENSSL_ALGO_SHA256
如果请求参数中声明:
sign_type=RSA2
代码实际调用的却是:
OPENSSL_ALGO_SHA1
支付宝会按照 RSA2 规则验签,因此必然失败。
下面的示例可以独立验证 PHP 签名环境:
<?php
function formatPrivateKey(string $privateKey): string
{
$privateKey = trim($privateKey);
if (str_contains($privateKey, 'BEGIN')) {
return $privateKey;
}
$privateKey = preg_replace('/\s+/', '', $privateKey);
return "-----BEGIN PRIVATE KEY-----\n"
. chunk_split($privateKey, 64, "\n")
. "-----END PRIVATE KEY-----";
}
$content = 'app_id=你的应用ID&charset=UTF-8&method=alipay.trade.query';
$privateKey = formatPrivateKey($appPrivateKey);
$keyResource = openssl_pkey_get_private($privateKey);
if ($keyResource === false) {
while ($error = openssl_error_string()) {
error_log($error);
}
throw new RuntimeException('无法加载应用私钥');
}
$success = openssl_sign(
$content,
$signature,
$keyResource,
OPENSSL_ALGO_SHA256
);
if (!$success) {
while ($error = openssl_error_string()) {
error_log($error);
}
throw new RuntimeException('签名生成失败');
}
$sign = base64_encode($signature);
echo $sign;
这里的 $content 仅用于演示。实际调用时,必须使用 SDK 按支付宝规则生成的完整待签名字符串。
对比最终待签名字符串
验签针对的是字节序列。即使业务参数看起来相同,只要待签名字符串中有一个字符不同,签名结果就会完全不同。
排查时要重点检查:
- 参数名称是否按规则排序
- 是否排除了
sign - 是否错误地加入了空值参数
- 参数值前后是否带有空格、换行或不可见字符
biz_content是否在签名后重新生成- JSON 中的键顺序、斜杠、Unicode 或数字格式是否发生变化
- 布尔值是否在
true、false、1和0之间变化 - 金额是否从
"0.01"变成0.01 timestamp、charset和version是否与实际发送的值一致
下面的代码可以生成便于检查的待签名字符串。它主要用于说明问题,实际项目仍应优先使用 SDK 自带的实现:
<?php
function buildSignContent(array $params): string
{
unset($params['sign']);
ksort($params, SORT_STRING);
$pairs = [];
foreach ($params as $key => $value) {
if ($value === null || $value === '') {
continue;
}
if (is_array($value) || is_object($value)) {
throw new InvalidArgumentException(
"{$key} 必须先转换成最终发送的字符串"
);
}
$pairs[] = $key . '=' . $value;
}
return implode('&', $pairs);
}
调试时应同时记录:
error_log('sign_content_hex=' . bin2hex($signContent));
error_log('sign_content=' . $signContent);
error_log('sign_base64=' . $sign);
bin2hex() 可以帮助发现 BOM、回车、换行和不可见空格。这些字符通常无法从普通日志中看出来。
如果 Java 端可以成功,可让 Java 和 PHP 使用完全相同的固定参数,并分别输出:
待签名字符串
待签名字符串的 UTF-8 字节或十六进制值
未经 URL 编码的 Base64 签名
只要待签名字符串的字节完全一致,并且两端使用同一把私钥和同一种算法,生成的签名就应当能通过对应公钥的验证。在这些条件相同的情况下,RSA PKCS#1 v1.5 签名通常也会得到相同结果。
不要在签名前进行 URL 编码
待签名字符串通常由原始参数值拼接而成。生成签名后,再交给 HTTP 请求层进行表单编码。
下面是错误的处理方式:
$value = urlencode($value);
$signContent .= $key . '=' . $value;
如果 SDK 已经负责请求编码,业务代码又手动调用一次 urlencode() 或 http_build_query(),就可能造成双重编码。
还要检查 Base64 签名中的 +。在 application/x-www-form-urlencoded 中,没有正确编码的 + 可能被解析成空格。
例如,原始签名是:
abc+def/ghi==
服务端实际收到的内容可能变成:
abc def/ghi==
如果必须自行发送请求,应让请求库统一执行一次表单编码:
$postBody = http_build_query($params, '', '&', PHP_QUERY_RFC1738);
不要先对 $params['sign'] 单独调用 urlencode(),再把整个数组传给 http_build_query()。
固定 biz_content,不要重复序列化
PHP 和 Java 生成的 JSON 可能不同。JSON 在语义上相同,不代表用于签名的字节也相同。
建议只生成一次 biz_content,签名和发送都使用同一个变量:
<?php
$bizContent = json_encode(
[
'out_trade_no' => 'ORDER_202609080001',
],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
if ($bizContent === false) {
throw new RuntimeException(json_last_error_msg());
}
$params['biz_content'] = $bizContent;
// 后续签名和发送均使用 $params,不要再次生成 biz_content。
签名后不要再执行类似操作:
$params['biz_content'] = json_encode($anotherArray);
即使 $anotherArray 看起来包含相同字段,键顺序、类型或转义方式也可能已经不同。
金额和订单号等对格式敏感的字段建议使用字符串:
$bizContent = [
'out_trade_no' => 'ORDER_202609080001',
'total_amount' => '0.01',
'subject' => '支付测试',
];
尤其不要把订单号转换成浮点数。较长的数字经过浮点处理后,可能出现精度损失或变成科学计数法。
检查私钥 PEM 格式
不少 PHP 签名问题并非密钥内容错误,而是 OpenSSL 无法正确解析私钥格式。
常见的私钥头有:
-----BEGIN PRIVATE KEY-----
以及:
-----BEGIN RSA PRIVATE KEY-----
它们通常分别对应 PKCS#8 和 PKCS#1 格式。不同 SDK 或 PHP/OpenSSL 版本对输入格式的兼容情况可能不同。
需要确认以下几项:
- PEM 头尾与实际格式一致
- Base64 主体中没有多余空格
- 内容中没有全角字符
- 从配置文件或环境变量读取时,
\n是否被保存成两个普通字符 - 私钥首尾没有 BOM
- 私钥没有被模板、YAML、Docker Compose 或配置中心截断
如果环境变量中保存的是转义换行,可以根据实际存储方式恢复:
$privateKey = str_replace('\\n', "\n", $privateKey);
不要无条件执行这项转换。只有确认配置中保存的是字面量 \n 时才应处理,否则可能破坏原始密钥。
可以直接检查 OpenSSL 能否读取私钥:
$key = openssl_pkey_get_private($privateKey);
if ($key === false) {
while ($message = openssl_error_string()) {
error_log($message);
}
throw new RuntimeException('私钥格式错误或当前 OpenSSL 无法读取');
}
不要忽略 openssl_sign() 的返回值。有些代码在签名失败后,仍会对空变量执行 base64_encode(),最终只能看到一个看似正常的空签名。
检查 PHP 运行环境
确认实际处理支付请求的 PHP 环境已经启用 OpenSSL:
if (!extension_loaded('openssl')) {
throw new RuntimeException('PHP OpenSSL 扩展未启用');
}
命令行 PHP 和 Web 服务器使用的 PHP 可能不是同一个环境,例如:
- CLI 和 PHP-FPM 使用不同的
php.ini - 容器内外的 PHP 版本不同
- 扩展版本或 OpenSSL 库不同
- 部署后仍在运行旧的 PHP-FPM 进程
因此,不能只根据命令行测试结果判断线上请求环境。可以在实际请求进程中临时记录:
error_log('php_version=' . PHP_VERSION);
error_log('openssl_version=' . OPENSSL_VERSION_TEXT);
error_log('openssl_loaded=' . (extension_loaded('openssl') ? 'yes' : 'no'));
日志中不要输出完整私钥。
区分请求验签和响应验签
调用 AlipayTradeQueryRequest 时返回“验签出错”,通常表示支付宝无法使用平台保存的应用公钥验证当前请求。此时应优先检查:
app_id- 应用私钥
- 平台上传的应用公钥
sign_type- 待签名字符串
- 签名传输过程
如果请求已经成功返回业务 JSON,但 PHP SDK 在处理响应时提示验签失败,排查重点就不同了。此时应检查:
alipayrsaPublicKey是否确实是支付宝公钥- 是否误填了应用公钥
- 返回内容是否被代理、网关或日志组件修改
- SDK 是否提取了正确的响应节点进行验签
不要因为配置项名称中都含有 publicKey,就把应用公钥和支付宝公钥当成同一项。
建议的排查顺序
- 确认 PHP 实际使用的
app_id、网关和运行环境。 - 确认
rsaPrivateKey中保存的是应用私钥。 - 确认与该私钥对应的应用公钥已经上传到同一个
app_id。 - 确认
alipayrsaPublicKey中填写的是支付宝公钥。 - 确认请求声明的是
RSA2,实际签名使用的是OPENSSL_ALGO_SHA256。 - 固定一组参数,输出 PHP 和 Java 最终生成的待签名字符串。
- 使用
bin2hex()比较两端字符串的真实字节。 - 比较签名前后的
biz_content,确认没有重复序列化。 - 检查签名是否被重复进行 URL 编码,以及
+是否变成了空格。 - 检查 OpenSSL 的返回值和错误队列。
排查的核心是比较“最终待签名字符串”,而不只是业务参数。很多看起来密钥和编码都没有问题的验签错误,最后都可以归结为配置对象用错、签名算法不一致,或者签名后参数值发生了变化。
备注:内容仅供参考。
