PAYATHON 2026

支付回调验证签名一直返回 false,官方 SDK 近期失效

支付小周

结论

这种情况通常不是”官方 SDK 突然失效”,而是验签所用的原始数据、证书或密钥发生了变化。常见原因包括:

  • 框架解析、转码或重新序列化了回调请求体,导致它不再是支付平台发送的原始字节。
  • 支付平台轮换了平台证书,本地仍在使用旧证书。
  • 商户私钥、平台公钥、API 密钥等用途不同的密钥被混用。
  • 回调协议或签名算法已经升级,项目仍在使用旧版 SDK 或旧验签接口。
  • 时间戳、随机串、签名值等请求头缺失,或者拼接顺序、换行符有误。
  • 网关、代理或日志过滤器提前读取并修改了请求体。
  • 实际收到的是测试请求、重复通知或其他类型的通知,并非成功支付回调。

排查时先不要猜测 SDK 是否有问题。保存一份完整的原始回调请求,再对照支付平台的当前文档逐项核对。

先确认验签需要哪些数据

各支付平台的验签规则不同,常见输入包括:

  • HTTP 原始请求体
  • 时间戳
  • 随机串
  • 签名值
  • 证书序列号
  • 签名算法
  • 平台公钥或平台证书

验签必须使用回调请求中的原始值,不能先把 JSON 解析成对象再重新序列化。即使字段和值完全一致,只要字段顺序、空格、转义字符或换行符发生变化,验签就可能失败。

例如,下面两个 JSON 的业务含义相同,但对应的签名字节不同:

{"amount":100,"status":"SUCCESS"}
{
  "status": "SUCCESS",
  "amount": 100
}

如果支付平台签名的是第一段内容,使用第二段内容验签一定会返回 false。

按顺序排查

1. 保存原始请求信息

在 JSON 解析、参数绑定和业务处理之前,记录以下信息:

HTTP method
Request URI
Content-Type
Timestamp header
Nonce header
Signature header
Certificate serial number
Raw request body

在生产环境记录回调内容时应做好脱敏,不要输出私钥、API 密钥、完整银行卡号或其他敏感信息。

请求体如果已经被过滤器、拦截器或框架读取,控制器再次读取时可能得到空内容。此时应使用支持重复读取的请求包装器,或者由最前面的过滤器缓存原始字节。

2. 不要使用解析后的对象验签

错误做法:

PaymentNotification notification = objectMapper.readValue(body, PaymentNotification.class);
String rebuiltBody = objectMapper.writeValueAsString(notification);

// rebuiltBody 可能已经改变,不能保证与原始回调正文一致
boolean valid = verify(rebuiltBody, signature);

正确的处理方式是先保留原始正文并完成验签,成功后再进行解析:

byte[] rawBody = request.getInputStream().readAllBytes();
String body = new String(rawBody, StandardCharsets.UTF_8);

boolean valid = verifyWithOfficialSdk(
    timestamp,
    nonce,
    body,
    signature,
    certificateSerialNumber
);

if (!valid) {
    throw new SecurityException("Invalid payment callback signature");
}

PaymentNotification notification =
    objectMapper.readValue(body, PaymentNotification.class);

这里的 verifyWithOfficialSdk 仅用于说明流程。实际方法名和参数顺序应以对应支付平台及所用 SDK 版本的文档为准。

3. 检查平台证书是否轮换

如果验签之前一直正常,最近才开始全部失败,应优先检查平台证书或平台公钥。

需要确认:

  • 回调携带的证书序列号是否与本地证书匹配。
  • 本地证书是否已过期。
  • 平台是否发布了新证书。
  • 自动下载或更新证书的任务是否执行失败。
  • 多实例部署中是否只有部分实例更新了证书。
  • 容器镜像、配置中心或密钥管理服务中是否仍保存着旧证书。
  • 灰度环境和正式环境是否加载了不同证书。

不要只凭文件名判断证书,可以通过以下命令查看证书序列号和有效期:

openssl x509 -in platform_cert.pem -noout -serial -dates -subject

如果平台允许多张证书同时有效,应根据回调中的证书序列号选择对应证书,不能始终使用固定的一张。

4. 确认没有混用密钥

支付系统通常会同时使用多种凭据:

  • 商户私钥:通常用于给商户请求签名。
  • 商户公钥或商户证书:通常供平台验证商户签名。
  • 平台公钥或平台证书:通常供商户验证平台回调。
  • API 密钥:可能用于报文解密或派生密钥,不一定用于验签。
  • Webhook Secret:部分平台使用 HMAC 验证回调。

验签时必须使用平台指定的密钥。如果把商户公钥、商户证书或 API 密钥传给需要平台公钥的接口,通常只会得到 false,未必会抛出明显异常。

还要检查测试环境与生产环境是否发生混用。测试环境的回调不能使用生产环境的平台证书验证,反过来也一样。

5. 检查签名串的拼接方式

