PAYATHON 2026

如何仅为特定 route 禁用 Laravel CSRF 验证?

支付老李

结论

可以只为支付回调路由关闭 CSRF 验证,但必须改用支付平台提供的签名、MAC、证书或服务端查询接口来验证请求是否真实。

Laravel 通常不会对 GET 请求执行 CSRF 验证。如果示例路由确实使用 GET,却仍然出现 TokenMismatchException,先检查支付平台实际发送的是否为 POST,并确认异常是不是由其他请求触发的。

配置 CSRF 例外

Laravel 版本不同,配置方式也有所不同。

较新的 Laravel 项目

如果项目通过 bootstrap/app.php 配置中间件,可以在其中添加例外路径:

use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->validateCsrfTokens(except: [
        'payment/ok',
        'payment/fail',
    ]);
})

路径通常不需要以 / 开头。

使用 VerifyCsrfToken 中间件的项目

在 app/Http/Middleware/VerifyCsrfToken.php 中设置 $except:

<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;

class VerifyCsrfToken extends Middleware
{
    protected $except = [
        'payment/ok',
        'payment/fail',
    ];
}

这里也支持通配符。不过,支付回调最好逐一列出,以免无意中扩大豁免范围:

protected $except = [
    'payment/callback/*',
];

正确声明回调路由

路由使用哪种 HTTP 方法,应以第三方支付平台实际发送的请求为准。如果平台发送 POST:

use App\Http\Controllers\TransactionsController;
use Illuminate\Support\Facades\Route;

Route::post('/payment/ok', [TransactionsController::class, 'ok']);
Route::post('/payment/fail', [TransactionsController::class, 'fail']);

如果支付平台确实使用 GET,则可以保留:

Route::get('/payment/ok', [TransactionsController::class, 'ok']);
Route::get('/payment/fail', [TransactionsController::class, 'fail']);

不要依赖 $_REQUEST,应通过 Laravel 的 Request 对象读取参数:

use Illuminate\Http\Request;

public function ok(Request $request)
{
    $transId = $request->input('trans_id');

    if (!$transId) {
        return response('Missing transaction ID', 400);
    }

    return response($transId);
}

input() 会同时读取查询字符串和请求体。如果只接受查询字符串参数,可以改用:

$transId = $request->query('trans_id');

CSRF 豁免不等于回调可信

CSRF token 用于保护浏览器会话,不适合验证第三方服务器回调。关闭 CSRF 后,任何人都可以尝试访问该 URL,所以不能只凭 trans_id 就把订单标记为支付成功。

较稳妥的处理流程如下:

  1. 按照支付平台文档验证回调签名。
  2. 核对商户编号、订单编号、金额、币种和支付状态。
  3. 必要时调用支付平台的服务端 API,再次查询交易结果。
  4. 确认交易对应的本地订单存在,并且尚未处理。
  5. 在数据库事务中更新订单状态。
  6. 保证接口具有幂等性,避免重复回调造成重复入账或发货。
  7. 按平台要求返回确认响应,否则平台可能继续重试。

下面是示意代码。具体字段和签名算法必须以支付平台文档为准:

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

public function ok(Request $request)
{
    if (!$this->paymentGateway->verifySignature($request->all())) {
        return response('Invalid signature', 403);
    }

    $transId = $request->input('trans_id');

    if (!$transId) {
        return response('Missing transaction ID', 400);
    }

    $transaction = $this->paymentGateway->queryTransaction($transId);

    if (!$transaction || $transaction->status !== 'paid') {
        return response('Transaction not paid', 400);
    }

    DB::transaction(function () use ($transaction) {
        $order = Order::where('payment_reference', $transaction->id)
            ->lockForUpdate()
            ->firstOrFail();

        if ($order->is_paid) {
            return;
        }

        if ($order->amount !== $transaction->amount) {
            throw new RuntimeException('Payment amount mismatch');
        }

        $order->update([
            'is_paid' => true,
            'paid_at' => now(),
        ]);
    });

    return response('OK', 200);
}

区分“浏览器跳转”和“服务端通知”

支付平台通常会提供两类地址:

  • 浏览器返回地址:支付完成后将用户重定向回来,用于展示结果。
  • 服务端回调地址:由支付平台直接通知应用,用于确认交易并更新订单。

浏览器返回页面中的参数可能被用户修改,不能将其作为支付成功的唯一依据。订单状态应由经过签名验证的服务端回调或主动查询结果决定。返回页面只负责读取并展示本地订单的最新状态。

因此,只应为真正接收第三方通知的回调端点关闭 CSRF 验证,并按照支付平台的要求,为该端点配置独立的身份验证机制。

备注:内容仅供参考。