Payment Gateway Integration 中如何验证 return URL 响应
结论
不能只凭 unique reference no 判断支付是否成功。它只能用来关联订单,不能证明请求来自支付网关。即便这个值足够随机,只要它曾出现在浏览器、表单或跳转 URL 中,用户就可能获取并重放它。
安全的实现需要遵循两条原则:
- 浏览器访问的 return URL 一律视为不可信输入,不能用它直接修改订单状态。
- 支付结果必须通过支付网关提供的服务端机制确认。优先使用带签名的 webhook,必要时再由服务器调用网关的订单查询 API 复核。
只有签名验证通过,而且商户订单号、网关交易号、金额、币种、商户身份和支付状态都匹配,才能把订单更新为已支付。
reference no 为什么不够安全
reference no 通常只是订单或支付请求的标识符。它能帮助系统找到对应记录,但不具备身份认证能力。
攻击者可能从这些位置获得它:
- HTML hidden field;
- 浏览器地址栏或跳转参数;
- 浏览器开发者工具;
- 前端 JavaScript;
- Referer、访问日志或代理日志;
- 重复提交或历史请求。
因此,下面的判断并不安全:
request.reference_no == session.reference_no
它最多只能说明请求中带有预期的编号,无法证明:
- 请求确实由支付网关发出;
- 该订单已经付款;
- 支付金额和币种正确;
- 参数在传输过程中没有被修改;
- 同一个成功响应没有被重复使用。
即使 reference no 是高强度随机值,也只能降低被猜中的概率,不能代替签名验证和服务端查询。
推荐的支付确认流程
1. 在服务端创建支付记录
客户端只提交商品或订单信息。订单号、金额、币种和待支付状态应由服务端生成并保存。不要相信客户端传来的价格。
order_id: ORD-20260901-8F3A...
amount: 19900
currency: CNY
status: pending
金额建议使用最小货币单位。例如,19900 表示 199.00 CNY,这样可以避免浮点数误差。
2. 将用户重定向到支付网关
服务端根据数据库中的订单生成支付请求。如果网关要求对请求签名,应严格按照官方文档规定的字段顺序、编码方式和算法生成签名。
此时,return_url 只负责把用户带回结果页面,例如:
https://merchant.example.com/payment/return
用户回到这个页面,并不代表付款已经成功。
3. 接收服务端 webhook
支付完成后,网关通常会向商户服务器发送 webhook,也可能使用以下名称:
- callback;
- notification;
- IPN;
- server-to-server notification。
收到通知后,应先读取原始请求体并验证签名,然后再解析和处理业务字段。签名验证失败时应直接拒绝请求,不能更新订单。
4. 核对支付数据
签名有效后,还要逐项核对:
merchant_id是否属于当前商户;order_id是否存在;- 订单当前状态是否允许付款;
- 通知中的金额是否等于订单金额;
- 币种是否一致;
- 网关交易号是否有效,且没有绑定其他订单;
- 状态是否为网关定义的最终成功状态;
- 必要时检查支付环境,例如 sandbox 与 production;
- 必要时调用网关查询 API 再次确认。
不能只检查 status=success 之类的单个参数。
5. 以幂等方式更新订单
支付网关可能重复发送通知,因此 webhook 处理必须具备幂等性:
- 已支付订单再次收到相同通知时返回成功;
- 不重复发货;
- 不重复增加余额;
- 不重复发送权益;
- 为网关交易号设置唯一约束。
订单状态更新和后续业务记录最好放在同一个数据库事务中。
6. return URL 只展示结果
用户返回站点后,return URL 应根据订单号查询服务器中保存的实际订单状态:
- 已收到并确认 webhook:显示支付成功;
- 尚未确认:显示“支付结果确认中”;
- 已失败或已关闭:显示对应状态。
不要根据 return URL 中的查询参数把订单改为已支付。Webhook 和浏览器跳转的到达顺序并不固定,用户先回到页面时看到“确认中”是正常的。
通用的 HMAC 验签示例
下面的示例只用于说明验证思路,不代表任何具体支付网关的参数格式。实际项目必须使用网关官方文档规定的签名字段、拼接顺序、字符编码、摘要算法和密钥类型。
import express from "express";
import crypto from "node:crypto";
const app = express();
// 某些网关要求对原始请求体计算 HMAC。
// express.json() 会改变可用于验签的数据形态,因此这里保留 Buffer。
app.post(
"/payment/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.get("X-Payment-Signature");
if (!signature || !verifyHmac(req.body, signature)) {
return res.status(401).send("invalid signature");
}
let event;
try {
event = JSON.parse(req.body.toString("utf8"));
} catch {
return res.status(400).send("invalid payload");
}
const {
event_id,
merchant_id,
order_id,
transaction_id,
amount,
currency,
status
} = event;
if (merchant_id !== process.env.PAYMENT_MERCHANT_ID) {
return res.status(400).send("invalid merchant");
}
try {
await database.transaction(async (tx) => {
// 应使用行锁或等效的并发控制。
const order = await tx.getOrderForUpdate(order_id);
if (!order) {
throw new Error("order not found");
}
if (
order.amount !== amount ||
order.currency !== currency
) {
throw new Error("payment details mismatch");
}
// event_id 和 transaction_id 应在数据库中建立唯一约束。
if (await tx.hasProcessedEvent(event_id)) {
return;
}
if (
status === "PAID" &&
order.status === "pending"
) {
await tx.markOrderPaid({
orderId: order_id,
transactionId: transaction_id,
paidAt: new Date()
});
await tx.grantPurchasedItems(order_id);
}
await tx.markEventProcessed(event_id);
});
} catch (error) {
// 应记录必要的审计信息,但不要记录密钥、完整卡号等敏感数据。
return res.status(400).send("payment rejected");
}
// 使用网关文档要求的状态码和响应正文。
return res.status(200).send("ok");
}
);
function verifyHmac(rawBody, receivedSignature) {
const expected = crypto
.createHmac("sha256", process.env.PAYMENT_WEBHOOK_SECRET)
.update(rawBody)
.digest("hex");
const received = Buffer.from(receivedSignature, "hex");
const calculated = Buffer.from(expected, "hex");
return (
received.length === calculated.length &&
crypto.timingSafeEqual(received, calculated)
);
}
如果网关使用 RSA、ECDSA 或证书签名,应使用网关提供的公钥进行验证,不能自行改用 HMAC。还要注意,有些网关签署原始请求体,有些网关签署经过规范化排序的字段,这两种方式不能混用。
return URL 的安全处理示例
return URL 可以接收订单标识,但只能查询服务端状态:
app.get("/payment/return", async (req, res) => {
const orderId = String(req.query.order_id || "");
// 这里还应验证当前登录用户是否有权查看该订单。
const order = await database.getOrder(orderId);
if (!order || order.userId !== req.user.id) {
return res.status(404).send("order not found");
}
if (order.status === "paid") {
return res.send("支付成功");
}
if (order.status === "failed") {
return res.send("支付失败");
}
return res.send("支付结果确认中,请稍后刷新");
});
如果网关没有 webhook,return URL 仍然不能自行认定支付成功。服务器应使用订单号或网关返回的支付标识调用支付查询 API,并以查询结果为准:
const payment = await gateway.queryPayment({
merchantOrderId: order.id
});
if (
payment.status === "PAID" &&
payment.amount === order.amount &&
payment.currency === order.currency &&
payment.merchantId === configuredMerchantId
) {
await markOrderPaidIdempotently(order, payment.transactionId);
}
查询 API 的认证凭据只能保存在服务器端,不能发送到浏览器。
还需要防范的情况
重放攻击
签名正确的旧通知仍可能被重新发送。系统应保存 event_id 或 transaction_id,并设置唯一约束。如果协议包含时间戳和 nonce,还应按照官方要求验证有效时间窗口,并拒绝重复的 nonce。
不过,不要随意给 webhook 设置过短的时间窗口。网络故障可能导致网关延迟重试,具体设置应以该网关的重试机制为准。
金额篡改
订单金额必须以服务端数据库中的记录为准。即使通知签名有效,也要核对通知中的金额和币种,因为一笔真实但金额不同的支付不能用于结算当前订单。
并发通知
Webhook、return URL 主动查询和后台对账可能同时处理同一笔支付。应使用数据库事务、行锁、条件更新或唯一约束,保证状态只更新一次。例如:
UPDATE orders
SET status = 'paid', transaction_id = :transaction_id
WHERE id = :order_id
AND status = 'pending';
执行后还要检查受影响的行数,避免重复触发发货逻辑。
CSRF 与开放回调地址
如果 return URL 只用于读取和展示状态,CSRF 风险相对有限。任何会改变订单状态的接口仍需做好权限校验和请求来源控制。不过,CSRF token 不能证明请求来自支付网关,因此无法代替 webhook 签名。
Webhook 通常不能使用用户会话或普通 CSRF token,应依靠网关签名完成身份验证。
IP 白名单
如果网关公布了稳定的回调 IP 范围,可以把 IP 白名单作为附加防护,但不能把它作为唯一的验证方式。代理、负载均衡、IPv6、地址变更和来源头伪造都可能影响这种方案。
读取客户端 IP 时,也不能无条件信任 X-Forwarded-For,必须正确配置可信代理。
TLS 与密钥管理
回调地址和支付查询 API 应使用 HTTPS。Webhook 密钥、API 密钥和私钥必须保存在服务端的密钥管理系统或安全配置中,并支持轮换。日志中不要输出:
- 密钥和完整签名材料;
- 完整银行卡号或安全码;
- 身份证件等敏感信息;
- 未脱敏的完整支付请求。
最低安全基线
一个可靠的实现至少需要满足以下条件:
- return URL 不负责确认支付;
- 使用 webhook 签名或服务端查询 API 验证结果;
- 逐项核对订单号、金额、币种、商户号、交易号和最终状态;
- 订单状态更新具备幂等性;
- 在数据库层面防止交易号被重复使用;
- 正确处理重复通知、乱序通知和并发请求;
- 定期通过网关账单或查询 API 进行对账;
- 严格按照所接入支付网关的官方签名与回调规范实现。
核心判断很简单:reference no 解决的是“这是哪一笔订单”,数字签名或服务端查询解决的才是“这个支付结果是不是真的”。
备注:内容仅供参考。
