PAYATHON 2026

首次实现 payment gateway 应注意哪些安全问题?

支付阿杰

结论

首次接入支付功能未必很难,但不要自行实现支付网关,也不要让银行卡号、CVV 等敏感数据经过自己的服务器。

更稳妥的做法是选择成熟的支付服务商,使用其托管收银台、官方 SDK 或前端令牌化组件。你的系统只需创建订单、发起支付、验证回调并更新业务状态。这样既能降低开发难度,也能大幅缩小 PCI DSS 合规范围。

如果项目需要直接处理银行卡数据、保存支付凭证,或者涉及大额交易和较高的业务风险,应请有支付安全经验的人员参与设计和代码审查。普通项目即使使用托管支付页面,上线前也至少要进行一次独立安全审查。

先分清接入支付和实现网关

通常所说的“添加 payment gateway”,指的是接入第三方支付服务,而不是从零开发一个网关。

典型的支付流程如下:

  1. 用户在你的系统中创建订单。
  2. 服务器根据数据库中的商品价格计算应付金额。
  3. 服务器调用支付服务商 API 创建支付会话。
  4. 用户跳转到服务商托管的支付页面,或通过令牌化组件输入支付信息。
  5. 支付服务商通过 webhook 通知你的服务器支付结果。
  6. 服务器验证 webhook,并以幂等方式更新订单。
  7. 用户返回结果页面,但不能把页面跳转本身作为支付成功的依据。

只要敏感支付数据始终由支付服务商处理,开发工作就主要集中在订单状态和接口安全上,整体难度可控。如果自行接收银行卡号、处理 3-D Secure、管理拒付或保存支付凭证,复杂度和责任都会明显增加。

不要让敏感支付数据进入自己的系统

应优先采用这些集成方式:

  • 托管收银台或支付跳转页面;
  • 服务商提供的 iframe、支付组件或移动端 SDK;
  • 由服务商生成的一次性 token;
  • 保存银行卡时,使用服务商返回的 customer ID 或 payment method token。

不要自行存储以下数据:

  • 完整银行卡号;
  • CVV/CVC;
  • 磁道数据;
  • 支付账户密码;
  • 未加密的支付访问令牌。

数据库经过加密,也不代表这些数据可以被安全、合规地保存。尤其不能为了“以后退款方便”而保留 CVV。退款通常应使用原交易 ID 调用服务商的退款 API。

采用托管方案并不等于完全免除 PCI DSS 责任。具体适用范围取决于支付方式、部署结构、地区、收单机构和服务商要求,应以当前有效的合规文件和专业意见为准。

金额必须由服务器计算

不要相信浏览器提交的金额、币种、折扣或商品名称,攻击者可以直接修改 HTTP 请求。

错误做法:

const { productId, amount } = req.body;

await paymentProvider.createPayment({
  amount,
  currency: "CNY"
});

更安全的做法是只接收商品或订单标识,再根据服务器中的可信数据计算价格:

app.post("/api/checkout", requireLogin, async (req, res) => {
  const { orderId } = req.body;

  const order = await db.orders.findForUser(orderId, req.user.id);
  if (!order) {
    return res.status(404).json({ error: "Order not found" });
  }

  if (order.status !== "pending") {
    return res.status(409).json({ error: "Order cannot be paid" });
  }

  const amount = calculateAmountFromTrustedData(order.items);

  const session = await paymentProvider.createCheckoutSession({
    amount,
    currency: order.currency,
    reference: order.id,
    idempotencyKey: `checkout:${order.id}`
  });

  await db.orders.savePaymentSession(order.id, session.id);

  res.json({ checkoutUrl: session.url });
});

这里的 paymentProvider、方法名称和参数只是结构示例。实际代码必须使用服务商的官方 SDK,并严格遵循其签名、金额单位和幂等机制。

金额最好用最小货币单位的整数表示,例如分,而不是浮点数:

const amountInCents = 1999;

不要把 19.99 * 100 之类的浮点运算结果作为最终结算依据。部分货币没有两位小数,具体规则应由币种配置决定。

webhook 才是支付状态的可信来源

用户被重定向到 /payment/success,不代表订单已经支付。返回地址可以伪造,用户也可能在完成支付后直接关闭页面。

