All articles
A Laravel webhook POST bypassing CSRF via VerifyCsrfToken except, with raw getContent bytes verified by hash_equals on a local php artisan serve instance.
LaravelPHPwebhook debugginglocal testing

Laravel Webhooks: CSRF Exemption, Raw Body, Signature Checks

Laravel ships with CSRF protection on every web route. Webhook providers send POST requests without session cookies or CSRF tokens, so your handler never runs — you get a 419 before the controller executes. The fix is a targeted CSRF exemption plus reading the raw body with $request->getContent() before JSON parsing.

Exempt webhook routes from CSRF

Add your webhook URI to the $except array in App\Http\Middleware\VerifyCsrfToken (or the middleware class registered in bootstrap/app.php on Laravel 11+):

protected $except = [
    'webhooks/stripe',
    'webhooks/*',
];

Scope exemptions narrowly. Only webhook paths that receive server-to-server POSTs belong here — never blanket-disable CSRF for your whole app.

Read the raw body for signature verification

After CSRF is out of the way, read untouched bytes:

public function handle(Request $request)
{
    $payload = $request->getContent(); // raw string
    $signature = $request->header('Stripe-Signature', '');

    if (!$this->verifySignature($payload, $signature)) {
        return response('', 401);
    }

    $event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
    $this->processEvent($event);
    return response('', 200);
}

Use getContent(), not $request->all() or $request->input(). Laravel's input helpers assume parsed form/JSON data and will not give you the exact bytes the provider signed.

Verify signatures before business logic

Extract provider-specific headers, compute the expected digest from the raw payload, and compare with a timing-safe function (hash_equals in PHP). Return 401 on mismatch. Only after verification succeeds should you decode JSON and dispatch jobs. See the signature verification guide for provider-specific formats.

Set up the tunnel

  1. Start Laravel: php artisan serve --port=8000.
  2. In a second terminal: npx portpreview 8000.
  3. Paste the tunnel URL plus your webhook path into the provider dashboard.
  4. If you use TrustProxies, ensure forwarded headers from the tunnel are trusted so $request->ip() and URL generation stay correct.
  5. Trigger a test event and confirm it reaches your controller.

Common pitfalls

Reading the body twice

Calling getContent() after $request->json() or file_get_contents('php://input') elsewhere returns empty. Read once, store in a variable, verify, then parse.

Route middleware order

Global middleware that parses JSON before your controller runs can alter bytes. Keep webhook routes on the api stack or a dedicated route group without parsing side effects.

Retries and idempotency

Providers retry on slow or non-2xx responses. Persist event IDs and short-circuit duplicates — see retry and idempotency patterns.

Where to go deeper

For fundamentals, read what localhost tunneling is and how to debug webhooks locally. For signature mechanics, see the signature verification guide. For duplicate-safe handlers, read retry and idempotency patterns. start PortPreview free.

Frequently asked questions

Why does Laravel return 419 on webhook POSTs?
VerifyCsrfToken rejects POSTs without a valid CSRF token. Webhook providers do not send one. Add the webhook path to the $except array in VerifyCsrfToken, then verify signatures instead.
Should I use getContent() or $request->all() for webhooks?
Use getContent() for signature verification. all() and input() return parsed data that may not match the exact bytes the provider signed.
How do I test Laravel webhooks locally?
Run php artisan serve, expose the port with npx portpreview, paste the tunnel URL into the provider dashboard, and trigger test events.