When the system involves third-party payment callbacks (WeChat Pay, Alipay, etc.) or webhook notifications, prevent forgery, tampering, and replay attacks.
Pitfall #1: Callback does not verify platform signature
Scenario: After receiving a payment callback, directly decrypt/parse and process it without verifying that the request actually comes from the payment platform
Root cause
The payment callback URL is exposed on the public internet, so anyone can forge requests. Without signature verification, attackers can construct fake "payment successful" notifications.
Incorrect example
// ❌ 错误: 直接解密处理,未验签
@PostMapping("/payment/callback/wechat")
public String wechatCallback(@RequestBody String body) {
JsonNode json = objectMapper.readTree(body);
// 直接尝试解密 resource
String decrypted = decryptAesGcm(ciphertext, apiV3Key, nonce, aad);
processPayment(decrypted); // 谁发来的都处理
return "{\"code\":\"SUCCESS\"}";
}
Correct approach
// ✅ 正确: 先验签,再解密,再处理
@PostMapping("/payment/callback/wechat")
public String wechatCallback(
@RequestBody String body,
@RequestHeader("Wechatpay-Signature") String signature,
@RequestHeader("Wechatpay-Timestamp") String timestamp,
@RequestHeader("Wechatpay-Nonce") String nonce,
@RequestHeader("Wechatpay-Serial") String serial) {
// 1. 用微信支付平台公钥验签(不是商户私钥)
String message = timestamp + "\n" + nonce + "\n" + body + "\n";
PublicKey platformKey = loadPlatformPublicKey(serial);
if (!verifySignature(message, signature, platformKey)) {
return "{\"code\":\"FAIL\",\"message\":\"验签失败\"}";
}
// 2. 验签通过后再解密和处理
String decrypted = decryptAesGcm(ciphertext, apiV3Key, nonce, aad);
processPayment(decrypted);
return "{\"code\":\"SUCCESS\"}";
}
Key distinction
| Key/Certificate | Purpose | Holder |
|---|---|---|
| Merchant private key | Signing when the merchant requests WeChat | Merchant |
| Merchant API certificate | Merchant identity identifier | Merchant |
| WeChat Pay platform public key/certificate | Verify WeChat callback signature | Issued by the WeChat platform, the merchant holds the public key |
| API V3 Key | Decrypt callback notification body | Merchant |
Checklist
- Does the callback entry point verify the platform signature before decryption
- Does signature verification use the platform public key/certificate rather than the merchant private key
- Is it matched by
Wechatpay-Serialto the corresponding platform certificate (supporting certificate rotation) - If signature verification fails, is it directly rejected without further processing
Pitfall #2: No anti-replay validation
Scenario: An attacker intercepts a genuine payment success callback and repeatedly sends it to the system
Root cause
The signature of a legitimate old callback message is still valid; signature verification alone cannot prevent replay.
Incorrect example
// ❌ 错误: 验签通过就直接处理,不检查是否重复
if (verifySignature(message, signature, platformKey)) {
processPayment(decrypted); // 同一笔单可能被处理多次
}
Correct approach
// ✅ 正确: 三层防重放
// 第 1 层: 时间戳窗口
long callbackTime = Long.parseLong(timestamp);
long now = Instant.now().getEpochSecond();
if (Math.abs(now - callbackTime) > 300) { // 5 分钟窗口
return "{\"code\":\"FAIL\",\"message\":\"timestamp expired\"}";
}
// 第 2 层: 按 transaction_id 幂等
String transactionId = payData.get("transaction_id").asText();
if (paymentRecordRepository.existsByTransactionIdAndStatus(
transactionId, "SUCCESS")) {
log.info("重复通知,已处理: transactionId={}", transactionId);
return "{\"code\":\"SUCCESS\"}"; // 返回成功,让平台停止重试
}
// 第 3 层: 订单状态机幂等
TradeOrder order = orderRepository.findByOrderNumber(outTradeNo);
if (order.getPaymentStatus() == PaymentStatus.PAID) {
log.info("订单已支付,跳过: orderId={}", order.getId());
return "{\"code\":\"SUCCESS\"}";
}
Checklist
- Is the callback timestamp window validated (5 minutes recommended)
- Is idempotency deduplication performed by transaction_id
- Is the current order status checked (to prevent an already-paid order from being processed repeatedly)
- Do duplicate notifications return success (so the platform stops retrying)
Pitfall #3: Trusting the amount in the callback
Scenario: Directly using the amount in the callback message as the basis for crediting, without validating it against the local order amount
Root cause
Even if signature verification passes, consistency validation should be based on the local order amount to prevent order mismatches or, in extreme cases, amount inconsistency.
Incorrect example
// ❌ 错误: 直接用回调金额入账
int paidAmount = payData.get("amount").get("total").asInt();
order.setPaidAmount(paidAmount); // 不校验是否与下单金额一致
order.setStatus("PAID");
Correct approach
// ✅ 正确: 回调金额必须与本地订单金额严格一致
int callbackAmountFen = payData.get("amount").get("total").asInt();
int expectedAmountFen = order.getTotalAmount()
.multiply(BigDecimal.valueOf(100)).intValue();
if (callbackAmountFen != expectedAmountFen) {
log.error("金额不一致: callback={}, expected={}, orderId={}",
callbackAmountFen, expectedAmountFen, order.getId());
// 标记异常,不入账
paymentRecord.setReconcileStatus("MISMATCHED");
paymentRecord.setReconcileErrorMessage("金额不一致");
return;
}
Checklist
- Is the callback amount strictly compared with the local order amount payable
- Is the basis for amount comparison consistent with the order request (the same field)
- If the amounts are inconsistent, is crediting rejected and an exception logged
- Is there an encapsulated method that explicitly defines the "WeChat Pay amount basis" (to prevent later changes from causing divergent bases)
Pitfall #4: Callbacks and reconciliation use different validation logic
Scenario: The callback path has complete validation, but the "manual reconciliation" path skips some validation
Root cause
Reconciliation order supplementation and callback handling are essentially both "confirming payment success"; if they go through different validation logic, it is easy to leave a security gap in the reconciliation chain.
Incorrect example
// ❌ 错误: 回调有完整校验
public void handleCallback(JsonNode payData, TradeOrder order) {
validateAmount(payData, order);
validateMchId(payData, config);
validateAppId(payData, config);
processPayment(order, transactionId);
}
// ❌ 错误: 对账直接补单,跳过校验
public void reconcile(PaymentRecord record) {
JsonNode queryResult = queryWechatOrder(outTradeNo);
if ("SUCCESS".equals(tradeState)) {
processPayment(order, transactionId); // 没有校验金额/商户号
}
}
Correct approach
// ✅ 正确: 抽取统一校验函数,回调和对账都复用
public void validatePaymentResult(
JsonNode payData, TradeOrder order, TenantConfig config) {
// 1. out_trade_no 一致
// 2. transaction_id 非空
// 3. appid 一致
// 4. mchid 一致
// 5. 金额一致
// 任何一项不通过都抛异常
}
// 回调链路
public void handleCallback(JsonNode payData, TradeOrder order, TenantConfig config) {
validatePaymentResult(payData, order, config);
processPayment(order, transactionId);
}
// 对账链路
public void reconcile(PaymentRecord record) {
JsonNode queryResult = queryWechatOrder(outTradeNo);
validatePaymentResult(queryResult, order, config); // 同一套校验
processPayment(order, transactionId);
}
Checklist
- Whether callbacks and reconciliation reuse the same set of payment result validation logic
- Whether reconciliation order supplementation also validates the amount, merchant number, and AppID
- Whether "manually clicking the reconciliation button" itself does not count as a basis for payment success
- Whether reconciliation only trusts the real response from the payment platform's order query API
Pitfall #5: Placeholder/TODO implementation goes live
Scenario: Critical logic such as payment queries and reconciliation uses placeholder implementations, but goes live without being discovered
Incorrect example
// ❌ 错误: 占位实现,返回假数据
private JsonNode queryWechatOrderStatus(PaymentRecord record) {
// TODO: 接入微信支付订单查询 V3 API
log.warn("微信订单查询 API 待实现: orderId={}", record.getOrderId());
return objectMapper.createObjectNode()
.put("trade_state", "NOTPAY")
.put("trade_state_desc", "未支付(占位)");
}
Correct approach
// ✅ 正确: 占位实现必须明确失败,不能返回看似正常的假数据
private JsonNode queryWechatOrderStatus(PaymentRecord record) {
throw new UnsupportedOperationException(
"微信订单查询 API 未实现,请先完成接入");
}
Checklist
- Whether payment-related TODO/placeholder implementations throw exceptions instead of returning fake data
- Whether there are logs or monitoring that can detect when placeholder logic is triggered
- Whether all TODOs in the payment chain were checked before going live
Checklist (payment callback security)
Signature verification and anti-forgery:
- Whether the callback verifies the platform signature
- Whether signature verification uses the platform public key/certificate (not the merchant private key)
- Whether platform certificate rotation is supported (matched by serial)
Anti-replay:
- Whether the timestamp window is validated
- Whether idempotency is implemented by transaction_id
- Whether the order state machine is checked
Business validation:
- Whether the amount strictly matches the local order
- Whether out_trade_no matches the local order number
- Whether appid / mchid match the tenant configuration
- Whether callbacks and reconciliation reuse the same set of validation
Implementation completeness:
- Whether the payment chain has placeholder/TODO implementations
- Whether placeholder implementations fail explicitly (rather than returning fake data)
Scope of application
- WeChat Pay API v3
- Alipay Open Platform
- Stripe Webhooks
- PayPal IPN/Webhooks
- Other third-party payment/webhook notifications
Rule traceability
> 📋 本回复遵循:`payment-callback-safety` - [章节名]