Skip to content

Laravel 419 Page Expired: Causes and Fixes

Laravel 419 Page Expired means the CSRF token check failed. Learn the 8 real causes, how to confirm each one, and the fix, for Laravel 12 and 13

Dasun Kalupahana 10 min read 407 views 0 comments
On this page
  1. What a 419 actually is
  2. Cause 1: the form has no CSRF token
  3. Cause 2: the session expired while the page was open
  4. Cause 3: the token changed because the user logged in or out
  5. Cause 4: the session cookie is not coming back
  6. Cause 5: the session is not saved on the server
  7. Cause 6: a cached page contains someone else's token
  8. Cause 7: AJAX, fetch or SPA requests without a token
  9. Cause 8: an external service posts to your route
  10. Laravel 13: the 403 variant and the origin settings
  11. Debug a 419 in five minutes
  12. Things not to do
  13. Quick reference

A Laravel 419 "Page Expired" error means one thing: the CSRF check failed. The framework compared the token that came with your request against the token stored in the user's session, and they did not match, or one of them was missing. The error page says "expired", but a session timeout is only one of about eight ways to get there. This guide goes through each cause in the order you are most likely to hit it, how to confirm it, and the fix. Behavior was checked against the Laravel documentation and the framework source (12.x and tag v13.35.0).

What a 419 actually is

In the web middleware group, the CSRF middleware runs right after the session starts. If the request fails the check, it throws TokenMismatchException with the message "CSRF token mismatch.", and the exception handler turns that into an HTTP 419 response. Browsers see the "Page Expired" page. JSON requests get {"message": "CSRF token mismatch."} instead, because the message of an HTTP exception is returned as-is when debug mode is off.

Two details are worth knowing before you debug:

  • It is not written to your log. TokenMismatchException is in the handler's built-in list of exceptions that are not reported, so storage/logs/laravel.log stays empty and you have to debug from the browser side.
  • GET, HEAD and OPTIONS are never checked. Only POST, PUT, PATCH and DELETE can return 419.

Which middleware, in which version

The check lives in Illuminate\Foundation\Http\Middleware\ValidateCsrfToken in Laravel 12 (it was VerifyCsrfToken before that). In Laravel 13 it is PreventRequestForgery, and the old class names are kept as deprecated subclasses. Laravel 13 also adds a first layer: if the browser sends Sec-Fetch-Site: same-origin, the request is accepted without looking at a token at all. Otherwise it falls back to the token check. The Laravel documentation notes that browsers only send that header over HTTPS, so on plain HTTP you always go through the token check.

I ran the Laravel 13.35.0 middleware directly against a test session to confirm exactly what passes and what fails:

Request (POST unless noted)Result
GET, no tokenPass
No token, no Sec-Fetch-Site header (HTTP, curl, older browser)419
Correct _token fieldPass
Wrong _token419
Correct X-CSRF-TOKEN headerPass
Valid encrypted X-XSRF-TOKEN headerPass
Garbage X-XSRF-TOKEN header419
Old token, but the session is a new one419
Token from before session()->regenerate()419
Token from before session()->invalidate()419
Sec-Fetch-Site: same-origin, no tokenPass
Sec-Fetch-Site: same-site, no token419
Sec-Fetch-Site: cross-site, valid tokenPass
URI listed in the exclusion listPass

The rows about a new session, regenerate() and invalidate() explain a surprising number of real-world 419 errors, as you will see below.

Cause 1: the form has no CSRF token

The most common cause is also the simplest. Any form that sends POST, PUT, PATCH or DELETE needs the hidden token field:

<form method="POST" action="/profile">
    @csrf
    <!-- Equivalent to: -->
    <!-- <input type="hidden" name="_token" value="{{ csrf_token() }}"> -->
    ...
</form>