如果 SDK 要求分别传入时间戳、随机串和正文,就应原样传递,不要先自行拼接,再把结果交给 SDK。

如果协议要求手动构造待验签字符串,必须严格按照平台文档处理。例如,某些协议可能使用以下格式:

timestamp + "\n" + nonce + "\n" + body + "\n"

常见错误包括:

  • 漏掉最后一个 \n
  • 使用了 \r\n
  • 参数顺序错误
  • 对正文调用了 trim()
  • 对签名字符串进行了 URL 解码
  • Base64 解码次数不正确
  • 时间戳转成数字后格式发生变化
  • 请求头值含有未处理的前后空白

上面的拼接格式只是常见示例,并非所有支付平台都采用这一规则。具体格式应以目标平台的当前文档为准。

6. 检查 SDK、协议和运行环境

需要确认项目实际加载的 SDK 版本,不能只看构建文件中的版本声明。Java 项目可以检查依赖树:

mvn dependency:tree

或:

./gradlew dependencies

重点检查以下问题:

  • 项目是否同时引入了新旧两个 SDK。
  • 其他依赖是否传递引入了旧版本。
  • 回调接口是否已经从旧协议迁移到新协议。
  • SDK 是否改用了新的证书管理方式。
  • JDK、OpenSSL 或加密 Provider 升级后,算法支持是否发生变化。
  • 测试配置是否覆盖了生产环境配置。
  • 配置文件中的 PEM 内容是否因换行、转义或环境变量注入而损坏。

不要在没有依据时直接升级到最新版。先查看当前版本对应的更新说明和迁移文档,再在测试环境中使用真实保存的回调样本做回归验证。

手动验签可用于定位问题

如果平台使用 SHA256withRSA,可以绕过 SDK 做一次对照验证,从而判断问题出在数据或证书,还是 SDK 的调用方式。

import java.nio.charset.StandardCharsets;
import java.security.PublicKey;
import java.security.Signature;
import java.util.Base64;

public static boolean verifySha256WithRsa(
        PublicKey platformPublicKey,
        String message,
        String base64Signature) throws Exception {

    Signature verifier = Signature.getInstance("SHA256withRSA");
    verifier.initVerify(platformPublicKey);
    verifier.update(message.getBytes(StandardCharsets.UTF_8));

    byte[] signatureBytes = Base64.getDecoder().decode(base64Signature);
    return verifier.verify(signatureBytes);
}

调用时,message 必须完全按照支付平台规定的格式构造:

String message =
        timestamp + "\n" +
        nonce + "\n" +
        rawBody + "\n";

boolean valid = verifySha256WithRsa(
        platformPublicKey,
        message,
        signatureHeader
);

这段代码只适用于明确采用 RSA 和 SHA256withRSA 的协议。如果平台使用 RSA-PSS、ECDSA、HMAC 或其他算法,不能直接套用。

可以根据结果进一步判断:

  • SDK 和手动验签都失败:优先检查原始正文、证书、签名串和环境。
  • 手动验签成功,但 SDK 失败:检查 SDK 版本、参数顺序、证书加载方式和接口选择。
  • 同一份请求有时成功、有时失败:检查负载均衡后各实例的配置,以及证书是否完成同步。
  • 历史请求成功,新请求失败:重点检查证书轮换或回调协议变化。

还要检查代理和中间件

如果回调会经过 CDN、WAF、API 网关或反向代理,应确认这些组件没有执行以下操作:

  • 解压或重新压缩请求体
  • 改写字符编码
  • 修改请求头名称或值
  • 删除签名相关请求头
  • 将请求体转换成表单参数
  • 重新生成 JSON
  • 限制请求体长度
  • 提前消费请求流

可以分别在网关入口和应用入口记录原始正文的 SHA-256 摘要。如果两个摘要不同,说明请求在传输链路中发生了变化:

MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(rawBody);
String bodyHash = HexFormat.of().formatHex(hash);

与完整记录支付回调正文相比,记录摘要更安全,也便于比较不同节点收到的数据是否一致。

推荐的处理流程

回调入口应按以下顺序处理:

读取并缓存原始请求体
        ↓
读取签名相关请求头
        ↓
按证书序列号选择平台证书
        ↓
调用官方 SDK 验签
        ↓
验签成功后解密或解析业务数据
        ↓
校验商户号、订单号、金额和状态
        ↓
执行幂等业务处理
        ↓
返回平台要求的成功响应

验签成功不代表回调可以直接入账。系统还需要校验商户号、订单金额、币种和订单状态等业务字段,并使用支付单号或平台通知编号进行幂等控制。

如果经过上述排查仍无法定位问题,可以准备一份脱敏后的失败样本,内容包括原始请求体、签名相关请求头、证书序列号、SDK 名称及实际版本、运行时版本和手动验签结果,然后提交给支付平台技术支持。不要提供任何私钥或 API 密钥。

备注:内容仅供参考。