Bonaventure OgetoBy Bonaventure Ogeto|

Paystack Webhooks and CSRF Protection in Laravel

Laravel returns 419 Page Expired for Paystack webhooks because its VerifyCsrfToken middleware rejects POST requests without a CSRF token. The fix depends on your Laravel version. In Laravel 10 and below, add your webhook path to the $except array in App/Http/Middleware/VerifyCsrfToken.php. In Laravel 11, use the withMiddleware callback in bootstrap/app.php to exclude your webhook URI from CSRF verification.

Why Laravel Returns 419 for Paystack Webhooks

When Paystack sends a webhook to your Laravel application, it sends a POST request with a JSON body and an X-Paystack-Signature header. Laravel's CSRF middleware (VerifyCsrfToken) intercepts the request and looks for a CSRF token. Since Paystack is a server, not a browser, it does not send a CSRF token. Laravel rejects the request with a 419 Page Expired response.

The 419 status code is Laravel-specific. It is not a standard HTTP status code. When you see 419 in your Paystack dashboard's webhook delivery log, it always means CSRF verification failed.

The fix is to exclude your webhook route from CSRF verification. How you do this depends on your Laravel version and where you define the route.

There are two approaches:

  1. Define the route in routes/api.php. The api middleware group does not include VerifyCsrfToken, so routes in api.php are not subject to CSRF checking. This is the cleanest approach.
  2. Add an explicit exception. If your route must be in routes/web.php (perhaps because of other middleware you need), you can add the URI to the CSRF exception list.

We will cover both approaches, starting with the cleanest one.

The Clean Approach: routes/api.php

The simplest way to avoid CSRF issues is to define your webhook route in routes/api.php instead of routes/web.php. Routes in api.php use the "api" middleware group, which does not include CSRF verification.

// routes/api.php
use App\Http\Controllers\PaystackWebhookController;

Route::post('/webhooks/paystack', [PaystackWebhookController::class, 'handle']);

The URL for this route will be https://yourdomain.com/api/webhooks/paystack (Laravel prepends /api to all routes in api.php by default). Register this full URL in your Paystack dashboard.

If you do not want the /api prefix, you can customize it in app/Providers/RouteServiceProvider.php (Laravel 10) or bootstrap/app.php (Laravel 11).

In Laravel 11, the route registration in bootstrap/app.php looks like this:

// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        api: __DIR__.'/../routes/api.php',
        apiPrefix: 'api',  // Change this to '' for no prefix
        // ...
    )
    ->create();

This approach is preferred because it avoids CSRF entirely rather than creating exceptions. Your webhook endpoint is an API endpoint, not a web page, so it belongs in the API route group.

CSRF Exception: Laravel 10 and Below

If you need your webhook route in routes/web.php, you must add it to the CSRF exception list. In Laravel 10 and below, open app/Http/Middleware/VerifyCsrfToken.php and add the URI to the $except array:

<?php

namespace App\Http\Middleware;

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

class VerifyCsrfToken extends Middleware
{
    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array<int, string>
     */
    protected $except = [
        'webhooks/paystack',
        'webhooks/paystack/*',  // Also exclude sub-paths if needed
    ];
}

