Paystack Webhook Returning 419 in Laravel
Exclude your Paystack webhook route from Laravel CSRF protection. In Laravel 11, add the exclusion in bootstrap/app.php using withMiddleware(). In Laravel 10 and earlier, add the webhook path to the $except array in app/Http/Middleware/VerifyCsrfToken.php. Paystack webhooks are authenticated via HMAC signature, not CSRF tokens, so excluding the route is safe as long as you verify the x-paystack-signature header.
What the Error Looks Like
You set up a Laravel route for your Paystack webhook. You configure the URL in the Paystack dashboard. A transaction happens. Paystack sends the webhook. Your Laravel app returns:
HTTP/1.1 419
Content-Type: application/json
{
"message": "CSRF token mismatch."
}
Or in your Laravel logs:
Symfony\Component\HttpKernel\Exception\HttpException
419 | CSRF token mismatch.
Paystack sees the 419 response, marks the delivery as failed, and schedules a retry. The retry also fails with 419. After enough retries, Paystack gives up on your endpoint.
This happens because Laravel's VerifyCsrfToken middleware runs on all POST requests by default. It checks for a _token field in the request body or an X-CSRF-TOKEN header. Paystack sends neither.
Why CSRF Protection Blocks Webhooks
CSRF (Cross-Site Request Forgery) protection exists to prevent malicious websites from making requests on behalf of your logged-in users. It works by requiring a secret token that only your frontend knows.
The flow for normal form submissions:
- Laravel generates a CSRF token and embeds it in your form
- User submits the form with the token
- Laravel's VerifyCsrfToken middleware checks the token
- If valid, the request proceeds. If missing or invalid, Laravel returns 419.
Webhooks are server-to-server requests. Paystack's server does not have access to your CSRF token. It cannot include one. So the middleware rejects every webhook request.
This is not a security flaw. CSRF protection and webhook authentication are two different security mechanisms for two different threat models. CSRF protects against browser-based attacks. Webhook signature verification protects against forged server-to-server requests.
Fix for Laravel 11
Laravel 11 changed how middleware is configured. There is no longer a dedicated VerifyCsrfToken middleware class in your app by default. Instead, configure exclusions in bootstrap/app.php:
<?php
// bootstrap/app.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', // exclude this route from CSRF
// 'webhooks/*', // or exclude all webhook routes
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
})
->create();
The validateCsrfTokens(except: [...]) method tells Laravel to skip CSRF verification for the listed URIs. The path should match your route definition without a leading slash.
Fix for Laravel 10 and Older
In Laravel 10, 9, 8, and older versions, add the webhook URI to the $except array in app/Http/Middleware/VerifyCsrfToken.php:
<?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/*', // wildcard if you have multiple webhook providers
];
}
Make sure the URI in the $except array matches your route exactly. It is relative to the application root, without a leading slash. If your route is /webhooks/paystack, use 'webhooks/paystack' in the array.
You can use wildcards: 'webhooks/*' excludes all routes under /webhooks/. This is convenient if you also handle Stripe, Flutterwave, or other webhooks.
Complete Webhook Controller
Here is a complete webhook controller with CSRF exclusion and signature verification:
Route definition (routes/web.php):
use App\Http\Controllers\PaystackWebhookController;
Route::post('/webhooks/paystack', [PaystackWebhookController::class, 'handle']);
Controller:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class PaystackWebhookController extends Controller
{
public function handle(Request $request)
{
// Step 1: Get the raw body for signature verification
$rawBody = file_get_contents('php://input');
// Step 2: Get the signature header
$signature = $request->header('X-Paystack-Signature');
if (!$signature) {
Log::warning('Paystack webhook: No signature header');
return response('No signature', 401);
}
// Step 3: Verify the signature
$secret = config('services.paystack.secret_key');
$computed = hash_hmac('sha512', $rawBody, $secret);
if (!hash_equals($computed, $signature)) {
Log::warning('Paystack webhook: Signature verification failed');
return response('Invalid signature', 401);
}
// Step 4: Parse the verified payload
$event = json_decode($rawBody, true);
// Step 5: Handle the event
Log::info('Paystack webhook received: ' . $event['event']);
match ($event['event']) {
'charge.success' => $this->handleChargeSuccess($event['data']),
'transfer.success' => $this->handleTransferSuccess($event['data']),
'transfer.failed' => $this->handleTransferFailed($event['data']),
default => Log::info('Unhandled Paystack event: ' . $event['event']),
};
// Step 6: Return 200
return response('OK', 200);
}
private function handleChargeSuccess(array $data): void
{
Log::info('Processing charge.success: ' . $data['reference']);
// Update your database, grant access, send receipts, etc.
}
private function handleTransferSuccess(array $data): void
{
Log::info('Processing transfer.success: ' . $data['reference']);
}
private function handleTransferFailed(array $data): void
{
Log::warning('Processing transfer.failed: ' . $data['reference']);
}
}
Config (config/services.php):
'paystack' => [
'secret_key' => env('PAYSTACK_SECRET_KEY'),
'public_key' => env('PAYSTACK_PUBLIC_KEY'),
],
Alternative: Use API Routes Instead
Laravel API routes (routes/api.php) do not have CSRF protection by default. If you define your webhook route in routes/api.php instead of routes/web.php, you do not need to exclude it from CSRF verification.
// routes/api.php
use App\Http\Controllers\PaystackWebhookController;
Route::post('/webhooks/paystack', [PaystackWebhookController::class, 'handle']);
The URL becomes /api/webhooks/paystack (prefixed with /api). Update the webhook URL in your Paystack dashboard to match.
This approach works but has trade-offs:
- Pro: No CSRF configuration needed.
- Con: API routes may have other middleware (like throttling or Sanctum auth) that you need to remove for the webhook route.
- Con: The
/apiprefix changes your URL. If you already configured the non-api URL in Paystack, you need to update it.
Most Laravel developers define webhook routes in routes/web.php with the CSRF exclusion. It is the more common pattern.
Verification and Testing
After applying the fix, verify it works:
# Test locally with artisan serve
php artisan serve
# In another terminal, send a test request
SECRET="sk_test_your_test_key"
BODY='{"event":"charge.success","data":{"id":12345,"reference":"test_ref","amount":50000,"currency":"NGN","status":"success"}}'
SIGNATURE=$(echo -n "$BODY" | openssl dgst -sha512 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:8000/webhooks/paystack \
-H "Content-Type: application/json" \
-H "X-Paystack-Signature: $SIGNATURE" \
-d "$BODY"
# Expected: HTTP 200, body "OK"
# If you still get 419, the CSRF exclusion is not applied correctly
After deploying, trigger a test transaction from the Paystack dashboard and check your Laravel log file (storage/logs/laravel.log) for the webhook receipt log message.
For the complete error reference, see Paystack Errors and Troubleshooting: The Complete Reference. For signature verification details, see Paystack Signature Verification Failed: The Definitive Fix.
Preventing This in New Projects
When starting a new Laravel project with Paystack integration:
- Create a dedicated
webhooksroute group in your routes file from day one - Add the CSRF exclusion immediately, before you even build the handler
- Use a naming convention that makes webhook routes obvious:
/webhooks/paystack,/webhooks/stripe, etc. - Write a feature test that sends a POST to your webhook route without a CSRF token and asserts it does not return 419
// tests/Feature/PaystackWebhookTest.php
public function test_webhook_does_not_return_419(): void
{
$response = $this->postJson('/webhooks/paystack', [
'event' => 'charge.success',
'data' => ['reference' => 'test_001'],
]);
// Should be 401 (no valid signature) but NOT 419 (CSRF rejection)
$this->assertNotEquals(419, $response->status());
}
This test catches the CSRF issue immediately if someone accidentally removes the exclusion.
Key Takeaways
- ✓Laravel CSRF protection intercepts all POST requests and returns 419 if no valid CSRF token is present. External webhooks like Paystack cannot include a CSRF token, so you must exclude webhook routes.
- ✓In Laravel 11, CSRF exclusions are configured in bootstrap/app.php using the withMiddleware() method and validateCsrfTokens().
- ✓In Laravel 10 and earlier, add the webhook URI to the $except array in app/Http/Middleware/VerifyCsrfToken.php.
- ✓Excluding a route from CSRF does not mean it is unprotected. You protect the route by verifying the x-paystack-signature HMAC header instead.
- ✓Always verify the Paystack signature on excluded routes. Without CSRF or signature verification, anyone can send fake webhook payloads to your endpoint.
- ✓Use a wildcard pattern like webhooks/* if you have multiple webhook providers, or be specific with webhooks/paystack to limit the exclusion.
Frequently Asked Questions
- Is it safe to disable CSRF for the webhook route?
- Yes, as long as you verify the x-paystack-signature HMAC header. CSRF protection and webhook signature verification serve different purposes. CSRF prevents browser-based attacks from logged-in users. Webhook signatures prevent anyone from sending fake server-to-server requests. Your webhook route is protected by the signature, not by CSRF.
- Can I use the $except wildcard for all webhook routes?
- Yes. Using "webhooks/*" in the $except array excludes all routes under the /webhooks/ path from CSRF verification. This is convenient if you handle webhooks from multiple providers (Paystack, Stripe, Flutterwave). Just make sure all excluded routes have their own authentication mechanism.
- I added the exception but still get 419. What is wrong?
- Check that the URI in the exception exactly matches your route. It should be without a leading slash: "webhooks/paystack" not "/webhooks/paystack". Also check for trailing slashes: "webhooks/paystack" is different from "webhooks/paystack/". Clear your config cache with php artisan config:clear and route cache with php artisan route:clear, then try again.
- Does this apply to Laravel Sanctum or Passport APIs?
- If your webhook route is in routes/api.php and uses Sanctum or Passport middleware, you may need to exclude it from those authentication guards as well. Webhook routes should generally not use any session-based or token-based authentication. They should rely solely on the Paystack signature for verification.
- What changed in Laravel 11 regarding CSRF configuration?
- Laravel 11 simplified the middleware configuration. Instead of a dedicated VerifyCsrfToken middleware class in your app, you configure CSRF exceptions in bootstrap/app.php using the withMiddleware() callback and the validateCsrfTokens(except: [...]) method. The functionality is the same, only the configuration location changed.
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