# 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.

`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](https://laravel.com/docs/13.x/csrf#origin-verification)). Browsers send that header only over HTTPS (and to `localhost`). We confirmed it with `curl` against a route without a token:

```bash
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')

</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() }}">
```

```js
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.

## The session cookie never comes back

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`:

| Setting | What goes wrong |
|---|---|
| `SESSION_DOMAIN` | Set 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=true` | The cookie is only sent over HTTPS. On a plain-HTTP staging site or `http://localhost`, it never comes back. |
| `SESSION_SAME_SITE=strict` or `none` | `strict` 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 proxy | Behind 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:

```dotenv
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](/errors/no-application-encryption-key/#rotate-app-key).
- 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:

```js
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](/docs/laravel/) records every request without code changes. For other errors that do surface as exceptions, see the other [error pages](/errors/), starting with [SQLSTATE errors](/errors/sqlstate/).
