如何安全实现 3-D Secure 信用卡支付
结论
不要临时保存完整的信用卡信息,也不要指望原始表单一直留在浏览器中,以便后续再次扣款。更安全、合理的做法是:
- 通过支付网关的 Hosted Fields、客户端 SDK 或托管收银台采集卡号。
- 首次提交时,由网关把卡信息转换成一次性 token、Payment Method ID 或 Payment Intent。
- 如果触发 3-D Secure challenge,只保存你自己的订单号和网关交易 ID。
- 验证完成后,由服务端查询并确认交易,再用交易 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 等字段。遇到这种情况,应确认网关是否提供以下任一能力:
- 首次请求后返回可恢复的 transaction/session ID。
- 先对卡片执行 tokenization,再用 token 发起认证。
- 在网关侧保存尚未完成的授权请求。
- 提供完成 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 结束后再由服务端确认交易。这样无需暂存信用卡信息,也不会让支付结果依赖某个容易中断的浏览器页面。
备注:内容仅供参考。
