Laravel 中如何安全传递 3DSecure 信用卡信息?
结论
不要把完整卡号、有效期或 CVV 放进 Laravel encrypted cookie,也不要为了跨越 3DS 跳转而长期自行保存这些信息。
建议采用以下流程:
- 通过支付服务商的 Hosted Fields、支付页面或前端 SDK 收集卡片信息。
- 由支付服务商返回一次性 token、payment method ID 或 payment intent ID。
- Laravel 只保存订单号、支付服务商返回的标识、3DS 状态和随机
state。 - 用户完成 3DS 验证并返回后,后端使用这些标识继续支付,不再向浏览器索取原始卡片信息。
具体接口名称取决于支付服务商。如果现有接口明确要求在 3DS 返回后再次提交完整卡片信息,应先向服务商确认是否支持 tokenization、支付会话或由服务端继续交易的接口。自行保管卡片数据会显著扩大 PCI DSS 合规范围。CVV 原则上不能在授权完成后保存,即使经过加密也不行。
为什么不应使用 encrypted cookie
Laravel 的加密 Cookie 能保证数据的机密性和完整性,但这不代表它适合保存支付卡数据。
原因包括:
- Cookie 保存在用户设备上,还会经过浏览器、代理和网络请求链路。
- 浏览器扩展、终端恶意软件或配置错误的调试工具可能获取 Cookie。
- Cookie 会自动随请求发送,因此更容易被日志、监控系统和异常报告意外记录。
- Cookie 容量有限,其生命周期和删除行为也很难完全控制。
- 系统一旦接触、传输或保存完整卡号,通常就会进入更严格的 PCI DSS 合规范围。
- CVV 属于敏感认证数据,不能把加密存储当成合规的长期保存方案。
HttpOnly、Secure 和 SameSite 等属性仍应设置,但这些属性适合保护会话标识,无法把 Cookie 变成银行卡保险箱。
推荐的支付流程
典型流程如下:
浏览器
│
│ 卡片信息通过支付服务商 SDK / Hosted Fields 提交
▼
支付服务商
│
│ 返回 payment_method_id / token
▼
Laravel
│
│ 创建本地支付记录,只保存引用和随机 state
│
│ 发起 3DS 验证
▼
发卡行验证页面
│
│ 用户完成验证
▼
Laravel 回调地址
│
│ 校验 state、订单状态和服务商回调
│
│ 使用 payment intent / token 继续支付
▼
支付服务商
本地数据库可以保存:
- 本地订单 ID;
- 支付服务商的 payment ID、transaction ID 或 token;
- 随机生成且只能使用一次的
state; - 支付状态和必要的失败原因;
- 金额、币种;
- 卡片品牌和掩码,例如
Visa •••• 4242; - 3DS 结果中允许商户保存的交易标识。
以下内容不要保存:
- CVV;
- 磁道数据;
- PIN 或 PIN block;
- 完整卡号,除非业务确实需要、支付服务商无法代管,并且已经建立相应的 PCI DSS 控制;
- 序列化后的完整卡片信息,无论存放位置是 session、cache、queue、日志还是异常上下文。
Laravel 实现示例
下面是一个与支付服务商无关的结构示例。PaymentGateway 中的方法只是抽象名称,使用时需要替换成实际 SDK 提供的接口。
创建支付记录并发起 3DS
<?php
namespace App\Http\Controllers;
use App\Models\Payment;
use App\Services\PaymentGateway;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class PaymentController extends Controller
{
public function start(Request $request, PaymentGateway $gateway)
{
$data = $request->validate([
'order_id' => ['required', 'integer'],
'payment_method_id' => ['required', 'string', 'max:255'],
]);
$state = Str::random(64);
$payment = DB::transaction(function () use ($data, $state) {
return Payment::create([
'order_id' => $data['order_id'],
'gateway_payment_method_id' => $data['payment_method_id'],
'three_ds_state_hash' => hash('sha256', $state),
'status' => 'pending_3ds',
]);
});
$result = $gateway->startThreeDS([
'payment_id' => (string) $payment->id,
'payment_method_id' => $payment->gateway_payment_method_id,
'return_url' => route('payments.3ds.callback'),
'state' => $state,
]);
$payment->update([
'gateway_transaction_id' => $result->transactionId,
]);
return redirect()->away($result->redirectUrl);
}
}
浏览器提交的是支付服务商生成的 payment_method_id,不是卡号或 CVV。数据库中保存随机 state 的哈希,用于关联并约束回调。
处理 3DS 返回
旧版流程可能返回 PaRes 和 MD,较新的 3DS2 接口也可能返回其他交易标识。字段名称和验证方式必须以支付服务商的文档为准。
<?php
namespace App\Http\Controllers;
use App\Models\Payment;
use App\Services\PaymentGateway;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ThreeDSCallbackController extends Controller
{
public function __invoke(Request $request, PaymentGateway $gateway)
{
$data = $request->validate([
'payment_id' => ['required', 'integer'],
'state' => ['required', 'string'],
'PaRes' => ['nullable', 'string'],
'MD' => ['nullable', 'string'],
]);
$payment = Payment::query()->findOrFail($data['payment_id']);
abort_unless(
hash_equals(
$payment->three_ds_state_hash,
hash('sha256', $data['state'])
),
403,
'Invalid 3DS state.'
);
abort_unless($payment->status === 'pending_3ds', 409);
$result = $gateway->completeThreeDS([
'gateway_transaction_id' => $payment->gateway_transaction_id,
'payment_method_id' => $payment->gateway_payment_method_id,
'pares' => $data['PaRes'] ?? null,
'md' => $data['MD'] ?? null,
]);
DB::transaction(function () use ($payment, $result) {
$lockedPayment = Payment::query()
->lockForUpdate()
->findOrFail($payment->id);
if ($lockedPayment->status !== 'pending_3ds') {
return;
}
$lockedPayment->update([
'status' => $result->succeeded ? 'paid' : 'failed',
'three_ds_state_hash' => null,
'gateway_response_code' => $result->responseCode,
]);
});
return $result->succeeded
? redirect()->route('payments.success', $payment)
: redirect()->route('payments.failed', $payment);
}
}
在生产环境中,还要按照支付服务商的要求验证回调签名、3DS 结果,以及服务器到服务器返回的最终支付状态。不能仅凭浏览器提交的 PaRes、MD、订单号或“支付成功”字段更新订单。
Session 应该保存什么
如果业务需要使用 Laravel session,只保存本地支付 ID 即可:
$request->session()->put('pending_payment_id', $payment->id);
不要这样保存:
$request->session()->put('card', [
'number' => $request->input('number'),
'expiry' => $request->input('expiry'),
'cvv' => $request->input('cvv'),
]);
即使 session 使用数据库或 Redis 驱动,完整卡片信息仍会写入服务端存储。如果使用 Cookie session 驱动,这些数据还会通过 Cookie 返回客户端。
3DS 通常包含跨站跳转,浏览器的 SameSite 策略可能使回调请求无法携带原有的 session Cookie。因此,不能只依赖 session 关联交易。应使用独立、随机、短期有效且只能使用一次的 state,并在服务端保存其哈希。
加密后存入数据库是否更安全
与加密 Cookie 相比,受控的服务端数据库通常更方便实施访问控制、审计、到期删除和网络隔离。但这并不表示把数据加密后存进数据库就是推荐做法。
只要数据库中保存了完整卡号:
- 系统仍属于卡片数据处理环境;
- 应用服务器仍需要具备解密能力;
- 数据库、备份、密钥管理、运维访问和日志系统都可能被纳入合规评估;
- 还必须处理密钥轮换、权限分离、审计、数据保留期限和安全删除;
- CVV 仍然不能因为经过加密而长期保存。
所以,只有在确实无法避免保存卡号时,数据库加密才可作为其中一项控制措施。它不能代替 tokenization,也无法解决 CVV 的存储限制。
如果保存数据只是为了完成一次 3DS 交易,应优先让支付服务商代管原始卡片数据,本地只保存短期 token。
是否需要每条数据使用独立密钥
“每条记录使用一个数据密钥,再用网站通用密钥加密数据密钥”是 envelope encryption 的一种形式。它在技术上可以降低部分密钥泄露风险,但前提是密钥体系得到真正隔离。
如果通用主密钥、数据库和应用代码位于同一台服务器,攻击者取得应用执行权限后,通常既能读取密文,也能调用解密逻辑。仅把加密后的独立密钥放在同一个数据库中,能带来的安全收益有限,却会增加轮换、恢复和删除的复杂度。
确实需要保存受保护数据时,可以采用以下方式:
- 使用 KMS 或 HSM 管理主密钥;
- 为数据生成独立的数据加密密钥;
- 数据库只保存密文和经过主密钥加密的数据密钥;
- 限制应用身份,使其只能执行必要的解密操作;
- 审计密钥使用和解密行为;
- 建立密钥轮换、吊销、备份和应急流程;
- 将数据保留期限设到最短。
无论密钥设计多么复杂,都不能用它保存 CVV,也不能让系统自动脱离 PCI DSS 范围。
其他安全注意事项
- 全流程强制使用 HTTPS。
- 回调地址只接受预期的 HTTP 方法和字段。
- 校验支付服务商的签名、证书或消息认证码。
state必须随机、短期有效、绑定具体订单,而且只能使用一次。- 金额和币种必须从服务端订单读取,不能信任回调或前端提交的值。
- 支付完成接口需要具备幂等性,避免重复回调造成重复扣款。
- 不要在日志中记录请求体、
PaRes、卡号、CVV、token 或完整的网关响应。 - 配置异常监控、APM 和反向代理时,要对敏感字段进行脱敏。
- 只有收到支付服务商的服务端确认后,才能把订单标记为已支付。
- 具体实现应遵循所用支付服务商的集成文档,并由负责 PCI DSS 合规的人员或合格安全评估机构确认。
Laravel 应保存支付流程状态,卡片信息则交给支付服务商保管。跨越 3DS 跳转时传递的应是不可猜测的交易引用,而不是银行卡信息本身。
备注:内容仅供参考。
