Skip to content
CWE-352A01:2021 – Broken Access Control

Laravel

The web middleware group, the except wildcard trap, the XSRF-TOKEN mirror cookie, and Sanctum's stateful-domain configuration.

The rule

Keep the web middleware group on every browser-facing route, keep the except list empty, and get SANCTUM_STATEFUL_DOMAINS right.

Laravel applies VerifyCsrfToken as part of the web group, so anything in routes/web.php is protected automatically. Routes in routes/api.php are not — the api group has no CSRF middleware, on the assumption that an API is authenticated by a token rather than a cookie.

That assumption is where the trouble starts. Laravel Sanctum's SPA mode deliberately authenticates api routes with the session cookie, which reintroduces the ambient credential without reintroducing the middleware. Sanctum handles this correctly when configured properly, and does not when it is not.

The other classic is the except array: a wildcard added to make a webhook work, which quietly exempts far more than intended.

The except trap

PHPbootstrap/app.phpVulnerable
<?php
// Laravel 11/12 style. In Laravel 10 and earlier this lives in the $except
// property of App\Http\Middleware\VerifyCsrfToken.

return Application::configure(basePath: dirname(__DIR__))
    ->withMiddleware(function (Middleware $middleware) {
        $middleware->validateCsrfTokens(except: [
            // Added for one Stripe webhook. The wildcard exempts every route
            // under /webhooks -- including ones added later by someone who
            // has no idea this list exists.
            'webhooks/*',

            // Added during an integration and never removed. This is the
            // entire API surface.
            'api/*',

            // The worst case, seen more often than it should be: this
            // disables CSRF protection for the whole application.
            '*',
        ]);
    })->create();

// A genuine third-party webhook DOES need an exemption -- the caller has no
// token. But it must then authenticate by SIGNATURE, not by session cookie.
// If a route in this list is reachable with a session cookie, it is a
// finding.

Session and cookie configuration

PHPconfig/session.phpVulnerable
<?php
return [
    // Scoped to the parent domain, so every subdomain receives the session
    // cookie AND can overwrite it. Usually set so that api.example.com can
    // see the session -- a real requirement with a real cost.
    'domain' => '.example.com',

    // Not enforced over HTTPS. A network position on plaintext for any
    // subdomain can read or shadow the cookie.
    'secure' => false,

    // null means the browser decides: Lax in Chromium, nothing in Firefox
    // and Safari. Behaviour differs by engine and the team cannot reason
    // about it.
    'same_site' => null,

    // 'none' is worse -- it explicitly opts out of SameSite entirely and
    // restores the full pre-2020 attack surface.
    // 'same_site' => 'none',
];

Sanctum SPA mode

PHPthe Sanctum configuration that mattersVulnerable
<?php
// Sanctum has two modes and they have completely different CSRF properties.
//
// API TOKEN MODE: the client sends Authorization: Bearer <token>. Not
// ambient, so CSRF does not apply. Nothing to configure.
//
// SPA MODE: the client authenticates with the SESSION COOKIE. Ambient, so
// CSRF absolutely applies -- and this is the mode most SPA tutorials use.

// config/sanctum.php
return [
    // Requests from these domains are treated as "stateful": Sanctum applies
    // the session cookie AND the CSRF middleware to them.
    //
    // Get this list wrong and one of two things happens:
    //
    //   TOO NARROW -- your own frontend is not listed, so it is treated as
    //   token-mode, the session cookie is not applied, and login appears
    //   broken. The usual "fix" found online is to add '*' or to disable
    //   CSRF, both of which are the vulnerability.
    //
    //   TOO WIDE -- a wildcard or a domain you do not control is listed, so
    //   requests from it are treated as first-party.
    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,127.0.0.1')),

    'guard' => ['web'],

    'middleware' => [
        // Both must stay. Removing the CSRF line is the documented-in-blog-
        // posts way to make a misconfigured SPA work, and it disables the
        // protection for every stateful request.
        'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
        'validate_csrf_token'  => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
        'encrypt_cookies'      => Illuminate\Cookie\Middleware\EncryptCookies::class,
    ],
];

// The SPA must call GET /sanctum/csrf-cookie first, which sets the
// XSRF-TOKEN cookie. Axios reads it and sets X-XSRF-TOKEN automatically;
// plain fetch does not, so read the cookie and set the header by hand.

Blade and JavaScript

HTMLresources/views/account.blade.phpSecure
{{-- @csrf renders the hidden _token input. A hand-written <form> without
     it returns 419 Page Expired, which is Laravel's CSRF failure status. --}}
<form method="POST" action="{{ route('account.email') }}">
  @csrf
  <input type="email" name="email" required>
  <button type="submit">Update</button>
</form>

{{-- HTML forms cannot emit PUT, PATCH, or DELETE, so Laravel provides
     @method, which renders a hidden _method field. Note this is exactly the
     override mechanism an attacker uses -- it is fine here because the CSRF
     token is still required, but it means a route reachable by POST is also
     reachable as a PUT. --}}
<form method="POST" action="{{ route('account.destroy') }}">
  @csrf
  @method('DELETE')
  <button type="submit">Delete account</button>
</form>

{{-- For fetch/XHR: --}}
<meta name="csrf-token" content="{{ csrf_token() }}">

Checklist

  • The web middleware group is applied to every browser-facing route.
  • The except list contains exact paths only, no wildcards, and every entry authenticates by signature rather than session.
  • config/session.php: domain => null, secure => true, http_only => true, same_site => 'lax', cookie name __Host- prefixed.
  • Sanctum in SPA mode: SANCTUM_STATEFUL_DOMAINS lists your exact frontend domains, no wildcards, and the validate_csrf_token middleware is still in config/sanctum.php.
  • Routes in routes/api.php that authenticate by session cookie have CSRF middleware applied explicitly.
  • Route::match and Route::any are not used for state-changing routes.
  • @csrf is present in every Blade form; a 419 in testing usually means it is missing rather than that protection is broken.
  • Session is regenerated on login — $request->session()->regenerate(). Laravel's scaffolding does this; a hand-rolled login may not.