Fixing Laravel '419 Page Expired' (CSRF Token Mismatch) in Blade, Sanctum & Production


You submit a login form, trigger an Axios POST request from your frontend, or test a webhook endpoint, and Laravel rejects the request before it ever reaches your controller.

In a standard browser view, you see a minimalist gray error screen:

419 | PAGE EXPIRED

If your frontend sends an Accept: application/json header, the network response tab returns an HTTP 419 status code with this JSON payload (thrown internally by Illuminate\Session\TokenMismatchException):

{
    "message": "CSRF token mismatch."
}

HTTP status code 419 is not an official RFC standard code—it is a custom status code created specifically by Laravel. It means the ValidateCsrfToken middleware intercepted a state-changing request (POST, PUT, PATCH, or DELETE), compared the incoming _token or X-XSRF-TOKEN header against the user’s active server session, and found that they either did not match or the session itself did not exist.


Quick Diagnostic Reference

Match your application setup to the exact failure point below:

Symptom / Environment Root Cause Jump to Fix
Standard Blade <form> fails immediately on submit Missing @csrf directive or max_input_vars truncation Fix 1
Stripe / PayPal webhook or external POST fails in Laravel 11/12 Route is subject to CSRF protection in bootstrap/app.php Fix 2
React / Vue / Next.js SPA using Sanctum returns 419 SESSION_DOMAIN port mismatch or missing withXSRFToken in Axios Fix 3
Works on localhost, fails with 419 on HTTPS production / Cloudflare SESSION_SECURE_COOKIE mismatch or untrusted reverse proxy Fix 4
@csrf is present, but every form submission still throws 419 Server cannot persist sessions (storage permissions or DB driver) Fix 5

1. Blade Forms and Vanilla JavaScript AJAX Requests

Every HTML form submitting a POST, PUT, PATCH, or DELETE request to routes inside routes/web.php must include a hidden _token input generated by Laravel’s session engine.

Standard Blade Forms

Place the @csrf Blade directive immediately inside your opening <form> tag:

<form method="POST" action="{{ route('profile.update') }}">
    @csrf
    @method('PUT')

    <input type="text" name="name" value="{{ old('name') }}" />
    <button type="submit">Save Changes</button>
</form>

The Large Form Edge Case (max_input_vars): If @csrf is placed at the bottom of a huge form containing hundreds of dynamic inputs, PHP may silently truncate the end of the $_POST payload if it exceeds max_input_vars (default 1000 in php.ini), stripping out the _token field. Always place @csrf as the very first line inside <form>.

Vanilla Fetch or jQuery AJAX Calls

If you are firing JavaScript requests inside a Blade template without Sanctum, expose the token in your <head> layout and attach it as an X-CSRF-TOKEN header:

<!-- Inside resources/views/layouts/app.blade.php -->
<meta name="csrf-token" content="{{ csrf_token() }}">
// Attach the header in your fetch request
const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

fetch('/appointments/book', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'X-CSRF-TOKEN': token
    },
    body: JSON.stringify({ slot_id: 42 })
});

2. Excluding Webhooks & API Routes in Laravel 11 and 12

If an external third-party service (like Stripe, LemonSqueezy, Telegram, or a mobile app) sends a POST request to your application, it cannot possess a browser session cookie and will fail with 419 Page Expired.

In older versions of Laravel, you excluded routes by editing app/Http/Middleware/VerifyCsrfToken.php. In Laravel 11 and 12, VerifyCsrfToken.php was removed from the application skeleton.

How to exclude CSRF routes in Laravel 11 & 12:

Open bootstrap/app.php and chain validateCsrfTokens(except: [...]) inside the withMiddleware closure:

<?php

use Illuminate\Foundation\Application;
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: [
            'stripe/*',
            'webhooks/*',
            'api/v1/callbacks/*',
        ]);
    })
    ->withExceptions(function ($exceptions) {
        //
    })->create();

(Note: If you are building stateless token-based endpoints for mobile apps, place those routes inside routes/api.php rather than routes/web.php. If routes/api.php is missing in your fresh Laravel 11/12 project, generate it cleanly by running php artisan install:api).


3. Fixing 419 in Laravel Sanctum SPAs (React, Vue & Axios 1.6+)

When authenticating a decoupled frontend SPA (e.g., http://localhost:5173) against a Laravel backend (http://localhost:8000), Sanctum uses cookie-based CSRF protection. First, your frontend calls /sanctum/csrf-cookie to receive an XSRF-TOKEN cookie, and then sends that value back in the X-XSRF-TOKEN header on subsequent POST requests.

If /sanctum/csrf-cookie returns 204 No Content but your very next POST /login request fails with 419, check these three configuration traps:

A. The Axios withXSRFToken Breaking Change

Starting in Axios v1.6.2+, setting withCredentials: true is no longer enough to make Axios automatically attach the X-XSRF-TOKEN header on cross-port or cross-subdomain requests. You must explicitly enable withXSRFToken:

// src/lib/axios.js
import axios from 'axios';

const api = axios.create({
    baseURL: 'http://localhost:8000',
    withCredentials: true,
    withXSRFToken: true, // <-- Required in modern Axios versions!
    headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
    },
});