Check the rendered HTML in your browser's page source. If the _token input is missing, the form was written as plain HTML, copied from a static page, or built by a JavaScript snippet that never added it. For PUT, PATCH and DELETE forms you also need @method('PUT') and so on, since HTML forms only send GET and POST. In Laravel 13.35 a strict form request that rejects unknown fields ignores both _token and _method, so those two keys do not break it.

Cause 2: the session expired while the page was open

Someone opens a form, goes to lunch, comes back and submits. By then the session is gone. The default session lifetime in a fresh application is SESSION_LIFETIME=120, measured in minutes, so the form must be submitted within two hours of the last request to keep the same session. When the session has expired, the server starts a new one with a new token, and the old token on the page no longer matches.

Fixes, from least to most invasive:

  • Show a friendly message and send the user back to the form with their input kept (code below), instead of the default error page.
  • Raise SESSION_LIFETIME in .env if long forms are normal in your app. Remember a longer lifetime also keeps stale sessions around longer.
  • Keep long forms alive with a small request every few minutes while the page is open, since every request through the web group refreshes the session.
// bootstrap/app.php
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->render(function (HttpException $e, Request $request) {
        if ($e->getStatusCode() !== 419 || $request->expectsJson()) {
            return null; // let every other error keep its normal handling
        }

        return redirect()
            ->back()
            ->withInput($request->except(['password', 'password_confirmation']))
            ->with('status', 'Your session expired. Please submit the form again.');
    });
})

Important: type-hint HttpException here, not TokenMismatchException. I checked the handler source: it converts the token exception into an HttpException with status 419 before it runs your custom render callbacks, so a callback typed for TokenMismatchException never fires. Returning null from a render callback tells Laravel to carry on with its default handling, which is why the early return above is safe. Also keep passwords out of the flashed input, as shown.

Cause 3: the token changed because the user logged in or out

Laravel's session guard calls session()->regenerate(true) when a user logs in. That regenerates both the session ID and the CSRF token. Logout code that calls invalidate(), which the Laravel documentation shows for clearing a session, does the same (my test above confirms an old token fails after it). Any page that was already open before that moment holds a token that is now dead.

The classic reproduction: open the login page in two tabs, log in through the first, then submit the form in the second. The second tab gets a 419 even though nobody waited long. Another variant is a user who logs out in one tab and keeps working in another.

There is nothing to "fix" in the framework here, because regenerating on login is a deliberate security measure. The right response is the friendly redirect from Cause 2, so people land on a message instead of an error. Avoid calling session()->regenerate() yourself on every request, since that kills every token you have just issued.

If the browser does not send the session cookie, the server starts a fresh, empty session on every request, and no token can ever match. Open DevTools, go to the Network tab, click the failing POST and look at its request headers. If the cookie named by SESSION_COOKIE (by default the snake-cased APP_NAME followed by _session) is not in the request, you have found it. Common reasons, all controlled by config/session.php:

SettingDefaultHow it breaks sessions
SESSION_SECURE_COOKIEnot setIf set to true while the site is opened over plain HTTP, the browser will not store or send the cookie. This mostly bites staging servers and custom dev domains.
SESSION_DOMAINnot setIf it is set to a domain that does not match the host in the address bar (for example .example.com while you browse localhost or a different domain), the cookie is rejected.
SESSION_SAME_SITElaxFine for normal forms. If your app is embedded in an iframe on another site, or the form is posted from another site, the cookie is withheld.
SESSION_PATH/A non-root path means the cookie only goes to URLs under that path.

See the values the application is really using:

php artisan config:show session

One more local-development trap: localhost and 127.0.0.1 are different hosts to a browser, with separate cookies. Starting the page on one and posting to the other loses the session.

Cause 5: the session is not saved on the server

