不存储信用卡信息,如何实现结账时自动填充?
结论
不要在自己的数据库中保存卡号、有效期或安全码。应使用符合 PCI DSS 要求的支付服务商,并采用”卡片保险库(Vault)+ 令牌化(Tokenization)“方案。
用户首次输入银行卡信息时,由支付服务商的托管页面、Hosted Fields 或官方前端 SDK 直接采集数据。真实卡片信息保存在支付服务商处,服务商会返回一个不可逆的支付方式标识,例如 payment_method_id。你的系统只需保存:
- 支付服务商的客户 ID
- 支付方式令牌或 ID
- 卡品牌、尾号、有效期等展示信息
- 是否为默认支付方式
用户再次结账时,后端通过已保存的支付方式 ID 发起支付,无需重新输入完整卡号。
为什么普通加密不能替代令牌化
即使先加密再写入数据库,卡号仍属于系统保存的信用卡信息。只要系统可以解密,密钥、应用服务器、数据库备份、日志和运维流程就可能成为攻击目标,PCI DSS 合规范围也会明显扩大。
还要注意以下几点:
- CVV/CVC 属于敏感认证数据,授权完成后不得保存,即使加密也不行。
- 浏览器
autocomplete使用的是用户设备或密码管理器保存的信息,不能保证跨设备、跨浏览器使用。 - 不要把卡号写入 Cookie、
localStorage、Session、日志或分析系统。 - 令牌并不是加密后的卡号。它通常只能用于指定的支付服务商、商户账户或使用场景。
大型电商平台可能会自行建设符合严格安全标准的卡片保险库,也可能采用支付服务商或银行卡网络提供的令牌化能力。普通网站通常没有必要承担这套基础设施带来的成本和风险。
推荐的实现流程
1. 选择支持保存卡片的支付服务商
至少确认服务商支持以下功能:
- 托管结账页或 Hosted Fields
- 卡片令牌化
- Customer 与 Payment Method 绑定
- 使用已保存的支付方式扣款
- 3-D Secure、SCA 等额外认证
- Webhook 签名验证
- 支付方式更新、删除和过期处理
如果想尽量缩小 PCI DSS 范围,优先选择由服务商托管输入框或整个结账页面的集成方式。究竟适用 SAQ A、SAQ A-EP 还是其他合规范围,要根据页面的实际集成方式和收单机构的要求确认,不能只凭”没有保存卡号”来判断。
2. 首次结账时直接令牌化
页面中的卡片输入框应由支付服务商的 SDK 创建。真实卡号直接发送给支付服务商,不经过你的应用服务器。
概念示例:
<form id="payment-form">
<div id="provider-card-field"></div>
<label>
<input id="save-card" type="checkbox">
保存此支付方式
</label>
<button type="submit">支付</button>
</form>
const paymentProvider = PaymentProvider.initialize(PUBLIC_KEY);
const cardField = paymentProvider.createCardField();
cardField.mount("#provider-card-field");
document
.querySelector("#payment-form")
.addEventListener("submit", async (event) => {
event.preventDefault();
const result = await paymentProvider.createPaymentMethod({
card: cardField
});
if (result.error) {
showError(result.error.message);
return;
}
await fetch("/api/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": getCsrfToken()
},
body: JSON.stringify({
paymentMethodId: result.paymentMethod.id,
saveForFuture: document.querySelector("#save-card").checked
})
});
});
PaymentProvider、createCardField() 和示例中的返回字段只是用于说明流程的抽象名称,并非某家服务商的真实 API。实际开发时,必须使用所选服务商的官方 SDK 和文档。
前端只应把 paymentMethodId 发给自己的后端,不能发送卡号或 CVV。
3. 后端绑定支付方式
后端先验证当前登录用户,再到支付服务商处创建或查找 Customer,并把支付方式绑定到该 Customer。
app.post("/api/checkout", requireLogin, async (req, res) => {
const { paymentMethodId, saveForFuture } = req.body;
const customerId = await getOrCreateProviderCustomer(req.user.id);
const payment = await paymentProvider.payments.create({
customerId,
paymentMethodId,
amount: 12900,
currency: "CNY",
saveForFuture
}, {
idempotencyKey: req.headers["idempotency-key"]
});
if (saveForFuture) {
await db.savedPaymentMethods.upsert({
userId: req.user.id,
provider: "selected-provider",
providerCustomerId: customerId,
providerPaymentMethodId: payment.paymentMethod.id,
brand: payment.paymentMethod.card.brand,
last4: payment.paymentMethod.card.last4,
expiryMonth: payment.paymentMethod.card.expiryMonth,
expiryYear: payment.paymentMethod.card.expiryYear
});
}
res.json({
paymentId: payment.id,
status: payment.status,
clientAction: payment.clientAction
});
});
金额必须由后端根据商品、优惠和订单数据重新计算,不能采用浏览器提交的金额。
数据库可以保存类似下面的记录:
user_id
provider
provider_customer_id
provider_payment_method_id
brand
last4
expiry_month
expiry_year
is_default
created_at
以下数据不能保存:
完整卡号 PAN
CVV/CVC
磁道数据
PIN 或 PIN block
前端提交的原始卡片表单
可还原完整卡号的调试日志
4. 再次结账时显示已保存的卡片
用户再次进入结账页时,可以向其显示非敏感的卡片摘要:
Visa •••• 4242
有效期 08/2029
用户选中卡片后,浏览器只提交该支付方式在本系统中的记录 ID:
{
"savedPaymentMethodRecordId": "spm_8f21..."
}
后端必须确认这条记录属于当前登录用户,然后再取出真正的 providerPaymentMethodId:
app.post("/api/orders/:orderId/pay", requireLogin, async (req, res) => {
const savedMethod = await db.savedPaymentMethods.findOne({
id: req.body.savedPaymentMethodRecordId,
userId: req.user.id
});
if (!savedMethod) {
return res.status(404).json({ error: "Payment method not found" });
}
const order = await loadPayableOrder(req.params.orderId, req.user.id);
const payment = await paymentProvider.payments.create({
customerId: savedMethod.providerCustomerId,
paymentMethodId: savedMethod.providerPaymentMethodId,
amount: order.amount,
currency: order.currency,
orderReference: order.id
}, {
idempotencyKey: `pay:${order.id}`
});
res.json({
status: payment.status,
clientAction: payment.clientAction
});
});
不要接受客户端直接传入的任意支付服务商令牌,否则攻击者可能尝试引用不属于自己的支付方式。
自动填充和保存卡片不是一回事
如果只是想减少用户输入,可以正确设置 HTML 自动填充属性:
<input
name="cardholder"
autocomplete="cc-name"
inputmode="text"
>
<input
name="card-number"
autocomplete="cc-number"
inputmode="numeric"
>
<input
name="expiry"
autocomplete="cc-exp"
inputmode="numeric"
>
<input
name="security-code"
autocomplete="cc-csc"
inputmode="numeric"
>
这样,浏览器或用户选择的密码管理器可以自动填写卡片信息,网站自身不会保存这些数据。但自动填充依赖具体设备和浏览器,无法提供稳定的账户级”已保存卡片”体验。
如果用户需要登录后在不同设备上选择同一张卡,应使用支付服务商提供的令牌化保存方案。
必须处理的安全细节
用户授权
保存卡片前,应取得用户的明确同意,并说明用途,例如”保存此支付方式,供以后购买使用”。如果以后要在用户不在场时自动续费或扣款,还要按照支付服务商和当地规则记录相应授权。一次性结账同意不能直接视为无限期扣款授权。
身份验证
查看、添加、删除或使用已保存的支付方式时,必须要求用户登录。修改默认卡、使用异常设备或支付高金额订单时,可以要求用户重新验证密码或进行多因素认证。
额外验证
保存卡片不代表以后一定可以在没有交互的情况下扣款。发卡行、地区法规或风控策略可能要求用户完成 3-D Secure、SCA、短信验证或其他验证。接口要能返回”需要用户操作”的状态,并让前端继续完成认证流程。
Webhook
不能只根据浏览器的跳转结果把订单标记为已付款。应验证支付服务商 Webhook 的签名,再根据服务商确认的最终状态更新订单。重复通知、乱序通知、退款和支付失败也要妥善处理。
幂等性
创建支付时应使用幂等键,防止用户重复点击、网络重试或服务超时导致重复扣款。通常可以用订单 ID 和支付尝试序号生成稳定的幂等键。
日志与监控
不要记录包含卡片数据的完整请求体,还应检查以下位置:
- 反向代理访问日志
- APM 和错误追踪
- 前端埋点与会话回放
- 客服工单
- 数据库备份
- 崩溃报告
- 测试环境和截图
即使使用 Hosted Fields,也不能让第三方分析脚本读取或覆盖支付组件周围的敏感交互。
删除与失效
用户删除卡片时,应同时在支付服务商处解绑或删除相应支付方式,并清理本地映射。系统还要处理卡片过期、支付方式被服务商撤销、Customer 被删除和商户账户迁移等情况。
最实际的架构选择
对大多数网站来说,较为安全且容易维护的方案是:
- 由支付服务商采集并保存真实卡片数据。
- 网站只保存 Customer ID、Payment Method ID、卡片尾号等展示信息。
- 后续支付由后端引用这些标识发起。
- 需要额外认证时,由服务商 SDK 接管相关流程。
- 通过签名 Webhook 确认最终支付结果。
- 根据实际集成方式完成 PCI DSS 自评,并满足收单机构的要求。
这种方案可以提供接近 Amazon 的结账体验,又不必把自己的数据库变成信用卡保险库。令牌化可以降低风险并缩小合规范围,但不能免除 HTTPS、访问控制、密钥管理、漏洞修复、日志治理和 PCI DSS 责任。
备注:内容仅供参考。
