PAYATHON 2026

支付宝PHP版SDK支付签名及验签失败的影响因素

支付阿杰

结论

文件编码和密钥是否匹配只是排查方向之一。PHP SDK 提示“验签出错”,常见原因包括:

  • 应用私钥、应用公钥和支付宝公钥的配置位置混淆
  • sign_type 与实际签名算法不一致,例如配置为 RSA2,代码却使用 SHA-1
  • 待签名字符串在签名前后发生了变化
  • 签名被重复进行 URL 编码,或者 Base64 中的 + 在传输时变成了空格
  • PHP 与 Java 对 JSON、数字和空值的处理不同
  • 私钥的 PEM 格式、换行或 OpenSSL 环境有问题
  • 应用、网关或环境配置错误

同一组业务参数在 Java SDK 中可以成功,并不能证明 PHP 端的配置也正确。排查时,应分别比较两端的“最终待签名字符串”和“原始 Base64 签名”,不要只比较最终请求 URL。

首先确认三类密钥没有混用

支付宝开放平台接口通常涉及三类密钥:

  1. 应用私钥:由商户保存,用来生成请求签名。
  2. 应用公钥:上传到支付宝开放平台,支付宝用它验证商户请求。
  3. 支付宝公钥:从开放平台获取,商户用它验证支付宝响应或异步通知。

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,就把应用公钥和支付宝公钥当成同一项。

建议的排查顺序

  1. 确认 PHP 实际使用的 app_id、网关和运行环境。
  2. 确认 rsaPrivateKey 中保存的是应用私钥。
  3. 确认与该私钥对应的应用公钥已经上传到同一个 app_id。
  4. 确认 alipayrsaPublicKey 中填写的是支付宝公钥。
  5. 确认请求声明的是 RSA2,实际签名使用的是 OPENSSL_ALGO_SHA256。
  6. 固定一组参数,输出 PHP 和 Java 最终生成的待签名字符串。
  7. 使用 bin2hex() 比较两端字符串的真实字节。
  8. 比较签名前后的 biz_content,确认没有重复序列化。
  9. 检查签名是否被重复进行 URL 编码,以及 + 是否变成了空格。
  10. 检查 OpenSSL 的返回值和错误队列。

排查的核心是比较“最终待签名字符串”,而不只是业务参数。很多看起来密钥和编码都没有问题的验签错误,最后都可以归结为配置对象用错、签名算法不一致,或者签名后参数值发生了变化。

备注:内容仅供参考。