export default api;

(Note: When withCredentials: true is active, your backend config/cors.php must also have 'supports_credentials' => true and an explicit origin URL. If the browser console blocks the request before receiving the 419 response, see our guide on Fixing Laravel CORS Preflight Errors).

B. Never Put Port Numbers in SESSION_DOMAIN

A very common mistake in .env is adding a port number or http:// protocol to SESSION_DOMAIN, or mismatching localhost and 127.0.0.1:

# ❌ BROKEN: Browser rejects cookies when SESSION_DOMAIN includes a port or protocol
SESSION_DOMAIN=http://localhost:8000

# ✅ WORKING (Local Development):
APP_URL=http://localhost:8000
FRONTEND_URL=http://localhost:5173
SESSION_DOMAIN=localhost
SANCTUM_STATEFUL_DOMAINS=localhost:5173,127.0.0.1:5173

# ✅ WORKING (Production Subdomains: api.example.com & app.example.com):
SESSION_DOMAIN=.example.com
SANCTUM_STATEFUL_DOMAINS=app.example.com

Crucial Localhost Rule: Browsers treat localhost and 127.0.0.1 as two completely different top-level domains for cookies. If your React app runs in the browser at http://localhost:5173, your Axios baseURL must point to http://localhost:8000—not http://127.0.0.1:8000.


4. Production HTTPS, Reverse Proxies (Cloudflare / Nginx) & Secure Cookies

Your forms work locally, but the moment you deploy to production behind Nginx, Cloudflare, AWS ALB, or Traefik, every form submission throws 419 Page Expired.

Cause 1: Untrusted Reverse Proxy

When your server sits behind Cloudflare or a load balancer, SSL terminates at the proxy, and traffic forwards to your Nginx/PHP container over internal HTTP (port 80 or 8000). Because Laravel sees an incoming HTTP request instead of HTTPS, cookie security attributes mismatch.

In Laravel 11 and 12, trust proxies inside bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*');
})

Cause 2: SESSION_SECURE_COOKIE=true on Local HTTP

If you copied your production .env variables locally and left SESSION_SECURE_COOKIE=true while accessing your site over http://localhost, the browser silently refuses to store the laravel_session cookie because the connection is not encrypted over HTTPS.

Ensure your .env matches your protocol:

# Local (HTTP)
SESSION_SECURE_COOKIE=false
SESSION_SAME_SITE=lax

# Production (HTTPS)
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax

5. Silent Session Persistence Failures (Files & Database Drivers)

Even if your @csrf token is present and your cookies are configured properly, Laravel will throw 419 Page Expired if the server itself fails to save the session record between page loads. Every refresh generates a brand-new session ID, invalidating the token sitting in your browser form.

Scenario A: Using SESSION_DRIVER=database (Default in Laravel 11 & 12)

Laravel 11 changed the default SESSION_DRIVER from file to database. If your database connection drops or your sessions table hasn’t been migrated cleanly, session persistence fails.

Verify your sessions table exists by running:

php artisan migrate:status

(If your migration crashed while creating indexes on the sessions or users table, see our guide on Fixing SQLSTATE[42000]: 1071 Specified Key Was Too Long. If Artisan cannot connect to MySQL at all, check Fixing SQLSTATE[HY000] [2002] Connection Refused).

Scenario B: Using SESSION_DRIVER=file on Linux or Docker

If your .env uses SESSION_DRIVER=file and the www-data web server user lacks write permissions on storage/framework/sessions/, Laravel cannot write session files to disk.

Ensure storage/framework/sessions is writable by your web server group:

sudo chown -R $USER:www-data storage/framework/sessions
sudo chmod -R 775 storage/framework/sessions

(For a full breakdown on configuring production-safe group permissions and SGID bits without using chmod 777, read our guide on Fixing storage/logs/laravel.log Permission Denied in Linux & Docker).


Verification: How to Confirm Cookies Are Persisting

After applying your fix, always clear Laravel’s cached config and remove stale cookies from your browser before testing:

php artisan optimize:clear
  1. Open your browser’s Developer Tools (F12) and navigate to Application $\rightarrow$ Cookies (Chrome/Edge/Brave) or Storage $\rightarrow$ Cookies (Firefox).
  2. Delete any existing laravel_session and XSRF-TOKEN cookies for localhost or your domain (stale cookies from an old SESSION_DOMAIN will conflict with new ones).
  3. Reload the page and confirm two fresh cookies appear: XSRF-TOKEN and your_app_session.
  4. Submit your form or trigger your API call—the POST request will now pass CSRF verification cleanly.