The cookie can be perfectly fine and the session data still missing on the next request. Check these:

  • Several servers with the file driver. File sessions live in storage/framework/sessions on one machine. A load balancer that sends the GET to server A and the POST to server B means server B has never heard of that session. Use a shared driver (redis, database, memcached) or sticky sessions. The Laravel documentation lists these drivers.
  • Storage not writable. If the web server user cannot write to storage/framework/sessions, sessions are never persisted. Fix the ownership of storage and bootstrap/cache.
  • Database driver without the table. The default driver in a new application is database, which needs the sessions table from the default migrations. A missing or failed migration breaks it.
  • APP_KEY changed. The session cookie is encrypted. When the key changes, the cookie can no longer be decrypted, the framework treats it as absent (I confirmed that in the cookie middleware source), and everyone gets a new session. After a deploy that rotates the key, expect 419s for anyone with a form already open.

Cause 6: a cached page contains someone else's token

The token is embedded in the HTML. If a CDN, reverse proxy or full-page cache stores a page that contains @csrf, every visitor receives the same HTML with the same token, which belongs to a session that is not theirs. The result is a 419 for nearly everyone, usually starting right after caching was switched on.

Do not cache pages that contain forms for logged-in or session-bound users, or load the token separately through an uncached request. A page rendered from an old browser tab or the back button can also carry a stale token for the same reason.

Cause 7: AJAX, fetch or SPA requests without a token

Laravel accepts the token from the _token input, the X-CSRF-TOKEN header, or the X-XSRF-TOKEN header. The second option pairs with a meta tag:

<meta name="csrf-token" content="{{ csrf_token() }}">
const token = document.querySelector('meta[name="csrf-token"]').content;

await fetch('/profile', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'X-CSRF-TOKEN': token,
    },
    body: JSON.stringify({ name: 'Amal' }),
});

Laravel also sends an encrypted XSRF-TOKEN cookie with each response, and libraries such as Axios copy it into the X-XSRF-TOKEN header automatically on same-origin requests. That is why Axios-based code often works with no extra setup while a hand-written fetch call does not.

If your frontend lives on a different origin, use Laravel Sanctum's SPA flow. Per its documentation: request /sanctum/csrf-cookie first, send the X-XSRF-TOKEN header with the URL-decoded cookie value (Axios does this for you), enable withCredentials and withXSRFToken on Axios, set supports_credentials to true in config/cors.php, and make the session cookie domain cover your subdomains, for example 'domain' => '.domain.com'.

Cause 8: an external service posts to your route

Stripe, PayPal, GitHub and similar services send webhooks that cannot know your CSRF token. Such routes should live outside the web group, such as in routes/api.php, with their own signature check. If a route must stay in web, exclude it explicitly. In Laravel 13:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
    $middleware->preventRequestForgery(except: [
        'stripe/*',
    ]);
})

On Laravel 12 the method is validateCsrfTokens(except: [...]) with the same argument. Laravel 13 keeps validateCsrfTokens() as a deprecated method that calls the new one. Excluding a route removes CSRF protection for it, so verify the sender's signature in your controller. Never exclude a broad pattern like * to make an error go away.

Laravel 13: the 403 variant and the origin settings

Laravel 13 can run in origin-only mode, which relies solely on the Sec-Fetch-Site header and skips tokens:

$middleware->preventRequestForgery(originOnly: true);

In that mode a failed check returns 403 instead of 419, and the framework stops sending the XSRF-TOKEN cookie. In my test, a cross-site POST with a perfectly valid token was rejected, and so was a POST with no Sec-Fetch-Site header at all, such as one made over plain HTTP. If you enable this and start seeing 403 where you expected 419, that is the reason. Requests from a subdomain have same-site, not same-origin, and are rejected unless you also pass allowSameSite: true.

Debug a 419 in five minutes

  1. Confirm the route is in the web group: php artisan route:list -v --path=profile lists the middleware, which should include the CSRF middleware.
  2. Look at the failing request in DevTools. Is there a _token in the form data or an X-CSRF-TOKEN / X-XSRF-TOKEN header? If not, it is Cause 1 or 7.
  3. Check that the session cookie is in the request headers. If not, it is Cause 4. Check the response of the earlier GET for a Set-Cookie header and any browser warning about a rejected cookie.
  4. Reload the page and submit again right away. If it works, it was an expired session or a token regenerated by a login (Causes 2 and 3). If it still fails, it is Cause 5, or Cause 6 if you use a cache.
  5. Print the effective config: php artisan config:show session.

