将 CreditCard 信息存储到 DataBase 的最佳实践
明确结论
最佳做法不是”加密信用卡信息后存入数据库”,而是尽量不接触,也不保存完整卡号:
- 使用合规支付服务商提供的 Hosted Checkout、Hosted Fields 或客户端 SDK 直接采集卡信息。
- 数据库只保存支付服务商返回的
payment_method_id、customer_id等令牌。 - 可以保存卡品牌、卡号后四位、有效期等用于展示的非敏感信息,但应限制用途和访问权限。
- CVV/CVC/CID 在授权完成后禁止存储,即使加密也不能保存。
- 如果业务必须保存完整卡号,就需要按照适用的 PCI DSS 要求建设系统,并通过相应评估。这通常比接入成熟支付服务商成本更高,风险也更大。
“数据库加密 + 权限限制”只是基础防护,无法代替令牌化,也不代表系统自动满足支付卡行业的合规要求。
为什么不建议自行保存卡号
完整卡号通常称为 PAN(Primary Account Number)。只要系统存储、处理或传输 PAN,就会扩大 PCI DSS 的合规范围。除了数据库,以下位置也可能意外留下卡号:
- Web 服务器访问日志
- 应用错误日志和异常追踪平台
- 消息队列
- 数据库备份及只读副本
- 分析平台和数据仓库
- 客服工单
- 浏览器监控、Session Replay
- 开发和测试环境
即使数据库字段已经加密,只要应用能够解密,攻击者取得应用权限后仍可能读到明文。自行保存卡号还要解决密钥轮换、权限审计、备份清理、数据保留期限、入侵检测和事件响应等问题。
更有效的办法,是尽可能减少敏感数据进入自己的系统。
推荐的支付流程
常见的安全流程如下:
- 前端加载支付服务商提供的 Hosted Fields 或客户端 SDK。
- 用户在服务商控制的输入框中填写卡号、有效期和 CVV。
- 卡信息直接发送给支付服务商,不经过自己的服务器。
- 服务商返回一次性令牌或可复用的
payment_method_id。 - 前端把这个标识提交给自己的后端。
- 后端使用服务端密钥调用支付服务商 API,完成扣款或绑定。
- 数据库只记录令牌、交易编号、金额、币种和支付状态。
流程可以表示为:
Browser
├─ Card data ─────────────> Payment Provider
└─ payment_method_id ─────> Application Server
│
├─ Charge API ──> Payment Provider
└─ Store token ─> Database
需要确认卡信息是否真的绕过了自己的服务器。仅在自建表单中通过 JavaScript 调用令牌化接口,不一定能让整个环境脱离合规范围。
数据库应该保存什么
可以设计一张只保存支付引用和展示信息的表:
CREATE TABLE customer_payment_methods (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
provider VARCHAR(32) NOT NULL,
provider_customer_id VARCHAR(128) NOT NULL,
provider_payment_method_id VARCHAR(128) NOT NULL,
card_brand VARCHAR(32),
card_last4 CHAR(4),
expiry_month SMALLINT,
expiry_year SMALLINT,
billing_name VARCHAR(128),
created_at TIMESTAMP NOT NULL,
revoked_at TIMESTAMP NULL,
UNIQUE (provider, provider_payment_method_id)
);
其中最重要的字段是:
provider_payment_method_id
它是支付服务商生成的引用,并非完整卡号。card_last4 只能用于向用户展示,例如:
Visa •••• 4242
以下内容不要存入数据库:
完整 PAN
CVV / CVC / CID
磁条完整数据
PIN 或 PIN block
3-D Secure 验证密码
CVV 通常只能用于当前授权,不能为了方便用户下次付款而保留。
前端令牌化示例
不同支付服务商使用的 SDK 名称和参数并不相同。下面只展示通用结构,不代表任何真实服务商的完整 API:
<form id="payment-form">
<div id="provider-card-field"></div>
<button type="submit">付款</button>
</form>
<script>
const paymentClient = PaymentProvider.createClient({
publicKey: "PUBLIC_KEY"
});
const cardField = paymentClient.createCardField();
cardField.mount("#provider-card-field");
document
.getElementById("payment-form")
.addEventListener("submit", async (event) => {
event.preventDefault();
const result = await paymentClient.createPaymentMethod({
card: cardField
});
if (result.error) {
showError(result.error.message);
return;
}
await fetch("/api/payments", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
paymentMethodId: result.paymentMethodId,
orderId: "ORDER_ID"
})
});
});
</script>
页面不应读取卡字段的明文值。卡输入组件应由支付服务商托管,并把敏感数据直接发送给服务商。
后端扣款示例
后端只接收支付令牌,金额则根据服务器端的订单数据确定:
import express from "express";
const app = express();
app.use(express.json());
app.post("/api/payments", async (req, res) => {
const { paymentMethodId, orderId } = req.body;
if (typeof paymentMethodId !== "string" || typeof orderId !== "string") {
return res.status(400).json({ error: "Invalid request" });
}
const order = await orderRepository.findPendingOrder(orderId);
if (!order) {
return res.status(404).json({ error: "Order not found" });
}
const payment = await paymentProvider.createPayment({
paymentMethodId,
amount: order.amountMinor,
currency: order.currency,
idempotencyKey: `order:${order.id}`
});
await paymentRepository.record({
orderId: order.id,
providerPaymentId: payment.id,
status: payment.status
});
return res.json({
paymentId: payment.id,
status: payment.status
});
});
实现时应遵守以下原则:
- 金额和币种必须从服务器端订单读取,不能相信浏览器提交的金额。
- 使用幂等键,避免网络重试造成重复扣款。
- 服务端密钥只能保存在服务端的密钥管理系统中。
- 不要把完整请求体写入日志。
- 支付是否成功,需要结合服务商返回值和通过签名验证的 Webhook 来确认。
PaymentProvider、createPayment 等名称仅用于示意。实际实现必须以所选支付服务商的官方 SDK 和文档为准。
如果确实必须保存完整卡号
只有当收单机构、支付服务商或业务规则明确要求,而且合规责任已经确定时,才应考虑自行保存 PAN。至少需要处理以下事项。
字段级加密
PAN 应在写入持久化存储前完成加密,不能只依赖磁盘加密或数据库透明加密。应使用经过审查的认证加密算法,例如 AES-GCM,并为每条数据生成唯一的随机 nonce。
示意结构:
pan_ciphertext
pan_nonce
pan_key_version
pan_last4
不要自行设计加密算法,也不要使用 ECB 模式、固定 IV、可逆”混淆”或单纯的 Base64 编码。
密钥与数据分离
密钥不能与密文保存在同一个数据库或同一份配置文件中。应使用 KMS、HSM 或专用密钥管理系统,并支持:
- 密钥访问审计
- 定期轮换
- 密钥版本管理
- 最小权限
- 紧急吊销
- 职责分离
如果应用进程能够不受限制地批量解密全部卡号,数据库加密所能提供的保护仍然有限。
严格控制读取能力
绝大多数业务代码都不需要读取完整 PAN。应把解密操作限制在独立的受控服务中,并满足以下要求:
- 默认拒绝访问
- 按职责授权
- 管理员不能因为拥有数据库权限就自动获得解密权限
- 记录每次敏感数据访问
- 对异常批量读取发出告警
- 定期复核权限
限制保存时间
应制定明确的数据保留规则。业务不再需要的数据要安全删除,同时检查以下位置:
- 数据库备份
- 只读副本
- 搜索索引
- 缓存
- 数据仓库
- 灾难恢复副本
只删除主表中的一行,并不代表系统中的这份数据已经全部清除。
还要注意的安全细节
不记录敏感信息
请求日志、异常信息和可观测性平台都应进行脱敏。不要直接记录整个请求对象:
// 不推荐
logger.info({ body: req.body }, "payment request");
// 推荐:只记录业务所需标识
logger.info(
{
orderId: req.body.orderId,
requestId: req.id
},
"payment request received"
);
如果日志中需要显示卡号,只能使用允许的掩码形式:
•••• •••• •••• 4242
不在测试环境使用真实卡
开发、测试、演示和自动化测试应使用服务商提供的 Sandbox、测试令牌和测试卡号。不要把生产环境中的卡数据复制到测试数据库。
验证 Webhook
支付结果通知必须验证签名,并防止重放。不能仅凭某个请求声称”支付成功”,就把订单状态改为已付款。
处理浏览器端风险
支付页面需要防范 XSS、恶意第三方脚本和供应链攻击。应尽量减少第三方脚本,配置合适的 Content Security Policy,并及时更新依赖。Hosted Fields 可以减少卡数据暴露,但无法让存在恶意脚本的支付页面自动变得安全。
不把令牌当成普通公开数据
支付令牌通常不能直接还原为卡号,但仍可能被用于未经授权的扣款。保护支付令牌时,应采用与账户凭证相近的措施:
- 不放入 URL
- 不写入前端日志
- 限制数据库访问
- 绑定当前商户和客户
- 删除或停用失效的支付方式
合规边界
具体需要满足哪些 PCI DSS 要求,取决于支付流程、商户类型、交易量、收单机构要求,以及所在国家或地区的法律。即使使用第三方支付服务商,也不代表完全没有合规义务,但通常可以大幅缩小合规范围。
上线前,应请支付服务商或收单银行确认:
- 推荐的集成方式
- 适用的 PCI DSS 自评估问卷或审计要求
- 是否允许保存 PAN
- 定期付款和”一键支付”的正确实现
- 强客户认证、3-D Secure 或当地监管要求
- 数据泄露后的通知和处置责任
对大多数 Web 应用来说,合适的方案很清楚:使用支付服务商托管的卡信息采集组件,通过令牌完成支付。自己的数据库不保存完整卡号,也绝不能保存 CVV。
备注:内容仅供参考。
