如何在数据库中临时安全存储信用卡信息?
结论
不要在现有的 PHP/MySQL CRM 中临时保存完整的信用卡信息。“只保存几小时”“加密后保存”或“付款后删除”都不能免除 PCI DSS 要求。这样做还会把 CRM、数据库、服务器、备份、日志、管理员终端及相关运维流程纳入持卡人数据环境(CDE)。
更合适的做法是使用收单机构或符合 PCI DSS 要求的支付服务商提供的能力,例如:
- 电话支付或 MOTO(Mail Order/Telephone Order)虚拟终端;
- 供客户填写的支付链接;
- 服务商托管的支付页面或安全录入组件;
- 首次付款时生成 token,经客户授权后用于后续付款;
- 服务商提供的延迟处理或授权后捕获功能。
CRM 只保存支付服务商返回的 token、交易 ID、卡品牌和脱敏卡号,例如 Visa •••• 1234,不要保存 PAN、CVV/CVC、磁道数据或 PIN。
为什么“加密后暂存”仍然不够
PCI DSS 关注的不只是数据库中有没有明文,还包括持卡人数据经过的完整路径:
客户口述
→ 业务人员的电脑和浏览器
→ JavaScript
→ PHP 应用
→ 网络与服务器
→ MySQL
→ 备份、复制、日志和监控
→ 簿记员电脑
→ 人工输入 terminal
只要 CRM 接收完整 PAN(Primary Account Number),或者有能力将其还原,上述组件通常都可能进入 PCI DSS 范围。应用层加密只是其中一项控制措施,不能替代网络隔离、身份认证、审计、漏洞管理、渗透测试、密钥管理和事件响应等要求。
这里还要区分两类数据:
- PAN:有明确业务需求和法律依据时可以保存,但必须按照 PCI DSS 要求进行保护,例如截断、tokenization 或强加密。
- Sensitive Authentication Data(SAD):包括 CVV/CVC/CID、完整磁道数据和 PIN/PIN block。授权完成后不得保存,即使已经加密也不行。
如果员工先记录 CVV,过一段时间再发起交易,那么授权前能否短暂保存,需要由收单机构或合格安全评估机构根据具体支付流程确认。授权完成后,必须确保没有留下任何副本。最安全的设计仍然是让 CRM 完全接触不到这些数据。
推荐流程
方案一:由业务人员直接使用虚拟终端
如果可以调整权限划分,可以为业务人员开通受限的虚拟终端账号:
- 业务人员在支付服务商的独立页面中录入卡片信息。
- 服务商立即完成授权或付款。
- CRM 只记录交易 ID、金额、状态、时间和操作人。
- 退款、撤销和对账权限仍可只授予簿记员。
通过角色权限和交易限额控制风险,通常比先把卡号交给另一个人,再由对方处理更安全。虚拟终端是否适用于当前商户类别、交易类型和 SAQ 范围,需要事先向收单机构确认。
方案二:向客户发送支付链接
在很多场景中,这是最简洁的流程:
- 业务人员在 CRM 中创建付款请求。
- 后端调用支付服务商 API,生成一次性支付链接。
- 通过短信或邮件将链接发送给客户。
- 客户直接在服务商托管的页面中输入卡片信息。
- 服务商通过 webhook 将付款结果通知 CRM。
卡号不会经过公司的 JavaScript、PHP 或 MySQL,内部人员也不需要看到卡号。
方案三:tokenization
如果客户明确授权公司进行后续付款,可以让支付服务商在其 PCI 环境中保存卡片,并向 CRM 返回不可逆的 token。
数据库可以保存以下信息:
customer_id
payment_provider
payment_method_token
card_brand
card_last4
card_expiry_display
provider_customer_id
consent_reference
created_at
revoked_at
token 必须只对指定商户或服务商有效,不能由公司自行设计为“加密卡号”。客户授权、循环扣款、card-on-file 标记和通知要求,应按照收单机构规则及当地法律确定。
PHP 集成示例
下面的代码只展示安全流程的边界,并不对应任何支付服务商的真实 API。实际字段、签名算法和端点必须以所选服务商的官方 SDK 和文档为准。
创建托管付款链接
<?php
declare(strict_types=1);
final class PaymentService
{
public function createPaymentLink(
int $customerId,
int $amountMinor,
string $currency
): array {
// 使用支付服务商的官方服务端 SDK。
// 不要从请求中接收 card_number、cvv 或 magnetic_track_data。
$response = $this->providerClient()->paymentLinks()->create([
'amount' => $amountMinor,
'currency' => $currency,
'reference' => 'customer:' . $customerId,
'success_url' => 'https://crm.example.com/payments/complete',
]);
return [
'provider_payment_id' => $response->id,
'checkout_url' => $response->url,
'status' => $response->status,
];
}
private function providerClient(): object
{
// 示例占位:从 secrets manager 读取 API credential,
// 不要将密钥写入源码或提交到版本库。
throw new LogicException('Initialize the provider SDK here.');
}
}
CRM 保存的是付款请求,而不是卡号:
CREATE TABLE payment_requests (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
customer_id BIGINT UNSIGNED NOT NULL,
provider_payment_id VARCHAR(191) NOT NULL UNIQUE,
amount_minor BIGINT UNSIGNED NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(32) NOT NULL,
requested_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL,
completed_at DATETIME NULL
);
验证 webhook
<?php
declare(strict_types=1);
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_PROVIDER_SIGNATURE'] ?? '';
if (!verifyProviderWebhook($rawBody, $signature)) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
if (($event['type'] ?? null) === 'payment.succeeded') {
$providerPaymentId = (string) $event['data']['payment_id'];
// 使用参数化 SQL,并按 provider_payment_id 幂等更新状态。
markPaymentAsCompleted($providerPaymentId);
}
http_response_code(204);
verifyProviderWebhook() 必须采用服务商规定的签名格式、时间戳校验和重放防护,不能根据示例猜测算法。日志中也不要记录完整的 webhook,除非已经确认其中绝不包含持卡人数据。
如果确实无法避免临时存储
只有在收单机构确认流程可行,并由熟悉 PCI DSS 的 QSA 或安全团队完成范围评估后,才能考虑自行保存 PAN。至少需要处理以下问题。
数据最小化
- 不保存 CVV/CVC、磁道数据或 PIN。
- 只收集完成付款所必需的数据。
- 明确规定最长保留期限,由系统自动清除,不要依赖簿记员手动点击“删除”。
- 页面和 API 不得将卡号写入 URL、查询参数、分析系统或错误报告。
加密与密钥管理
- 使用经过评审的认证加密方案,例如带认证的 AES-GCM。
- 每条记录使用唯一且不可预测的 nonce/IV。
- 不要自行设计密码算法,也不要把 Base64 编码当成加密。
- 密钥不能与密文保存在同一数据库或同一配置文件中。
- 使用 KMS、HSM 或专门的 secrets manager 管理主密钥。
- 建立密钥轮换、停用、权限分离、恢复和审计流程。
- 如果 Web 服务器可以自动取得解密密钥,攻击者控制服务器后通常也能解密数据。数据库字段已经加密,并不能消除这项风险。
访问控制
- 业务人员提交后,不能再次查看完整卡号。
- 只有得到明确授权的簿记员才能执行一次性显示或解密。
- 使用独立账号、最小权限和多因素认证。
- 对查看、解密、失败尝试和删除操作保留不可篡改的审计记录。
- 审计日志记录“谁在何时访问了哪条记录”,但不能记录卡号和 CVV。
- 防止批量导出,并针对异常访问量设置告警。
- 禁止共享账号,限制客服、开发人员和数据库管理员的日常访问权限。
浏览器和前端风险
如果卡号进入公司自己的 JavaScript 页面,前端依赖、浏览器扩展、XSS、调试工具、会话回放和第三方分析脚本都可能导致泄露。支付录入页面应与普通 CRM 页面隔离,并移除不必要的第三方脚本。
只在浏览器中加密后再提交,也不会自动缩小合规范围,因为公司的代码仍然控制着数据采集和加密过程。
日志与外围副本
需要重点检查:
- PHP 错误日志和异常堆栈;
- Web server access log;
- APM、session replay 和前端监控;
- MySQL general log、slow query log、binary log 和复制节点;
- 数据库 dump、快照和异地备份;
- 消息队列、缓存、搜索索引和调试工具;
- 客服工单、截图、剪贴板、浏览器自动填充和通话录音。
这些位置都不应出现卡号。通话录音尤其需要检查。如果客户在录音期间口述卡号,录音本身也可能成为持卡人数据存储。
删除并不等于执行一条 DELETE
下面的操作只能删除逻辑记录:
DELETE FROM temporary_cards WHERE id = ?;
数据仍可能留在以下位置:
- InnoDB page 和 undo/redo log;
- binary log 和只读副本;
- 快照、备份和灾难恢复副本;
- 缓存、交换空间和崩溃转储;
- 存储设备的历史块中。
因此,需要制定覆盖备份和副本的保留策略。实践中通常会使用短期数据密钥和 crypto-shredding。通过删除或停用对应密钥,使遗留密文无法恢复。但这种做法必须以正确的密钥隔离、备份生命周期和验证机制为前提,不能简单理解为删除一行密钥记录。
还需要确认的事项
开发前,应把实际数据流图提交给收单机构、支付服务商或 QSA,至少确认以下事项:
- 这类电话交易应采用哪种 MOTO/card-not-present 流程;
- 当前实体 terminal 是否允许工作人员稍后手工输入卡号;
- 可以使用哪种虚拟终端、支付链接或 token 服务;
- 适用哪份 SAQ 或其他验证方式;
- 如何处理通话录音、授权凭证和退款流程;
- 当地有关隐私、消费者保护和数据泄露通知的义务。
最终建议很明确:先调整付款流程,再考虑加密技术。 让合规的支付服务商接收卡号,CRM 只处理 token 和交易状态,通常比自行建设临时卡号保险箱更安全,也更便于审计和维护。
备注:内容仅供参考。
