skip to content

A payment provider's webhook POST to your Laravel 13 app gets 419 responses; why, and how do you exempt that route correctly?

level: middleimportance: must knowfreq 55%

answer

  1. server-to-server call, no browser headers
  2. routes/web.php means the web group
  3. preventRequestForgery(except: [...])
  4. or a route outside the web group
  5. then verify the provider's signature

basics

~20 s

Routes in routes/web.php run PreventRequestForgery, and a server-to-server webhook sends neither Sec-Fetch-Site nor a CSRF token, so it gets 419. Exempt the URI with preventRequestForgery(except:) or move the route outside the web group, then verify the provider's signature.

solid answer

~40 s

Every route in `routes/web.php` gets the `web` middleware group, which includes `PreventRequestForgery`. A payment provider's server sends no `Sec-Fetch-Site` header and has no session token, so the middleware throws `TokenMismatchException` and the handler answers 419. There are three fixes: exclude the URI in `bootstrap/app.php` with `$middleware->preventRequestForgery(except: ['webhooks/payments/*'])`, register the route outside the `web` group (for example in the opt-in `routes/api.php`, created by `php artisan install:api`), or call `->withoutMiddleware([PreventRequestForgery::class])` on that route. The docs prefer keeping such routes out of the `web` group. Whichever you choose, the exemption removes the forgery check, so the handler must verify the provider's signature itself. In Laravel 13 refer to `PreventRequestForgery`, not the deprecated `VerifyCsrfToken`.

code

php · 21 lines
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',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        // one call: each call resets originOnly and allowSameSite
        $middleware->preventRequestForgery(except: [
            'webhooks/payments/*',
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        // the skeleton's exception settings, unchanged
    })->create();

go deeper

for a junior

Know that routes in web.php are CSRF-checked and that a webhook from another server has no token, so it needs an exclusion.

for a middle

Compare the except list, a route outside the web group and withoutMiddleware(), and write the exclusion in bootstrap/app.php.

for a senior

Keep the exemption narrow, pair it with signature and replay checks, catch the repeated-call reset, and verify outside the test suite.

for a principal

Decide how machine-facing endpoints are separated from browser routes across the app so exemptions stay rare and auditable.

## Why the webhook gets 419 A **webhook** is an HTTP request that another service sends to your application when something happens, such as a payment succeeding. It comes from the provider's servers, not from a user's browser. In a Laravel 13 app, a route defined in `routes/web.php` runs the `web` middleware group, which includes `PreventRequestForgery`. For a `POST` from a server: - it is not a read (`GET`, `HEAD`, `OPTIONS`); - it carries no `Sec-Fetch-Site` header, because only browsers send it; - it carries no `_token`, `X-CSRF-TOKEN` or `X-XSRF-TOKEN`, because the provider has no session with you. So the middleware throws `TokenMismatchException`, the exception handler turns it into **HTTP 419**, and the provider sees the delivery fail and usually retries it later. ## Three ways to exempt the route | Approach | Where | Notes | |---|---|---| | Exclude the URI | `bootstrap/app.php`: `$middleware->preventRequestForgery(except: ['webhooks/payments/*'])` | Keeps the route in `web.php`; patterns match the path or full URL, with `*` wildcards | | Route outside the `web` group | the opt-in `routes/api.php` (`php artisan install:api`) or a dedicated route file | The docs' preferred option: no session, cookies or CSRF for a machine client | | Per-route removal | `Route::post(...)->withoutMiddleware([PreventRequestForgery::class])` | Visible next to the route | Before Laravel 13 the same exclusion was written `validateCsrfTokens(except: [...])` or, in older apps, as the `$except` property of an `app/Http/Middleware/VerifyCsrfToken.php` class. In Laravel 13, `validateCsrfTokens()` is deprecated in favour of `preventRequestForgery()`, and `VerifyCsrfToken` is a deprecated alias of `PreventRequestForgery`. ## A trap in bootstrap/app.php `preventRequestForgery()` takes three named arguments: `except`, `originOnly` and `allowSameSite`. Each call **resets** the two boolean settings to the values passed, defaulting to `false`. If one part of the file enables `originOnly: true` and a later line adds a webhook with `preventRequestForgery(except: [...])`, the second call silently turns origin-only mode off again. Put all the options in a single call. ## Exempting is not securing Removing the CSRF check says "this endpoint is not called by a browser holding a session". It does not say who is calling. The webhook handler must still: 1. **verify the provider's signature** over the raw request body with the shared secret the provider issued, and reject anything that fails; 2. **check the timestamp** if the provider signs one, to limit replays; 3. **be idempotent**, because providers retry and may deliver the same event more than once; 4. **avoid relying on the session or `auth()`**, since no user is logged in on this request. ## Choosing between the options - For one or two webhook endpoints in a mostly browser-facing app, the `except` list keeps everything in one place and is easy to audit. - For an app with many machine-facing endpoints, routing them outside the `web` group avoids starting sessions and encrypting cookies for requests that never use them. - Keep the exclusion narrow: `webhooks/payments/*`, never a broad pattern such as `api/*` on routes that still use cookie sessions. ## Keeping the exemption honest over time Exclusions tend to grow. A few habits keep the list meaningful: - keep every exempt URI under one recognisable prefix such as `webhooks/`, so a reviewer can see at a glance which endpoints skip the check; - name each provider in its own path segment, so removing an integration removes exactly one pattern; - review the `except` list whenever a route is renamed, because a pattern that no longer matches fails closed (419 again), while an overly broad replacement fails open. ## Verifying the fix A feature test will not show the 419: the middleware skips its checks while unit tests run. Confirm the exclusion by sending a real request to a running app, for example with the provider's own CLI or dashboard "send test event" feature, or with `curl` against a staging URL.

  • Why does the webhook's 419 not show up in a feature test that posts the same payload?
    `PreventRequestForgery` returns early when the application is running unit tests, so `$this->postJson('/webhooks/payments/events', ...)` passes whether or not the route is excluded. Verify the exclusion against a running environment, or assert on the middleware configuration, rather than trusting the feature test.
  • What goes wrong if bootstrap/app.php calls preventRequestForgery(originOnly: true) and later preventRequestForgery(except: ['webhooks/*'])?
    Each call sets `originOnly` and `allowSameSite` to the values passed, defaulting to `false`. The second call keeps the exclusion but turns origin-only mode back off, so the app silently falls back to token checks. Pass `except`, `originOnly` and `allowSameSite` together in one call.

saying these in an interview costs you the question

  • Send the webhook a CSRF token by adding @csrf to the provider's form.
  • Excluding a route from CSRF makes the webhook authenticated.
  • Disable PreventRequestForgery globally to stop webhook 419s.
  • In Laravel 13, edit app/Http/Middleware/VerifyCsrfToken.php to add exclusions.
  • Repeated preventRequestForgery() calls merge all their options.