PAYATHON 2026

Payment Gateway Integration 中如何验证 return URL 响应

支付小周

结论

不能只凭 unique reference no 判断支付是否成功。它只能用来关联订单,不能证明请求来自支付网关。即便这个值足够随机,只要它曾出现在浏览器、表单或跳转 URL 中,用户就可能获取并重放它。

安全的实现需要遵循两条原则:

  1. 浏览器访问的 return URL 一律视为不可信输入,不能用它直接修改订单状态。
  2. 支付结果必须通过支付网关提供的服务端机制确认。优先使用带签名的 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 解决的是“这是哪一笔订单”,数字签名或服务端查询解决的才是“这个支付结果是不是真的”。

备注:内容仅供参考。