419 Page Expired and CSRF token mismatch in Laravel

Laravel returns 419 Page Expired, or "CSRF token mismatch." as JSON, when a POST has no valid CSRF token: no @csrf, an expired session or a cookie issue.

Updated View as Markdown

On this page

419 Page Expired is the page Laravel shows when a POST, PUT, PATCH or DELETE request fails its CSRF check. An AJAX or API call that expects JSON gets the same failure as {"message": "CSRF token mismatch."} with status 419. The token in the request is missing, or it doesn't match the token in the user's session.

The CSRF middleware throws Illuminate\Session\TokenMismatchException, and Laravel's exception handler turns it into an HttpException with status 419. 419 is not a standard HTTP status; Laravel uses it for this one case. Everything below was run on Laravel 13.40 and applies to Laravel 12 unless the text says otherwise.

Laravel 12 and 13 check CSRF differently

On Laravel 12, every non-read request through the web middleware group needs a token: the _token form field, an X-CSRF-TOKEN header or an X-XSRF-TOKEN header.

Laravel 13 renamed the middleware to PreventRequestForgery and added a first check: when the browser sends Sec-Fetch-Site: same-origin, the request passes without a token (Laravel 13 CSRF docs). Browsers send that header only over HTTPS (and to localhost). We confirmed it with curl against a route without a token:

Terminal
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8000/submit
# 419
curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Sec-Fetch-Site: same-origin" http://127.0.0.1:8000/submit
# 200
curl -s -o /dev/null -w "%{http_code}\n" -X POST -H "Sec-Fetch-Site: cross-site" http://127.0.0.1:8000/submit
# 419

So on Laravel 13 over HTTPS, most 419s from your own forms disappear. The ones that remain come from plain-HTTP sites, older browsers, other origins (a subdomain, a payment provider, an app on another port) and clients that aren't browsers, such as Postman, curl or a mobile app. On Laravel 12, every cause below applies to every request.

The form has no @csrf

The most common cause on a new form. Every HTML form that posts to a web route needs the hidden token field:

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

Confirm it: view the page source and look for <input type="hidden" name="_token". If it's missing, so is @csrf.

For JavaScript requests from your own pages, put the token in the layout and send it as a header:

Blade
<meta name="csrf-token" content="{{ csrf_token() }}">
JavaScript
await fetch('/cart/items', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
  },
  body: JSON.stringify({ product_id: 42 }),
});

Axios needs nothing: it reads the encrypted XSRF-TOKEN cookie that Laravel sets and sends it as X-XSRF-TOKEN on same-origin requests. Livewire and Inertia also send the token for you; a 419 there means one of the session causes below.

The session expired

The CSRF token lives in the session. When the session is gone, so is the token, and the form that was rendered with it fails. This is the classic 419 after a user leaves a tab open over lunch: SESSION_LIFETIME is 120 minutes by default.

Confirm it: load the form, delete the session cookie (named after your app, such as laravel_session) in the browser's dev tools, then submit. A 419 means this is your cause.

Raise the lifetime if your users need it, and send them back to the form with their input instead of showing the bare 419 page. In bootstrap/app.php, catch the HttpException that the 419 becomes, because the handler converts the TokenMismatchException before your render callback sees it:

PHP
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        //
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        $exceptions->render(function (HttpException $e, Request $request) {
            if ($e->getStatusCode() === 419 && ! $request->expectsJson()) {
                return back()
                    ->withInput($request->except('_token', 'password', 'password_confirmation'))
                    ->with('status', 'Your session expired. Please submit the form again.');
            }
        });
    })->create();

The form now redirects back (302) with the old input and a message; JSON requests still get the 419.

If 419 happens on every submit, often right after login, the browser isn't sending the session cookie, so each request starts a new, empty session. Open dev tools → Application (or Storage) → Cookies and check whether the session cookie exists for the domain you're on. Then check .env:

SettingWhat goes wrong
SESSION_DOMAINSet to a domain that isn't the one in the address bar (.example.com vs app.example.org), so the browser drops the cookie. Leave it empty unless you share sessions across subdomains.
SESSION_SECURE_COOKIE=trueThe cookie is only sent over HTTPS. On a plain-HTTP staging site or http://localhost, it never comes back.
SESSION_SAME_SITE=strict or nonestrict drops the cookie on posts that arrive from another site (an OAuth or payment redirect). none requires SESSION_SECURE_COOKIE=true, or browsers reject it. The default lax is right for most apps.
APP_URL and the proxyBehind a load balancer that terminates TLS, Laravel thinks the request is HTTP unless it trusts the proxy. Add $middleware->trustProxies(at: '*'); in bootstrap/app.php (or the proxy's IP ranges).

Each server has its own sessions

With two or more web servers behind a load balancer, the file session driver stores the session on whichever server rendered the form. The submit lands on the other server, which has no such session: 419, at random, about half the time.

Use a shared store on every server:

.env
SESSION_DRIVER=database

Or redis. New Laravel apps default to database and ship the sessions table migration; older apps upgraded from Laravel 10 often still have file. All servers also need the same APP_KEY: session cookies are encrypted with it, and a server with another key can't read them.

A deploy changed the key or the config

A spike of 419s right after a deploy almost always means something in the deploy invalidated every open session:

  • APP_KEY changed. Every cookie encrypted with the old key is unreadable, so every user gets a new session and every open form fails. To rotate the key without this, put the old one in APP_PREVIOUS_KEYS; see No application encryption key has been specified.
  • The session driver or the Redis database changed, so existing sessions are gone.
  • php artisan config:cache ran with the wrong .env (a build container without your production values), so SESSION_DOMAIN, the driver or the key in the cached config differ from what you expect. Check with php artisan config:show session.

A cache served someone else's token

When a CDN or a full-page cache (Cloudflare "Cache Everything", Varnish, a response-cache package) caches a page with a form, every visitor gets the token of the user whose request filled the cache. None of them can submit it.

Confirm it: compare the _token value in the page source in two private windows. If it's identical, the page is cached. Exclude pages with forms from the cache, or load the token with JavaScript after the page loads.

The request isn't from a browser on your site

Webhooks (Stripe, Mollie, GitHub), callbacks from a payment page and requests from Postman or a mobile app can't have a token from your session. Webhooks belong outside the web group, for example in routes/api.php. If a route must stay in web, exclude it in bootstrap/app.php:

PHP
->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: [
        'webhooks/stripe',
        'webhooks/*',
    ]);
})

validateCsrfTokens works on Laravel 12 and 13. Laravel 13 also has preventRequestForgery(except: [...]), which does the same. Excluding a route removes its CSRF protection entirely, so only exclude routes that verify the caller another way, such as a webhook signature.

For Postman, either call the API routes with a token (Sanctum personal access tokens), or first GET a page, copy the XSRF-TOKEN cookie and send it back URL-decoded as the X-XSRF-TOKEN header.

SPA with Sanctum: "CSRF token mismatch." on login

A separate front end (Vue, React, Nuxt on another port or subdomain) that uses Sanctum's cookie authentication must ask for the CSRF cookie before it logs in, and send cookies with every request:

JavaScript
import axios from 'axios';

axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true;

await axios.get('https://api.example.com/sanctum/csrf-cookie');
await axios.post('https://api.example.com/login', { email, password });

On the Laravel side, check three things:

  • $middleware->statefulApi(); is in bootstrap/app.php, so API routes get sessions.
  • SANCTUM_STATEFUL_DOMAINS lists the front end's host with its port, such as app.example.com or localhost:5173.
  • SESSION_DOMAIN=.example.com when the front end and API are on different subdomains, and config/cors.php has 'supports_credentials' => true.

Since axios 1.6, withXSRFToken must be true for the header to be sent to another origin; without it, every request fails with "CSRF token mismatch." even though the cookie is there.

Catch 419s in production

Laravel doesn't report a 419 as an exception: TokenMismatchException is on the handler's internal don't-report list, so it never reaches your log or an error tracker. You see 419s only in request data.

In Lampwick, Requests lists each route with its 2xx/3xx, 4xx and 5xx counts and a chart over time; open a route such as POST /login to see each request with its status and user. A jump in 4xx on your form routes right after a time on Deploys points at the deploy causes above. The Laravel package records every request without code changes. For other errors that do surface as exceptions, see the other error pages, starting with SQLSTATE errors.