订单状态应根据验证通过的 webhook 更新。处理 webhook 时至少要做到:

  • 使用原始请求体校验签名;
  • 校验时间戳,并限制可接受的时间偏差;
  • 防止重复处理同一事件;
  • 检查支付对象是否属于当前商户;
  • 核对订单号、金额和币种;
  • 只允许合法的状态转换;
  • 尽快返回响应,把耗时业务交给队列处理。

示意代码如下:

app.post(
  "/webhooks/payment",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;

    try {
      event = paymentProvider.verifyWebhook({
        rawBody: req.body,
        signature: req.headers["payment-signature"],
        secret: process.env.PAYMENT_WEBHOOK_SECRET
      });
    } catch {
      return res.sendStatus(400);
    }

    const alreadyProcessed = await db.webhookEvents.exists(event.id);
    if (alreadyProcessed) {
      return res.sendStatus(200);
    }

    await db.transaction(async (tx) => {
      await tx.webhookEvents.insert(event.id);

      if (event.type !== "payment.succeeded") {
        return;
      }

      const order = await tx.orders.lockById(event.data.reference);
      if (!order) {
        throw new Error("Unknown order");
      }

      if (
        event.data.amount !== order.expectedAmount ||
        event.data.currency !== order.currency
      ) {
        throw new Error("Payment amount mismatch");
      }

      if (order.status === "pending") {
        await tx.orders.markPaid(order.id, event.data.paymentId);
      }
    });

    res.sendStatus(200);
  }
);

不同服务商对 webhook 原始报文、签名头、时间戳和重试机制有不同规定,不能直接套用一套通用的 HMAC 逻辑。应优先使用官方 SDK 提供的验证函数,并确认 Web 框架没有在验签前改写请求体。

幂等性不能省

网络超时不等于支付失败。客户端、服务器和支付服务商都可能重试同一个请求。没有幂等设计,就可能出现重复扣款、重复发货或重复退款。

以下环节都需要实现幂等:

  • 创建支付;
  • 处理 webhook;
  • 确认订单;
  • 发货或开通服务;
  • 退款。

可以为每个业务动作分配一个稳定且唯一的键:

checkout:order_123
fulfillment:order_123
refund:payment_456:request_1

数据库中也应建立唯一约束。不能只在应用层先查询再插入,因为多个并发请求可能同时通过检查。

CREATE UNIQUE INDEX webhook_events_event_id_uq
ON webhook_events(event_id);

CREATE UNIQUE INDEX payments_provider_payment_id_uq
ON payments(provider_payment_id);

严格设计订单状态机

不要只保存一个 paid: true。支付可能处于等待、授权、扣款、失败、取消、退款、部分退款或争议等不同状态。

可以根据业务需要设计类似的状态:

pending
payment_processing
authorized
paid
payment_failed
cancelled
partially_refunded
refunded
disputed

状态之间不能随意转换。例如:

const allowedTransitions = {
  pending: ["payment_processing", "cancelled"],
  payment_processing: ["authorized", "paid", "payment_failed"],
  authorized: ["paid", "cancelled"],
  paid: ["partially_refunded", "refunded", "disputed"],
  partially_refunded: ["refunded", "disputed"]
};

还要区分“授权”和“扣款”。某些支付方式会先冻结额度,之后才执行 capture。看到授权成功,不能立即把交易视为最终到账。

常规 Web 安全仍然不能少

支付服务商只能保护支付环节,无法修复你的账户系统或订单接口。至少要落实这些措施:

  • 全站使用 HTTPS,并正确配置 TLS;
  • 为后台账户启用多因素认证;
  • 使用安全的 session cookie;
  • 防范 SQL 注入、XSS、CSRF 和越权访问;
  • 对登录、下单、优惠券和支付接口实施速率限制;
  • 对管理、退款和修改收款配置等操作执行最小权限原则;
  • 退款等高风险操作需要重新认证或双人审批;
  • 定期更新依赖并修复安全漏洞;
  • 对外部 URL 跳转使用白名单,防止开放重定向;
  • 不要把密钥写入前端代码、Git 仓库或日志;
  • 生产环境和测试环境使用不同的密钥;
  • 定期轮换 API key 和 webhook secret。

支付接口的身份验证和授权是两回事。用户已经登录,不代表他有权支付、查询或退款任意订单。每次访问都必须检查订单归属。

