PAYATHON 2026

如何安全实现 3-D Secure 信用卡支付

支付老李

结论

不要临时保存完整的信用卡信息,也不要指望原始表单一直留在浏览器中,以便后续再次扣款。更安全、合理的做法是:

  1. 通过支付网关的 Hosted Fields、客户端 SDK 或托管收银台采集卡号。
  2. 首次提交时,由网关把卡信息转换成一次性 token、Payment Method ID 或 Payment Intent。
  3. 如果触发 3-D Secure challenge,只保存你自己的订单号和网关交易 ID。
  4. 验证完成后,由服务端查询并确认交易,再用交易 ID 或 token 完成支付。

如果当前网关要求在 3-D Secure 完成后重新提交 PAN、有效期和 CVV,应优先更换接口模式,或者启用网关的 tokenization/vault 功能。尤其是 CVV,原则上不能在授权完成后留存。即使经过加密,也不应将其纳入正常支付流程。

推荐的支付流程

典型流程如下:

浏览器
  └─ 使用 Hosted Fields / Gateway SDK 提交卡信息
       └─ 支付网关创建 Payment Method 或 Payment Intent
            ├─ 无需 3DS:直接完成支付
            └─ 需要 3DS:返回 transaction ID 和 challenge 信息
                 └─ 浏览器完成银行验证
                      └─ 返回你的 return URL
                           └─ 服务端向支付网关查询并确认交易
                                └─ 更新订单状态

你的系统只需临时保存以下数据:

{
  "orderId": "ORDER-20260831-001",
  "gatewayTransactionId": "txn_xxx",
  "expectedAmount": 19900,
  "expectedCurrency": "CNY",
  "status": "requires_action"
}

以下信息不需要保存:

  • PAN,也就是完整卡号
  • CVV/CVC
  • 信用卡有效期,除非网关明确允许,并且业务确实需要
  • 浏览器返回但尚未经过服务端验证的“支付成功”标志

不同网关对相关字段的命名可能不同,例如 PaymentIntent、payment_method、transactionId、MD、PaRes、CRes 或 threeDSMethodData。具体字段应以网关当前版本的文档为准。

客户端示例

下面是一个与厂商无关的示意流程。实际使用时,需要将 SDK 名称和参数替换为支付网关提供的接口。

<form id="payment-form">
  <div id="hosted-card-fields"></div>
  <button type="submit">支付</button>
</form>

<script>
const form = document.getElementById("payment-form");

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  // 卡号由支付网关托管的控件采集,不经过商户服务器。
  const paymentMethod = await gateway.createPaymentMethod({
    fields: "#hosted-card-fields"
  });

  const response = await fetch("/api/payments", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Idempotency-Key": crypto.randomUUID()
    },
    body: JSON.stringify({
      orderId: "ORDER-20260831-001",
      paymentMethodId: paymentMethod.id
    })
  });

  const result = await response.json();

  if (result.status === "requires_action") {
    // 优先调用网关 SDK,由 SDK 决定使用弹窗、iframe 还是顶层跳转。
    await gateway.handleNextAction({
      clientSecret: result.clientSecret
    });

    window.location.assign(
      `/payment/result?orderId=${encodeURIComponent(result.orderId)}`
    );
    return;
  }

  if (result.status === "succeeded") {
    window.location.assign(
      `/payment/success?orderId=${encodeURIComponent(result.orderId)}`
    );
    return;
  }

  throw new Error(result.message || "Payment failed");
});
</script>

这里最重要的一点是,浏览器提交给服务端的是 paymentMethodId,不是完整卡号。

服务端示例

下面使用 Node.js 风格的伪代码说明如何创建并确认支付:

app.post("/api/payments", async (req, res) => {
  const { orderId, paymentMethodId } = req.body;
  const idempotencyKey = req.get("Idempotency-Key");

  const order = await orders.findById(orderId);

  if (!order || order.status !== "pending") {
    return res.status(409).json({
      error: "Order is not payable"
    });
  }

  const payment = await gateway.createPayment(
    {
      amount: order.amount,
      currency: order.currency,
      paymentMethodId,
      returnUrl: `https://example.com/payments/return?orderId=${encodeURIComponent(order.id)}`,
      metadata: {
        orderId: order.id
      }
    },
    {
      idempotencyKey
    }
  );

  await orders.attachPayment(order.id, {
    gatewayTransactionId: payment.id,
    status: payment.status
  });

  res.json({
    orderId: order.id,
    status: payment.status,
    clientSecret: payment.clientSecret
  });
});

3-D Secure 返回页面不能只根据 URL 参数判断支付成功。服务端必须重新查询交易状态:

app.get("/payments/return", async (req, res) => {
  const order = await orders.findById(req.query.orderId);

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

  const payment = await gateway.getPayment(
    order.gatewayTransactionId
  );

  const amountMatches = payment.amount === order.amount;
  const currencyMatches = payment.currency === order.currency;
  const orderMatches = payment.metadata?.orderId === order.id;

  if (
    payment.status === "succeeded" &&
    amountMatches &&
    currencyMatches &&
    orderMatches
  ) {
    await orders.markPaidOnce(order.id, payment.id);
    return res.redirect(`/payment/success?orderId=${encodeURIComponent(order.id)}`);
  }

  if (
    payment.status === "processing" ||
    payment.status === "requires_capture"
  ) {
    return res.redirect(`/payment/pending?orderId=${encodeURIComponent(order.id)}`);
  }

  return res.redirect(`/payment/failed?orderId=${encodeURIComponent(order.id)}`);
});

markPaidOnce 必须具备幂等性,防止用户刷新页面、重复回调或 webhook 重试导致重复发货。

为什么不建议自行使用 iframe

iframe 并非绝对不安全,但不适合用来保留卡号,以便再次提交。

发卡行 ACS 可能通过 Content-Security-Policy: frame-ancestors 或 X-Frame-Options 禁止页面被嵌入。跨域 iframe 还受 Same-Origin Policy 限制,父页面无法直接读取银行页面的内容。通常只能让 ACS 最后跳转到你的同源 return page,再由该页面通过 window.postMessage() 通知父页面。

即便采用这种方式,仍有以下问题:

  • 用户刷新或关闭页面后,原始表单数据会丢失。
  • 移动端浏览器、WebView 和不同隐私设置下的行为可能不一致。
  • 卡号和 CVV 会在 DOM 中停留更久,增加被恶意脚本读取的风险。
  • 浏览器返回的验证结果可能被伪造,服务端仍需向网关确认。
  • 某些银行验证页面要求使用顶层导航,无法在 iframe 中稳定运行。

如果网关的官方 SDK 内部使用 iframe 或弹窗,可以直接使用。尺寸切换、跨窗口通信、失败恢复和协议字段通常都由 SDK 处理。除非网关明确支持这种集成方式,否则不要自行把 ACS URL 放进通用 iframe。

如果只能使用传统 3-D Secure 接口

某些较旧的 3-D Secure 1 或 legacy gateway 接口确实要求提交 PaRes、MD 等字段。遇到这种情况,应确认网关是否提供以下任一能力:

  1. 首次请求后返回可恢复的 transaction/session ID。
  2. 先对卡片执行 tokenization,再用 token 发起认证。
  3. 在网关侧保存尚未完成的授权请求。
  4. 提供完成 3-D Secure 的专用接口,只需提交交易 ID 和认证结果。

理想的完成请求类似:

{
  "transactionId": "txn_123",
  "threeDSResult": "opaque-result-from-gateway"
}

而不是:

{
  "cardNumber": "4111111111111111",
  "expiry": "12/30",
  "cvv": "123",
  "threeDSResult": "..."
}

如果网关既不支持 tokenization,也无法通过 transaction ID 恢复原请求,那么短期加密存储卡数据只能作为最后手段。这会显著扩大 PCI DSS 范围,还需要严格的密钥管理、访问控制、日志脱敏和自动过期机制。CVV 不应持久化到数据库、缓存、日志或 session store。

也不要将卡信息放入:

  • Cookie
  • localStorage 或 sessionStorage
  • URL、查询参数或 URL fragment
  • 用于跨页面保存数据的 hidden input
  • 服务端普通 session
  • 日志、错误追踪或前端埋点

回调和支付状态的安全检查

3-D Secure 验证成功并不一定意味着付款成功。认证完成后,支付仍可能因为余额不足、风控拒绝、网络超时或授权失败而失败。

处理回调时,至少需要做到以下几点:

  • 使用随机且不可预测的 state、nonce 或网关 transaction ID 关联订单。
  • 不信任浏览器传回的 success=true 或 authorization token。
  • 通过服务端 API 查询最终支付状态。
  • 校验订单号、金额、币种和商户账户。
  • 对创建支付、确认支付和将订单标记为已付款的操作执行幂等控制。
  • 同时接收并验证网关 webhook,以处理用户没有返回商户页面的情况。
  • webhook 验签后,仍需检查事件中的交易 ID 是否属于对应订单。
  • 对回调参数和 token 进行日志脱敏。
  • 使用 HTTPS,并为后台接口配置合适的 CSP、CSRF 防护和访问控制。

更稳妥的方案是采用支付网关当前支持的 3-D Secure 2 集成。卡信息直接提交给网关,商户只保存 Payment Method 或 Payment Intent 标识,challenge 结束后再由服务端确认交易。这样无需暂存信用卡信息,也不会让支付结果依赖某个容易中断的浏览器页面。

备注:内容仅供参考。