PAYATHON 2026

将 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
  • 开发和测试环境

即使数据库字段已经加密,只要应用能够解密,攻击者取得应用权限后仍可能读到明文。自行保存卡号还要解决密钥轮换、权限审计、备份清理、数据保留期限、入侵检测和事件响应等问题。

更有效的办法,是尽可能减少敏感数据进入自己的系统。

推荐的支付流程

常见的安全流程如下:

  1. 前端加载支付服务商提供的 Hosted Fields 或客户端 SDK。
  2. 用户在服务商控制的输入框中填写卡号、有效期和 CVV。
  3. 卡信息直接发送给支付服务商,不经过自己的服务器。
  4. 服务商返回一次性令牌或可复用的 payment_method_id。
  5. 前端把这个标识提交给自己的后端。
  6. 后端使用服务端密钥调用支付服务商 API,完成扣款或绑定。
  7. 数据库只记录令牌、交易编号、金额、币种和支付状态。

流程可以表示为:

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。

备注:内容仅供参考。