You can also reproduce the failure and the fix with curl using a cookie jar, which separates browser problems from server problems:

# 1. Load the form, keep the cookies, extract the token
curl -s -c jar.txt https://app.test/profile \
  | sed -n 's/.*name="_token" value="\([^"]*\)".*/\1/p' | head -1 > token.txt

# 2. POST without the token: expect 419
curl -s -o /dev/null -w '%{http_code}\n' -b jar.txt -c jar.txt \
  -X POST https://app.test/profile -d 'name=Amal'

# 3. POST with the token and the same cookies: expect your normal response
curl -s -o /dev/null -w '%{http_code}\n' -b jar.txt -c jar.txt \
  -X POST https://app.test/profile -d "_token=$(cat token.txt)" -d 'name=Amal'

If step 3 works with curl but fails in the browser, the problem is on the browser side: cookies, caching or JavaScript. If step 3 also fails, look at the server side: sessions, storage, APP_KEY or multiple servers.

Things not to do

  • Do not disable CSRF protection globally to silence the error. It exists to stop other sites from making your logged-in users submit requests.
  • Do not clear cookies and call it fixed. That only gives you a new session, and the cause comes back.
  • Do not rely on tests catching it. The CSRF middleware is skipped when running tests, which the Laravel documentation states and the source confirms, so a test suite can be green while production returns 419. Test real browser flows or curl against a staging server.

Quick reference

SymptomLikely causeFix
Every submit failsNo @csrf or token headerAdd @csrf or X-CSRF-TOKEN
Fails after the page was left openSession expiredFriendly redirect, longer lifetime, keep-alive
Fails after login or logout in another tabToken regeneratedFriendly redirect
Fails every time, session cookie missingDomain, secure or same-site settingsFix config/session.php values
Fails randomly behind a load balancerFile sessions on several serversUse Redis or database sessions
Fails for everyone after a deployAPP_KEY changed, or page cacheKeep the key stable, do not cache form pages
Webhook returns 419Route is in web groupMove to API routes or exclude it
403 instead of 419 (Laravel 13)Origin-only modeRemove originOnly or allow same-site

 

References: Laravel: CSRF Protection, Laravel: HTTP Session, Laravel Sanctum: SPA Authentication and Laravel 12.x: CSRF Protection.

Free resources for this topic

Code snippets and fixes for this topic

Get one practical tip every week

New tutorials, fixes and free cheat sheets from DEV Talk, straight to your inbox. No spam, unsubscribe in one click.

Free download: Laravel interview questions (PDF)

Have a question about this post?

Ask the community: other developers and the author can answer.

Ask the community

Get notified about new articles

One click, no email needed. Turn it off anytime with the bell at the top.

Get new articles on your iPhone

Add DEV Talk to your Home Screen, open it, then tap the bell.

Rate this post
0.0
Share

Enjoying the content?

If this article helped you, you can support the author with a coffee. It keeps new posts coming.

Buy me a coffee (opens in a new tab)

Comments 0

Be kind. Comments with links are reviewed before they appear.

No comments yet

Be the first to share your thoughts.

Get new articles in your inbox

Practical tutorials and deep dives, about once a week. No spam, unsubscribe anytime.

Privacy policy

Add DEV Talk to your Home Screen

  1. 1 Tap the Share button in Safari's toolbar.
  2. 2 Scroll down and tap Add to Home Screen.
  3. 3 Tap Add. Open it from the Home Screen to read like an app and to turn on notifications with the bell.

We use cookies to improve your experience and analyze traffic. By continuing, you agree to our Privacy Policy.