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@csrfis placed at the bottom of a huge form containing hundreds of dynamic inputs, PHP may silently truncate the end of the$_POSTpayload if it exceedsmax_input_vars(default1000inphp.ini), stripping out the_tokenfield. Always place@csrfas 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
localhostand127.0.0.1as two completely different top-level domains for cookies. If your React app runs in the browser athttp://localhost:5173, your AxiosbaseURLmust point tohttp://localhost:8000—nothttp://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
- Open your browser’s Developer Tools (
F12) and navigate to Application $\rightarrow$ Cookies (Chrome/Edge/Brave) or Storage $\rightarrow$ Cookies (Firefox). - Delete any existing
laravel_sessionandXSRF-TOKENcookies forlocalhostor your domain (stale cookies from an oldSESSION_DOMAINwill conflict with new ones). - Reload the page and confirm two fresh cookies appear:
XSRF-TOKENandyour_app_session. - Submit your form or trigger your API call—the
POSTrequest will now pass CSRF verification cleanly.