支付宝V3 APP支付下单接口验签出错如何排查?
结论
在本地用应用私钥签名,并能通过应用公钥验签,只能说明这对密钥在数学上匹配,不能说明支付宝收到的签名请求没有问题。
V3 接口验签失败时,还需要检查这些环节:
- 当前
app_id配置的应用公钥是否与本地私钥对应。 - 正式环境与沙箱环境、不同应用或配置模式是否混用。
- V3 待签名字符串的格式、换行符、URI 或请求体是否正确。
- 签名后发送的 JSON,是否与参与签名的 JSON 保持完全相同的字节内容。
Authorization请求头中的签名是否正确完成 Base64 和 URL 编码。- 是否混用了普通公钥模式与证书模式,或者证书序列号计算有误。
- HTTP 客户端、中间件、网关或 Flask 代理是否修改了 URI、请求体或请求头。
- 使用的支付宝 SDK 是否兼容 Python 3.13,实际调用的是否为预期的 V3 签名实现。
排查时,不必反复验证”私钥和本地公钥是否成对”。更有用的做法是还原支付宝实际收到的 HTTP 请求,并逐字节核对待签名内容。
先确认应用和环境没有串用
依次核对:
- 请求中的
app_id是否属于当前应用。 - 本地私钥对应的应用公钥是否已经上传到这个
app_id。 - 上传公钥后,应用配置是否已经保存并生效。
- 正式网关
https://openapi.alipay.com是否使用正式应用的配置。 - 如果此前使用过沙箱,确认程序没有继续读取沙箱私钥、应用 ID 或证书。
- 在多配置、多租户或多进程部署中,确认运行时加载的配置与配置文件内容一致。
可以在日志中记录 app_id、网关域名和公钥指纹,但不要直接打印私钥。
使用 OpenSSL 从私钥重新导出公钥,再与支付宝开放平台中配置的应用公钥比较:
openssl pkey -in app_private_key.pem -pubout -out derived_public_key.pem
openssl pkey -pubin -in derived_public_key.pem -outform DER \
| openssl dgst -sha256
如果从平台复制的公钥没有 PEM 头尾,可以先整理为以下格式:
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
不要混淆”支付宝公钥”和”应用公钥”。应用私钥用于请求签名,支付宝保存应用公钥并用它验证请求。支付宝公钥主要供商户验证支付宝返回的数据。
核对 V3 待签名字符串
V3 接口并非只对业务 JSON 签名。待签名内容通常还包括:
- HTTP Method
- 请求 URI
app_idtimestampnonce- 请求体
以 APP 支付下单为例,请求 URI 应为:
/v3/alipay/trade/app/pay
不要把完整域名、查询参数、末尾多余的 / 或反向代理改写后的路径当成 URI。HTTP Method 一般使用大写的 POST。
V3 待签名字符串的具体拼接规则,应以当前支付宝官方文档或所用官方 SDK 的实现为准。排查时,要以可见转义形式打印最终的待签名字符串:
logger.debug("sign_content=%r", sign_content)
%r 能暴露一些不易察觉的问题:
- 使用了
\r\n,但协议要求\n。 - 多出一个空格或换行。
- URI 末尾多了
/。 - 请求体前后有空白字符。
timestamp或nonce与请求头中的值不一致。
只打印”签名成功”或签名结果,无法帮助定位问题。完整待签名字符串及其字节摘要更有排查价值。
确保签名和发送使用同一份请求体
这是手写 V3 调用时很常见的问题。
下面两段 JSON 的业务含义相同,字节内容却不同:
{"out_trade_no":"202609090001","total_amount":"0.01"}
{
"out_trade_no": "202609090001",
"total_amount": "0.01"
}
如果第一段参与签名,实际发送的却是第二段,支付宝验签一定会失败。字段顺序、空格、换行、中文转义方式或金额的数据类型发生变化,也会产生相同的问题。
请求体应只序列化一次,并让签名和 HTTP 请求共用同一个字符串:
import json
biz_content = {
"out_trade_no": "202609090001",
"total_amount": "0.01",
"subject": "测试订单",
"product_code": "QUICK_MSECURITY_PAY",
}
body = json.dumps(
biz_content,
ensure_ascii=False,
separators=(",", ":"),
)
body_bytes = body.encode("utf-8")
发送请求时使用 data=body_bytes。签名完成后,不要再通过 json=biz_content 让 HTTP 客户端重新序列化:
response = requests.post(
url,
data=body_bytes,
headers=headers,
timeout=30,
)
以下写法有风险:
# body 参与了签名,但 json=biz_content 会再次序列化
response = requests.post(
url,
json=biz_content,
headers=headers,
)
即使两次序列化目前碰巧得到相同结果,也不应依赖这种行为。
检查签名算法和编码过程
请求签名通常使用 SHA256withRSA,即 RSA PKCS#1 v1.5 加 SHA-256。不要误用:
- RSA-PSS
- SHA-1
- 对签名内容的十六进制摘要再次签名
- 对 Base64 文本签名,而不是对原始待签名字节签名
- 错误的字符编码
使用 cryptography 的基本签名示例如下:
import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
def sign_sha256withrsa(private_key_pem: bytes, sign_content: str) -> str:
private_key = serialization.load_pem_private_key(
private_key_pem,
password=None,
)
signature = private_key.sign(
sign_content.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
return base64.b64encode(signature).decode("ascii")
需要注意:
sign_content应使用 UTF-8 编码。- Base64 结果不能包含额外换行。
- 写入
Authorization请求头时,应按照 V3 协议正确转义。 - 不要擅自把普通 Base64 改成 Base64 URL-safe 编码。
- 不要对签名值重复进行 URL 编码。
手动拼接请求头时,+、/、= 等字符很容易被错误处理。优先使用支付宝当前维护的官方 SDK 生成认证头。如果必须自行实现,应对照官方协议检查线上最终发送的请求头,而不是只检查编码前的签名变量。
检查普通公钥模式与证书模式
这两种模式不能混用。
普通公钥模式通常需要:
- 应用私钥
- 支付宝公钥
- 平台中配置的应用公钥
证书模式通常还需要:
- 应用公钥证书
- 支付宝公钥证书
- 支付宝根证书
- 应用公钥证书序列号
- 支付宝根证书序列号
如果应用在开放平台启用了证书模式,请求认证信息中的证书序列号必须根据对应证书正确计算。常见错误有:
- 使用普通应用公钥生成序列号。
- 加载了其他应用的证书。
- 证书已经更新,服务仍在使用旧私钥或旧证书。
- 请求携带了
app_cert_sn,但它与当前应用证书不对应。 - 在普通公钥模式下错误加入证书模式参数。
本地使用私钥和”从同一文件夹读取的公钥”成功验签,无法排除这些问题。
记录最终请求,而不是业务对象
可以临时增加诊断日志,至少记录以下内容:
import hashlib
logger.info("method=%s", method)
logger.info("request_uri=%s", request_uri)
logger.info("app_id=%s", app_id)
logger.info("timestamp=%s", timestamp)
logger.info("nonce=%s", nonce)
logger.info("body_repr=%r", body)
logger.info(
"body_sha256=%s",
hashlib.sha256(body.encode("utf-8")).hexdigest(),
)
logger.info("sign_content_repr=%r", sign_content)
logger.info(
"sign_content_sha256=%s",
hashlib.sha256(sign_content.encode("utf-8")).hexdigest(),
)
还要在真正发送请求的位置确认:
- URL 中没有多余空格。题目展示的 URL 两侧存在空格,代码中必须去除。
Content-Type符合接口要求,通常为application/json。Authorization是最终发送的请求头,没有在生成后被中间件覆盖。timestamp、nonce和app_id与生成待签名字符串时完全一致。- 请求体长度和摘要在签名前后没有变化。
不要把私钥、完整签名、付款敏感信息或用户隐私写入生产日志。
排除 HTTP 客户端和中间层改写
如果代码中的数据都正确,可以捕获最终的出站请求进行比较。重点检查:
requests、httpx或 SDK 是否重新编码了 JSON。- Flask 扩展是否包装或替换了 HTTP 客户端。
- 代理是否修改了请求路径。
- 网关是否解压、压缩或重写了请求体。
- 请求重试时是否重新生成了时间戳或 nonce,却继续使用旧签名。
- Header 值是否因换行、非 ASCII 字符或长度限制而被截断。
使用 requests 时,可以先构造 PreparedRequest,再查看真正准备发送的内容:
import requests
request = requests.Request(
method="POST",
url="https://openapi.alipay.com/v3/alipay/trade/app/pay",
data=body_bytes,
headers=headers,
)
prepared = request.prepare()
print(prepared.method)
print(prepared.path_url)
print(repr(prepared.body))
print(dict(prepared.headers))
with requests.Session() as session:
response = session.send(prepared, timeout=30)
调试输出中应隐藏 Authorization 的完整签名和其他敏感数据。
检查 Python 3.13 兼容性
Python 3.13.5 和 Flask 3 本身通常不会改变 RSA 签名规则,但相关 SDK、加密库或 HTTP 库未必已经在该运行环境中完成兼容性验证。
先确认版本信息:
python --version
python -m pip show cryptography requests httpx
python -m pip show alipay-sdk-python
实际包名以项目使用的依赖为准。如果使用第三方支付宝 SDK,还要确认它是否真正支持 V3 接口。不能仅凭包名中含有 alipay,就认定它符合当前官方协议。
可以用一个受支持的 Python 版本建立最小环境进行对照测试。如果相同配置和请求体在另一个版本中能够成功,再检查 SDK 与加密依赖的兼容性。如果仍然失败,问题更可能出在应用配置或待签名内容上。
推荐的最小化排查顺序
- 重新确认网关、
app_id和运行时加载的私钥。 - 从私钥导出公钥,与当前应用实际配置的公钥比较。
- 确认当前使用的是普通公钥模式还是证书模式。
- 固定一份请求体字符串,只序列化一次。
- 打印
method、URI、timestamp、nonce和待签名字符串的%r。 - 使用同一份
body_bytes完成签名和发送。 - 检查最终的
PreparedRequest,确认请求头和请求体没有被修改。 - 使用官方 SDK 或官方示例,对同一组业务参数发起请求作为对照。
- 如果仍然失败,保留支付宝返回的错误码、请求 ID、调用时间和应用 ID,通过支付宝开放平台的技术支持渠道查询服务端验签日志。
应优先检查两点:签名使用的请求体是否与实际发送的请求体完全一致,以及私钥对应的公钥是否配置在当前请求所用的 app_id 下。
备注:内容仅供参考。
