支付宝沙箱预支付报 isv.invalid-signature 如何解决?
结论
从返回信息判断,问题大概率不在 RSA2 密钥,而在请求参数的实际传输结果。
支付宝网关返回的验签字符串只有:
app_id=9021000142602452&charset=UTF-8&method=alipay.trade.precreate&sign_type=RSA2×tamp=2024-11-27 18:40:16&version=1.0
这里缺少 biz_content。如果本地签名原文包含 biz_content,支付宝网关却没有解析到这个参数,双方使用的签名原文就不一致,因此会返回 isv.invalid-signature。
排查时应优先确认:
biz_content是否确实作为请求参数发送;- 请求是否使用
application/x-www-form-urlencoded; - 参数是否在签名后被修改;
sign是否经过正确的 URL 编码,尤其是其中的+;- 是否存在重复 URL 编码;
- 是否混用了沙箱应用、正式应用或其他应用的密钥。
为什么本地验证一致,支付宝仍然验签失败
“本地签名结果与工具一致”只能说明:
- 当前私钥可以正常签名;
- 对当前这份本地字符串,签名算法和结果一致。
这并不能证明支付宝收到的参数与本地签名时使用的参数完全相同。
支付宝验签时会:
- 接收并解析 HTTP 请求参数;
- 排除
sign和空值参数; - 按参数名排序;
- 根据实际收到的参数值重新生成待验签字符串;
- 使用应用配置的公钥验证
sign。
请求在传输过程中只要发生参数丢失、编码变化或参数值被修改,即使私钥正确,验签仍会失败。
本次返回中的”网关生成的验签字符串”直接反映了支付宝实际参与验签的参数,而不是客户端认为自己发送了哪些参数。该字符串缺少 biz_content,所以应先检查请求的构造和提交方式。
响应中的 & 是 JSON 文本经过 HTML 展示后的转义,实际分隔符仍然是 &。不要把 & 当成签名原文的一部分。
正确的请求结构
调用 alipay.trade.precreate 时,biz_content 应作为公共请求参数之一发送,其值是 JSON 字符串。例如:
app_id=9021000142602452
method=alipay.trade.precreate
format=JSON
charset=UTF-8
sign_type=RSA2
timestamp=2024-11-27 18:40:16
version=1.0
biz_content={"out_trade_no":"202411270001","total_amount":"0.01","subject":"沙箱测试订单"}
sign=BASE64_SIGNATURE
请求通常通过 POST 表单提交:
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
不要将 biz_content 只放在普通 JSON 请求体中,同时又把公共参数放入 URL 查询字符串。支付宝网关能否从 JSON 请求体解析这些参数,取决于接口和 SDK 的调用方式。对于传统的支付宝 OpenAPI 网关调用,建议按表单参数提交,或者直接使用官方 SDK。
解决步骤
1. 对比网关验签字符串和本地签名原文
签名前记录完整的待签名字符串,但不要记录私钥。正常情况下,字符串中应包含 biz_content:
app_id=9021000142602452&biz_content={"out_trade_no":"202411270001","subject":"沙箱测试订单","total_amount":"0.01"}&charset=UTF-8&method=alipay.trade.precreate&sign_type=RSA2×tamp=2024-11-27 18:40:16&version=1.0
如果本地字符串包含 biz_content,错误响应中的网关字符串却没有,说明支付宝网关没有正确接收到该参数。
2. 检查 Content-Type
错误示例:
Content-Type: application/json
{
"biz_content": {
"out_trade_no": "202411270001",
"total_amount": "0.01",
"subject": "沙箱测试订单"
}
}
推荐使用以下传输形式:
Content-Type: application/x-www-form-urlencoded;charset=UTF-8
app_id=9021000142602452&method=alipay.trade.precreate&...&biz_content=%7B%22out_trade_no%22%3A...
这里展示的是 HTTP 传输后的编码结果。生成签名时,应使用 URL 编码前的参数值。
3. 确保签名后参数不再变化
以下情况都会导致验签失败:
- 签名后重新序列化
biz_content; - 修改字段顺序、空格、转义符或数字格式;
- 签名时金额是
"0.01",发送时变成0.01; - 签名时包含某个可选参数,发送时却被 HTTP 客户端过滤;
- 签名时没有某个参数,发送时拦截器又自动添加;
- 签名使用 UTF-8,发送时却采用其他字符集。
两个 JSON 对象在业务语义上可能相同,但签名校验比较的是字符串对应的字节。例如,下面两个值不能混用:
{"subject":"测试","total_amount":"0.01"}
{ "total_amount": "0.01", "subject": "测试" }
应先将 biz_content 序列化为最终字符串,再用同一个字符串完成签名和发送。
4. 正确处理 URL 编码
签名流程应为:
原始参数值
→ 排序并拼接待签名字符串
→ RSA2 签名
→ Base64
→ 对整个表单进行 URL 编码
→ 发送请求
不要先对 biz_content 进行 URL 编码,再使用编码后的 %7B...%7D 内容签名,除非所用 SDK 明确要求这样处理。
sign 是 Base64 字符串,可能包含:
+ / =
如果手工拼接请求却没有进行 URL 编码,表单解析时可能将 + 转换为空格,导致签名损坏。应交给 HTTP 表单库统一编码,避免手工拼接。
5. 确认没有重复编码
重复编码也很常见。例如,{ 第一次编码后是 %7B,再次编码就会变成 %257B。
错误方式:
String encodedBizContent =
URLEncoder.encode(bizContent, StandardCharsets.UTF_8);
form.put("biz_content", encodedBizContent);
// HTTP 客户端随后又对 form 进行一次编码
正确方式是将原始 JSON 字符串交给表单编码器:
form.put("biz_content", bizContent);
由 HTTP 客户端在发送时统一编码一次。
6. 核对沙箱环境和应用密钥
修复参数传输问题后如果仍然验签失败,再检查以下对应关系:
- 请求地址是否为支付宝沙箱网关;
app_id是否属于当前沙箱应用;- 签名私钥是否属于该应用当前配置的密钥对;
- 支付宝开放平台配置的是否为”应用公钥”,而不是”支付宝公钥”;
- 当前调用方式是普通公钥模式还是证书模式;
- 是否刚更换过密钥,但程序仍在读取旧配置;
- 私钥内容是否因环境变量、换行或配置中心转义而改变。
在普通公钥模式下,客户端使用应用私钥签名,支付宝使用开发者上传的应用公钥验签。支付宝公钥主要用于客户端验证支付宝返回结果的签名,不能与应用公钥混用。
Java 示例
建议优先使用支付宝官方 SDK,由 SDK 处理参数排序、签名和表单编码:
AlipayClient alipayClient = new DefaultAlipayClient(
"https://openapi-sandbox.dl.alipaydev.com/gateway.do",
appId,
appPrivateKey,
"json",
"UTF-8",
alipayPublicKey,
"RSA2"
);
AlipayTradePrecreateRequest request =
new AlipayTradePrecreateRequest();
Map<String, Object> biz = new LinkedHashMap<>();
biz.put("out_trade_no", "202411270001");
biz.put("total_amount", "0.01");
biz.put("subject", "沙箱测试订单");
String bizContent = objectMapper.writeValueAsString(biz);
request.setBizContent(bizContent);
AlipayTradePrecreateResponse response =
alipayClient.execute(request);
System.out.println(response.getCode());
System.out.println(response.getSubCode());
System.out.println(response.getSubMsg());
如果需要手工调用,可以按以下方式组织参数。重点是待签名原文和实际发送参数必须来自同一份数据:
Map<String, String> params = new TreeMap<>();
params.put("app_id", appId);
params.put("method", "alipay.trade.precreate");
params.put("format", "JSON");
params.put("charset", "UTF-8");
params.put("sign_type", "RSA2");
params.put("timestamp", "2024-11-27 18:40:16");
params.put("version", "1.0");
String bizContent =
"{\"out_trade_no\":\"202411270001\","
+ "\"total_amount\":\"0.01\","
+ "\"subject\":\"沙箱测试订单\"}";
params.put("biz_content", bizContent);
String contentToSign = params.entrySet().stream()
.filter(entry ->
entry.getValue() != null && !entry.getValue().isEmpty())
.map(entry -> entry.getKey() + "=" + entry.getValue())
.collect(Collectors.joining("&"));
String sign = rsa2Sign(contentToSign, appPrivateKey, "UTF-8");
params.put("sign", sign);
// 将 params 交给 application/x-www-form-urlencoded 表单编码器。
// 不要提前单独编码 biz_content 或 sign。
rsa2Sign 应使用 SHA256withRSA,再对签名结果进行 Base64 编码:
static String rsa2Sign(
String content,
PrivateKey privateKey,
String charset) throws Exception {
Signature signature = Signature.getInstance("SHA256withRSA");
signature.initSign(privateKey);
signature.update(content.getBytes(charset));
return Base64.getEncoder().encodeToString(signature.sign());
}
实际项目还需要正确解析 PKCS#8 私钥并处理异常。如果官方 SDK 已经包含这些功能,不建议自行实现完整的签名流程。
如何快速确认问题已经修复
重新发起请求后,检查错误响应或请求日志:
- 支付宝网关的验签字符串中应包含
biz_content; - 网关字符串中的
biz_content应与本地签名原文逐字符一致; sign传输后不应包含空格;- 请求体中不应出现
%257B、%2522等重复编码特征; - 请求使用的
app_id、应用私钥和平台配置的应用公钥必须属于同一密钥对。
根据当前错误信息,最先应做的是将 biz_content 作为 application/x-www-form-urlencoded 表单参数发送,并确保签名和发送使用同一个、未经提前 URL 编码的 biz_content 字符串。
备注:内容仅供参考。
