云端无法验证支付时,如何安全保存订单?
结论
如果支付处理器既不提供服务端交易查询 API,也不发送带有可验证签名的 webhook 或支付凭证,服务端就无法可靠确认支付是否真的发生。
手机是不可信环境。App 提交的 paid: true、交易编号、hash、HMAC,甚至加密后的数据,都不能单独作为出票依据。服务端必须从支付处理器处获得独立且可验证的支付结果。若现有方案无法提供这种能力,就应更换支付方案,或将订单保留为“待人工核验”,不能自动出票。
还要注意,Card.io 通常用于扫描和识别银行卡信息,本身未必是负责扣款和结算的 payment processor。支付流程是否安全,最终取决于实际执行扣款的支付服务商。
为什么当前 hash 方案无效
普通 hash 只能检查数据是否意外损坏,不能证明数据来自可信客户端。攻击者修改明文后,可以自行重新计算 hash:
hash("orderId=123&paid=true")
即使换成带密钥的 HMAC:
HMAC-SHA256(secret, message)
只要 secret 存在于移动 App 中,攻击者就可能通过反编译、动态调试、hook 或读取运行时内存取得它。有了密钥,攻击者便能为任意数据生成合法 HMAC。
HTTPS 仍然必不可少,但它保护的是传输过程:
- 防止网络中间人窃听或篡改请求;
- 验证客户端连接的是正确服务器;
- 无法阻止用户控制自己的手机并伪造 API 请求;
- 无法证明支付处理器确实完成了扣款。
算法是否公开并不重要。现代密码系统通常默认算法是公开的,安全性取决于密钥是否保密。这里的问题在于,客户端无法安全保存一个对所有订单都有授权能力的长期密钥。
推荐的支付确认流程
可靠的支付确认流程如下:
App → Server:创建订单
Server → Payment Processor:创建 payment intent / transaction
Server → App:返回短期支付参数
App → Payment Processor:完成支付交互
Payment Processor → Server:发送签名 webhook
Server → Payment Processor:必要时再次查询交易
Server:核对订单信息并标记为已支付
App → Server:查询订单最终状态
具体可以分为以下步骤。
1. 服务端创建订单
商品、价格、币种和库存都应由服务端确定,不能接受客户端直接指定的最终应付金额。
订单的初始状态应为:
PENDING_PAYMENT
App 报告“支付成功”时,服务端也不能直接把订单改成 PAID。
2. 服务端创建支付交易
服务端调用支付处理器的 API,创建一笔与订单绑定的交易,并保存:
- 内部
order_id; - 支付处理器的
payment_id; - 预期金额;
- 币种;
- 当前支付状态;
- 创建时间和过期时间。
如果支付处理器支持 metadata,可以把内部订单号写入交易 metadata,但不能只依赖客户端传回的订单号。
3. App 只负责支付交互
App 可以使用支付处理器提供的 SDK 展示支付页面、执行银行卡验证或完成 3-D Secure 流程。
客户端回调中的 success 只能用来改善界面体验,例如显示“支付结果确认中”,不能直接触发出票:
async function onClientPaymentSuccess() {
showMessage("支付结果确认中");
await refreshOrderStatus();
}
客户端也可以通知服务端“请尽快检查这笔交易”,但这个请求只能触发查询,不能作为支付证明。
4. 服务端通过 webhook 确认支付
支付处理器应直接把支付结果发送给服务端。服务端需要按照服务商文档验证 webhook 签名,并使用原始请求体参与验签。
下面是一个通用结构。实际使用时,header 名称和签名算法必须替换为支付处理器官方规定的格式:
import express from "express";
import crypto from "node:crypto";
const app = express();
app.post(
"/webhooks/payment",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = req.body;
const signature = req.get("X-Payment-Signature");
if (!signature || !verifyWebhook(rawBody, signature)) {
return res.sendStatus(400);
}
const event = JSON.parse(rawBody.toString("utf8"));
// 先记录 event.id,并建立唯一约束,避免重复处理 webhook。
const accepted = await registerEventOnce(event.id);
if (!accepted) {
return res.sendStatus(200);
}
if (event.type === "payment.succeeded") {
await confirmPaidOrder(event.data);
}
return res.sendStatus(200);
}
);
function verifyWebhook(rawBody, providedSignature) {
const expected = crypto
.createHmac("sha256", process.env.PAYMENT_WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const provided = Buffer.from(providedSignature, "hex");
const calculated = Buffer.from(expected, "hex");
return (
provided.length === calculated.length &&
crypto.timingSafeEqual(provided, calculated)
);
}
这里的 HMAC 是有效的,因为 PAYMENT_WEBHOOK_SECRET 只保存在支付处理器和服务端,不会下发到手机。如果服务商使用非对称签名,应使用其公开密钥验签,不要自行设计签名协议。
5. 核对交易,而不只是核对签名
签名有效只能说明事件来自支付处理器。更新订单前,还要核对以下信息:
payment_id是否与订单绑定;- 支付状态是否为处理器定义的最终成功状态;
- 实付金额是否等于订单金额;
- 币种是否一致;
- 商户账户是否正确;
- 交易是否已经退款、撤销或冲正;
- 同一个
payment_id是否已被其他订单使用; - webhook 是否重复发送。
状态更新应使用事务和条件更新:
UPDATE orders
SET
status = 'PAID',
paid_at = CURRENT_TIMESTAMP,
payment_id = :payment_id
WHERE id = :order_id
AND status = 'PENDING_PAYMENT'
AND amount_minor = :amount_minor
AND currency = :currency;
金额建议使用最小货币单位的整数,例如用分表示,不要使用浮点数:
{
"amount_minor": 1999,
"currency": "CNY"
}
只有成功更新一行后,才能进入出票流程。还应为 payment_id 和 webhook 的 event_id 建立唯一约束,以保证幂等性。
webhook 延迟时如何处理
客户端完成支付后,webhook 可能不会立刻到达。此时应返回“处理中”,不能提前把订单标记为已支付:
{
"order_id": "ord_123",
"status": "PAYMENT_PROCESSING"
}
App 可以轮询订单状态,也可以通过 WebSocket、Server-Sent Events 或推送通知接收更新。服务端还可以调用支付处理器的查询 API 主动核验,但查询结果仍须来自服务端与支付处理器之间的可信通信。
如果只能在手机上完成支付
只有满足以下任一条件,服务端才可能验证客户端提交的支付结果:
- 支付处理器签发了包含交易编号、金额、币种和商户信息的数字签名凭证,服务端可以使用处理器的公钥验证;
- 移动平台提供收据,服务端可以通过平台官方接口验证;
- 支付处理器提供服务端交易查询 API;
- 支付处理器向服务端发送带有可验证签名的 webhook。
服务端必须验证由支付处理器或平台签发的证明,而不是 App 自己签发的数据。
如果这些能力都不存在,那么握手、证书固定、代码混淆、设备指纹、root/jailbreak 检测和 App Attestation 都无法证明钱已经到账。这些措施可以增加攻击成本,减少自动化滥用,但不能代替支付验证。
还需要注意的安全问题
- 不要自行存储银行卡号、有效期或 CVV,优先使用支付服务商提供的 tokenization。
- 不要把支付处理器的服务端密钥放进 App。
- 不要让客户端决定价格、折扣、库存或支付状态。
- webhook 必须验证签名,并按照服务商要求检查时间戳和重放窗口。
- 出票流程必须支持幂等处理,避免重复 webhook 导致重复出票。
- 支付成功、出票成功和订单完成应使用不同状态,以便处理出票失败、退款和冲正。
- 如果业务涉及银行卡数据,应确认所选集成方式对应的 PCI DSS 责任范围。仅使用 HTTPS 并不代表已经满足合规要求。
服务端不能依赖用户控制的设备来证明用户已经付款。订单能否出票,必须以支付处理器、支付平台或银行提供的服务端可验证事实为准。
备注:内容仅供参考。
