PAYATHON 2026

如何防止 payment form 中的金额被篡改?

支付阿杰

结论

hidden field 不可信,前端代码也无法防止金额被修改。浏览器中的 HTML、JavaScript、请求参数和 Cookie 都由用户控制。

前端只应提交商品、套餐或订单标识。服务器根据数据库中的可信价格重新计算金额,再创建支付订单或签名支付请求。支付完成后,服务器还要通过支付网关的回调或查询接口,核对订单号、金额、币种和支付状态。

为什么 hidden field 不安全

hidden 只会隐藏字段,不会保护字段。用户可以通过开发者工具、控制台或代理软件修改它:

<input type="hidden" name="amount" value="19900">

即使不修改 DOM,用户也可以直接构造 HTTP 请求:

POST /checkout
Content-Type: application/x-www-form-urlencoded

product_id=42&amount=1

前端校验、禁用开发者工具、压缩 JavaScript 都解决不了这个问题。客户端传来的金额最多用于界面展示,不能作为实际扣款依据。

推荐的支付流程

一套安全的支付流程可以这样设计:

  1. 前端提交商品 ID、数量、优惠券代码等业务参数。
  2. 服务器查询数据库,读取商品当前价格和优惠规则。
  3. 服务器检查库存、购买权限、数量限制及优惠券有效性。
  4. 服务器重新计算应付金额,并创建待支付订单。
  5. 服务器使用订单号和计算出的金额调用支付网关。
  6. 前端跳转到支付页面,或使用服务器返回的支付会话标识。
  7. 支付完成后,服务器验证网关回调,并核对金额、币种和订单状态。
  8. 所有信息核对无误后,服务器才能将订单标记为已支付并交付商品。

实际支付金额只能来自服务器保存和计算的数据。

代码示例

下面用 Node.js 和 Express 演示基本做法。支付网关部分使用的是通用伪接口,需要替换成实际网关的服务端 SDK。

前端只提交商品和数量:

<form method="post" action="/checkout">
  <input type="hidden" name="productId" value="product_42">

  <label>
    数量
    <input type="number" name="quantity" min="1" max="10" value="1">
  </label>

  <button type="submit">立即支付</button>
</form>

服务器从数据库读取价格并计算金额:

app.post("/checkout", async (req, res) => {
  const productId = String(req.body.productId);
  const quantity = Number.parseInt(req.body.quantity, 10);

  if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
    return res.status(400).send("Invalid quantity");
  }

  const product = await db.products.findById(productId);

  if (!product || !product.active) {
    return res.status(404).send("Product not found");
  }

  // 使用最小货币单位,例如人民币分,避免浮点数精度问题。
  const amount = product.priceInCents * quantity;

  const order = await db.orders.create({
    userId: req.user.id,
    productId: product.id,
    quantity,
    currency: "CNY",
    amountInCents: amount,
    status: "pending"
  });

  // 必须通过支付网关的服务端 API 创建支付会话。
  const paymentSession = await paymentGateway.createPayment({
    merchantOrderId: order.id,
    amount,
    currency: "CNY",
    callbackUrl: "https://example.com/payment/webhook"
  });

  res.redirect(paymentSession.checkoutUrl);
});

即使用户修改了 productId 或 quantity,服务器仍会查询价格、验证数量并重新计算金额。用户添加伪造的 amount 参数,也无法改变实际付款金额。

验证支付回调

用户被重定向到“支付成功”页面,并不能证明付款已经成功,因为重定向地址和查询参数也可能被伪造。

服务器收到支付网关的回调后,应先按照网关文档验证签名,再核对订单信息:

app.post("/payment/webhook", async (req, res) => {
  const event = paymentGateway.verifyWebhook(
    req.rawBody,
    req.headers
  );

  if (!event) {
    return res.status(400).send("Invalid signature");
  }

  const order = await db.orders.findById(event.merchantOrderId);

  if (!order) {
    return res.status(404).send("Order not found");
  }

  if (
    event.status !== "paid" ||
    event.amount !== order.amountInCents ||
    event.currency !== order.currency
  ) {
    return res.status(400).send("Payment data mismatch");
  }

  if (order.status === "paid") {
    // 回调可能重复发送,需要保证幂等。
    return res.status(200).send("OK");
  }

  await db.transaction(async (transaction) => {
    await transaction.orders.markAsPaid(order.id, event.paymentId);
    await transaction.fulfillments.createForOrder(order.id);
  });

  res.status(200).send("OK");
});

verifyWebhook 的具体参数、签名算法和原始请求体要求由支付网关决定,必须严格按照其官方文档实现。有些网关还建议在处理回调前,通过服务端查询接口再次确认支付状态。

如果支付网关要求表单中包含金额

部分传统支付网关要求浏览器直接提交带有金额的表单。遇到这种情况,金额仍要由服务器生成,并与订单号等字段一起签名:

signature = HMAC(secret, order_id + amount + currency)

支付网关必须使用双方共享的密钥验证签名。即使用户修改 amount,也无法生成与新金额匹配的有效签名。

签名能否发挥作用,取决于网关是否支持并强制验证。只在自己的页面中添加签名字段没有意义,最终必须由支付网关完成验证。如果网关提供服务端创建订单或 Payment Intent 一类的接口,应优先使用这种方式。

其他注意事项

  • 金额应以整数形式的最小货币单位保存,例如 19900 分,不要使用浮点数 199.00 参与结算。
  • 商品名称、价格和优惠规则应从服务器或数据库读取。
  • 不要接受客户端传入的折扣金额、税费、运费或订单总额。
  • 创建订单时应固定订单金额,避免支付期间商品价格变化导致核对失败。
  • 每个支付会话都应绑定唯一订单号,防止重复支付或串单。
  • 回调处理必须具备幂等性,因为支付网关可能多次发送同一事件。
  • 如果金额、币种或订单号不一致,不要自动交付商品。应记录异常并转入人工核查。
  • HTTPS 可以防止数据在传输过程中被第三方窃听或篡改,但无法阻止用户修改自己浏览器中的数据。

不必设法保护 hidden field,应直接把它当作不可信输入。需要保护的是服务器端的定价、订单创建和支付结果验证流程。

备注:内容仅供参考。