PAYATHON 2026

Laravel 中如何安全传递 3DSecure 信用卡信息?

支付阿杰

结论

不要把完整卡号、有效期或 CVV 放进 Laravel encrypted cookie,也不要为了跨越 3DS 跳转而长期自行保存这些信息。

建议采用以下流程:

  1. 通过支付服务商的 Hosted Fields、支付页面或前端 SDK 收集卡片信息。
  2. 由支付服务商返回一次性 token、payment method ID 或 payment intent ID。
  3. Laravel 只保存订单号、支付服务商返回的标识、3DS 状态和随机 state。
  4. 用户完成 3DS 验证并返回后,后端使用这些标识继续支付,不再向浏览器索取原始卡片信息。

具体接口名称取决于支付服务商。如果现有接口明确要求在 3DS 返回后再次提交完整卡片信息,应先向服务商确认是否支持 tokenization、支付会话或由服务端继续交易的接口。自行保管卡片数据会显著扩大 PCI DSS 合规范围。CVV 原则上不能在授权完成后保存,即使经过加密也不行。

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 跳转时传递的应是不可猜测的交易引用,而不是银行卡信息本身。

备注:内容仅供参考。