不要在日志中泄露支付信息

排查支付问题需要审计记录,但日志本身也可能成为数据泄露源。

建议记录以下信息:

  • 内部订单 ID;
  • 服务商支付 ID;
  • webhook event ID;
  • 操作类型和结果;
  • 状态变化;
  • 操作者或系统身份;
  • 时间和追踪 ID。

以下内容应避免记录:

  • 完整银行卡号和 CVV;
  • API key、签名密钥;
  • 完整的 Authorization 请求头;
  • 未脱敏的个人信息;
  • webhook 完整请求体,除非已经确认其中不含敏感数据,并设置了合适的访问控制和保存期限。

日志应具备防篡改能力,并限制开发、客服和运维人员的访问范围。退款、人工改价、修改收款账户和补发商品等操作尤其需要保留审计记录。

防范业务层攻击

实际损失未必来自支付网关被破解,也可能源于业务规则漏洞,例如:

  • 修改商品数量或单价;
  • 重复使用优惠券;
  • 利用并发请求超卖;
  • 支付低价订单后替换商品;
  • 重放 webhook;
  • 伪造退款请求;
  • 使用被盗账户购买可立即转移的虚拟商品;
  • 支付失败后仍然触发发货;
  • 利用汇率或小数舍入差异少付金额。

因此,发货前应再次核对:

支付状态 == 最终成功
支付金额 == 订单应付金额
支付币种 == 订单币种
支付商户 == 当前商户
支付关联订单 == 当前订单
订单内容自创建支付后未被修改
订单尚未发货

对于虚拟商品、礼品卡、充值等高风险业务,还应考虑设备、IP、账户历史、交易频率和异常金额等风控信号。具体规则要结合业务的误报成本来设计,不能简单依靠封禁某个 IP。

测试不能只测“支付成功”

应使用服务商提供的测试环境覆盖以下情况:

  • 支付成功和失败;
  • 用户取消支付;
  • 支付超时;
  • 强认证或额外验证;
  • webhook 延迟、乱序和重复;
  • webhook 验签失败;
  • 创建支付请求超时后重试;
  • 金额或币种不一致;
  • 授权成功但扣款失败;
  • 全额和部分退款;
  • 同一订单被并发支付;
  • 重复点击支付按钮;
  • 服务商 API 暂时不可用;
  • 用户支付成功但没有返回网站。

不要在测试代码中写死生产密钥,也不要使用真实银行卡数据进行测试。上线前还要确认测试模式和生产模式已经完全隔离。

是否需要外部安全审查

出现以下情况时,建议进行专业审查:

  • 银行卡数据会进入你的服务器;
  • 需要自行保存支付凭证;
  • 业务涉及较高金额或大量交易;
  • 有复杂的订阅、分账、余额或提现功能;
  • 涉及多个国家或地区的支付法规;
  • 团队此前没有支付系统经验;
  • 客户合同、保险或收单机构要求审计;
  • 系统一旦出错,就会立即交付无法追回的资产。

即使只是接入简单的托管收银台,也可以安排一次针对性审查,重点检查支付流程、webhook 验签、幂等性、权限控制、密钥管理和订单状态机。这通常比笼统地“看看代码是否安全”更有针对性。

外部审查不能代替持续维护。上线后仍要更新依赖、轮换密钥、监控异常并做好事件响应。

推荐的实施顺序

  1. 根据地区、币种、退款和结算需求,选择合规的支付服务商。
  2. 优先使用托管收银台或官方令牌化组件。
  3. 画出订单、支付、退款和发货的状态转换。
  4. 由服务器计算金额并创建支付会话。
  5. 使用官方 SDK 验证 webhook。
  6. 为支付、回调、发货和退款建立幂等机制。
  7. 配置密钥管理、权限控制、审计日志和监控告警。
  8. 在测试环境中覆盖失败、超时、重复和并发场景。
  9. 根据数据流确认当前适用的 PCI DSS 和当地监管要求。
  10. 上线前进行独立代码审查和支付流程安全评估。

最重要的是缩小责任边界。让专业支付服务商处理支付凭证,你的系统只负责可信金额计算、订单状态、回调验证和业务授权。做到这一点,首次接入支付功能就不会演变成一项难以控制的安全工程。

备注:内容仅供参考。