The URI pattern supports wildcards. webhooks/paystack matches exactly that path, while webhooks/paystack/* matches any path starting with webhooks/paystack/. Include both if you might add sub-routes later.

Important: the URI in the $except array does not include the leading slash. webhooks/paystack, not /webhooks/paystack. This trips up developers who copy the path directly from their route definition.

CSRF Exception: Laravel 11

Laravel 11 changed the middleware configuration approach. Instead of a separate VerifyCsrfToken middleware class, you configure CSRF exceptions in bootstrap/app.php:

<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->validateCsrfTokens(except: [
            'webhooks/paystack',
            'webhooks/paystack/*',
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions) {
        //
    })
    ->create();

The validateCsrfTokens(except: [...]) method replaces the old $except array. The syntax is different, but the behavior is identical. The URIs you list here will skip CSRF verification.

If you are upgrading from Laravel 10 to 11 and your webhook stops working, this is the most likely cause. Your old VerifyCsrfToken.php middleware class is no longer used, and the exceptions you had there are not automatically migrated. You need to add them to bootstrap/app.php manually.

Complete Webhook Controller

Here is a complete Laravel controller for handling Paystack webhooks with signature verification:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;

class PaystackWebhookController extends Controller
{
    public function handle(Request $request): JsonResponse
    {
        // Get the webhook secret
        $secret = config('services.paystack.webhook_secret');
        if (empty($secret)) {
            Log::error('PAYSTACK_WEBHOOK_SECRET not configured');
            return response()->json(['error' => 'Server misconfigured'], 500);
        }

        // Get the signature header
        $signature = $request->header('x-paystack-signature');
        if (empty($signature)) {
            return response()->json(['error' => 'Missing signature'], 400);
        }

        // Get the raw body for signature verification
        $rawBody = $request->getContent();

        // Compute HMAC-SHA512 hash
        $computedHash = hash_hmac('sha512', $rawBody, $secret);

        // Verify signature (timing-safe comparison)
        if (!hash_equals($computedHash, $signature)) {
            Log::warning('Invalid Paystack webhook signature');
            return response()->json(['error' => 'Invalid signature'], 401);
        }

        // Parse the verified payload
        $payload = json_decode($rawBody, true);
        $eventType = $payload['event'] ?? '';
        $data = $payload['data'] ?? [];

        Log::info('Paystack webhook received: ' . $eventType);

        // Route to the appropriate handler
        match ($eventType) {
            'charge.success' => $this->handleChargeSuccess($data),
            'transfer.success' => $this->handleTransferSuccess($data),
            'transfer.failed' => $this->handleTransferFailed($data),
            'refund.processed' => $this->handleRefundProcessed($data),
            default => Log::info('Unhandled event: ' . $eventType),
        };

        return response()->json(['received' => true], 200);
    }

    private function handleChargeSuccess(array $data): void
    {
        $reference = $data['reference'] ?? '';
        $amount = $data['amount'] ?? 0;
        Log::info('Payment successful: ' . $reference . ' Amount: ' . $amount);

        // Your business logic:
        // - Update order status
        // - Send confirmation email
        // - Grant access to product
    }

    private function handleTransferSuccess(array $data): void
    {
        $transferCode = $data['transfer_code'] ?? '';
        Log::info('Transfer completed: ' . $transferCode);
    }

    private function handleTransferFailed(array $data): void
    {
        $transferCode = $data['transfer_code'] ?? '';
        $reason = $data['reason'] ?? 'Unknown';
        Log::warning('Transfer failed: ' . $transferCode . ' Reason: ' . $reason);
    }

    private function handleRefundProcessed(array $data): void
    {
        $reference = $data['transaction_reference'] ?? '';
        Log::info('Refund processed for: ' . $reference);
    }
}

And the config entry in config/services.php:

// config/services.php
return [
    // ... other services

    'paystack' => [
        'secret_key' => env('PAYSTACK_SECRET_KEY'),
        'public_key' => env('PAYSTACK_PUBLIC_KEY'),
        'webhook_secret' => env('PAYSTACK_WEBHOOK_SECRET'),
    ],
];

A few notes:

  • $request->getContent() returns the raw body as a string. This is the correct method for HMAC verification. Do not use $request->all() or $request->json().
  • hash_equals() is PHP's built-in constant-time string comparison. Always use this instead of === for comparing hashes.
  • The match expression (PHP 8.0+) is a clean way to route events. If you are on PHP 7.x, use a switch statement instead.

Custom Middleware for Signature Verification

If you want to keep your controller even cleaner, extract the signature verification into a custom middleware. This also lets you reuse the verification logic if you have multiple webhook endpoints (for example, from different payment providers).

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class VerifyPaystackSignature
{
    public function handle(Request $request, Closure $next)
    {
        $secret = config('services.paystack.webhook_secret');

        if (empty($secret)) {
            Log::error('PAYSTACK_WEBHOOK_SECRET not configured');
            abort(500, 'Server misconfigured');
        }

        $signature = $request->header('x-paystack-signature');

        if (empty($signature)) {
            abort(400, 'Missing signature');
        }

        $computedHash = hash_hmac('sha512', $request->getContent(), $secret);

        if (!hash_equals($computedHash, $signature)) {
            Log::warning('Invalid Paystack webhook signature');
            abort(401, 'Invalid signature');
        }

        return $next($request);
    }
}

Register the middleware in your route:

// routes/api.php
use App\Http\Controllers\PaystackWebhookController;
use App\Http\Middleware\VerifyPaystackSignature;

Route::post('/webhooks/paystack', [PaystackWebhookController::class, 'handle'])
    ->middleware(VerifyPaystackSignature::class);

Now your controller does not need to worry about signature verification at all. By the time the request reaches the controller, it has already been verified. This is a cleaner separation of concerns: the middleware handles security, and the controller handles business logic.

Offloading to Laravel Queues

For webhook handlers that need to do more than a quick database update, push the event to a Laravel queue and return 200 immediately. This ensures Paystack gets a fast response even if your business logic takes time.

<?php

// app/Jobs/ProcessPaystackEvent.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

class ProcessPaystackEvent implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 60;

    public function __construct(
        public string $eventType,
        public array $data
    ) {}

    public function handle(): void
    {
        match ($this->eventType) {
            'charge.success' => $this->handleChargeSuccess(),
            'transfer.success' => $this->handleTransferSuccess(),
            default => Log::info('Unhandled queued event: ' . $this->eventType),
        };
    }

    private function handleChargeSuccess(): void
    {
        $reference = $this->data['reference'] ?? '';
        Log::info('Processing payment in queue: ' . $reference);
        // Heavy processing here
    }

    private function handleTransferSuccess(): void
    {
        Log::info('Processing transfer in queue: ' . ($this->data['transfer_code'] ?? ''));
    }
}
// In your controller:
public function handle(Request $request): JsonResponse
{
    $payload = json_decode($request->getContent(), true);

    ProcessPaystackEvent::dispatch(
        $payload['event'] ?? '',
        $payload['data'] ?? []
    );

    return response()->json(['received' => true], 200);
}

For a comprehensive guide to queuing webhook work in Laravel, see Queueing Paystack Webhook Work with Laravel Queues.

Testing Your Webhook Endpoint

Write a test to verify your webhook endpoint works correctly. Laravel's built-in testing tools make this straightforward:

<?php

namespace Tests\Feature;

use Tests\TestCase;

class PaystackWebhookTest extends TestCase
{
    public function test_webhook_rejects_missing_signature(): void
    {
        $response = $this->postJson('/api/webhooks/paystack', [
            'event' => 'charge.success',
            'data' => ['reference' => 'test_123'],
        ]);

        $response->assertStatus(400);
    }

    public function test_webhook_rejects_invalid_signature(): void
    {
        $response = $this->postJson('/api/webhooks/paystack', [
            'event' => 'charge.success',
            'data' => ['reference' => 'test_123'],
        ], [
            'X-Paystack-Signature' => 'invalid_signature',
        ]);

        $response->assertStatus(401);
    }

    public function test_webhook_accepts_valid_signature(): void
    {
        $payload = json_encode([
            'event' => 'charge.success',
            'data' => [
                'reference' => 'test_ref_123',
                'amount' => 500000,
                'currency' => 'NGN',
            ],
        ]);

        $secret = config('services.paystack.webhook_secret');
        $signature = hash_hmac('sha512', $payload, $secret);

        $response = $this->call(
            'POST',
            '/api/webhooks/paystack',
            [],
            [],
            [],
            [
                'HTTP_X_PAYSTACK_SIGNATURE' => $signature,
                'CONTENT_TYPE' => 'application/json',
            ],
            $payload
        );

        $response->assertStatus(200);
        $response->assertJson(['received' => true]);
    }
}

Set a test webhook secret in your .env.testing file:

PAYSTACK_WEBHOOK_SECRET=test_webhook_secret_for_testing

The test manually computes the HMAC signature using the same secret and algorithm that Paystack uses, then sends it with the request. This verifies that your controller correctly validates the signature and returns 200 for properly signed requests.

Key Takeaways

  • Laravel returns a 419 Page Expired response (not 403) when CSRF verification fails. This is the telltale sign that your webhook endpoint needs a CSRF exception.
  • In Laravel 10 and below, add the webhook URI to the $except array in VerifyCsrfToken middleware. In Laravel 11, use the validateCsrfTokens exclusion in bootstrap/app.php.
  • Define webhook routes in routes/api.php, not routes/web.php. The api middleware group does not include CSRF verification by default, which avoids the problem entirely.
  • Use request()->getContent() to get the raw request body for HMAC verification. Do not use request()->all() or request()->json(), which return parsed data.
  • Laravel casts header names to lowercase internally. Access the signature with request()->header("x-paystack-signature").
  • Wrap your webhook route in a middleware that handles signature verification, keeping your controller clean and reusable.

Frequently Asked Questions

Why does Laravel return 419 instead of 403 for CSRF failures?
Laravel uses the custom HTTP status code 419 (Page Expired) for CSRF verification failures instead of the standard 403 (Forbidden). This is a Laravel convention to distinguish CSRF failures from other authorization errors. When you see 419 in your Paystack webhook delivery log, it always means CSRF protection is blocking the request.
Should I put my webhook route in web.php or api.php?
Use api.php. The api middleware group does not include CSRF verification, so routes in api.php are not subject to CSRF checking. This avoids the need for explicit CSRF exceptions. Your webhook endpoint is an API endpoint (it receives machine-to-machine POST requests), not a web page, so it belongs in the API route group.
Can I use request()->all() for signature verification in Laravel?
No. request()->all() returns the parsed request data as a PHP array. If you convert it back to JSON with json_encode(), the output may differ from the original payload (different key ordering, whitespace, Unicode escaping). Use request()->getContent() to get the raw body string, which is the exact bytes Paystack sent and signed.
How do I handle webhooks during Laravel deployment with zero downtime?
If you use a zero-downtime deployment tool like Envoyer or Laravel Forge, your webhook endpoint should remain available during deployment because the old version continues serving requests until the new version is ready. If you have brief downtime during deployment, Paystack will retry the webhook. Make sure your handler is idempotent so retried events do not cause double processing.
Does the CSRF exception in Laravel 10 carry over when upgrading to Laravel 11?
No. Laravel 11 removed the VerifyCsrfToken middleware class in favor of configuration in bootstrap/app.php. When upgrading, you must manually add your webhook URI to the validateCsrfTokens exception list in bootstrap/app.php. The old $except array in VerifyCsrfToken.php is not automatically migrated.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon