[Solved] Laravel 419 Page Expired Error: 7 Real Fixes

Laravel 419 Page Expired means the CSRF check failed. Learn every real cause, from missing @csrf to broken session cookies, with tested Laravel 11 fixes.

The Laravel 419 Page Expired error means one thing: the CSRF check failed because the request did not carry a valid token for the current session. The message is confusing because nothing visibly "expired" for most people who hit it, and the cause is rarely the same twice.

I have debugged this error on login forms, Vue and React front-ends, multi-subdomain CRMs and freshly deployed servers. In this guide I go through every cause I have seen in production, in the order I check them, with the exact code or config that fixes each one in Laravel 11. By the end you will know how to find your specific cause in a couple of minutes instead of guessing.

Prerequisites

  • A Laravel 10 or 11 application (examples use Laravel 11 and PHP 8.3)
  • Access to the .env file and the server where the app runs
  • Browser DevTools, specifically the Network and Application tabs

Why Laravel returns 419 Page Expired

Every web route in Laravel runs through the CSRF middleware (ValidateCsrfToken in Laravel 11, VerifyCsrfToken in older versions). On POST, PUT, PATCH and DELETE requests it compares a token sent with the request against the token stored in the user's session. If either one is missing or they do not match, Laravel throws a TokenMismatchException and renders the 419 page.

That gives us two halves to inspect. Either the request did not send the token, or the server could not find the session the token belongs to. Every fix below falls into one of those buckets, and keeping that in mind is what makes debugging fast.

Fix 1: Add @csrf to every form

The most common cause is still a plain HTML form without the token. The @csrf Blade directive renders a hidden _token input. Method spoofing for PUT or DELETE also needs it.

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

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

Fix 2: Send the CSRF token with AJAX, fetch and Axios

JavaScript requests do not include the hidden input, so you need to send the token as a header. Put it in a meta tag in your main layout and read it from JavaScript.

If you use Axios with Laravel's default bootstrap, Axios reads the XSRF-TOKEN cookie automatically and sends it as X-XSRF-TOKEN, so it usually works without extra code. With plain fetch you must add the header yourself.

<!-- resources/views/layouts/app.blade.php -->
<meta name="csrf-token" content="{{ csrf_token() }}">

// resources/js/api.js
const token = document.querySelector('meta[name="csrf-token"]').content;

export async function saveProfile(data) {
  const res = await fetch('/profile', {
    method: 'POST',
    credentials: 'same-origin',
    headers: {
      'X-CSRF-TOKEN': token,
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    },
    body: JSON.stringify(data),
  });

  if (res.status === 419) {
    window.location.reload(); // session expired, get a fresh token
    return;
  }
  return res.json();
}

Fix 3: Handle an expired session gracefully

If a page sits open longer than SESSION_LIFETIME (120 minutes by default), the session is garbage collected and the token in the page becomes stale. This is the classic "419 page expired on login" case: someone leaves the login tab open overnight and submits in the morning.

You can raise the lifetime, but a better user experience is to catch the exception and send people back with a friendly message. In Laravel 11 this goes in bootstrap/app.php.

// bootstrap/app.php
use Illuminate\Session\TokenMismatchException;
use Illuminate\Http\Request;

->withExceptions(function (Exceptions $exceptions) {
    $exceptions->render(function (TokenMismatchException $e, Request $request) {
        if ($request->expectsJson()) {
            return response()->json(['message' => 'Session expired. Please refresh.'], 419);
        }

        return redirect()->back()
            ->withInput($request->except('password', '_token'))
            ->with('error', 'Your session expired. Please try again.');
    });
})

Fix 4: Session cookie not being saved

This is the cause behind most "it works locally but gives 419 in production" reports. If the browser never stores the session cookie, every request starts a brand-new session with a brand-new token, so nothing ever matches.

Open DevTools, go to Application, then Cookies, and check whether laravel_session (or your app's cookie name) exists after loading the page. If it is missing, or a new value appears on every request, look at these settings.

  • SESSION_SECURE_COOKIE=true on a site served over plain http: the browser drops the cookie
  • SESSION_DOMAIN set to a domain that does not match the site, for example .example.com while you browse on an IP address
  • SESSION_SAME_SITE=none without a secure cookie: modern browsers reject it
  • APP_URL using http while the site runs on https behind a proxy, so Laravel builds the wrong URLs
  • A load balancer or Cloudflare stripping or caching Set-Cookie headers
# .env for production on https://app.example.com
APP_URL=https://app.example.com
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_DOMAIN=.example.com
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax

Fix 5: Trust your proxy behind a load balancer

When the app sits behind Nginx, a load balancer or Cloudflare, Laravel may think the request is plain http. Combined with a secure cookie, that breaks sessions. Tell Laravel to trust the forwarded headers.

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*');
})

Fix 6: Make session storage writable and shared

With the file session driver, storage/framework/sessions must be writable by the web server user. If it is not, the session is never persisted. After a deploy that changes ownership, this is the first thing I check.

If you run more than one server, the file driver cannot work at all, because the session written on server A does not exist on server B. Switch to the database or Redis driver.

# fix permissions (Ubuntu + Nginx/PHP-FPM)
sudo chown -R www-data:www-data storage bootstrap/cache
sudo chmod -R 775 storage bootstrap/cache

# or move sessions to the database
php artisan make:session-table   # Laravel 11 creates this by default
php artisan migrate
php artisan config:clear

Fix 7: Exclude webhooks from CSRF in Laravel 11

Third-party callbacks such as Stripe, PayPal or a payment gateway cannot send your CSRF token. Those routes should either live in routes/api.php (which has no CSRF middleware) or be excluded explicitly. In Laravel 11 the VerifyCsrfToken class is gone from the app folder, so the exclusion lives in bootstrap/app.php.

Only exclude routes that verify the request another way, such as a webhook signature. Never exclude normal user forms to make the error disappear.

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

Common errors and quick diagnosis

  • 419 only on the first login after a deploy: config cache holds old session settings, run php artisan config:clear then config:cache
  • 419 on every request in one browser only: stale cookies, clear site data and retry
  • 419 in an iframe or embedded widget: third-party cookie blocking, the session cookie needs SameSite=None and Secure
  • 419 on file uploads: the upload exceeded post_max_size, PHP discards the body including _token, raise post_max_size and upload_max_filesize
  • 419 from a SPA on another domain: use Sanctum and call /sanctum/csrf-cookie first

Best practices to prevent 419 errors

  • Keep the default CSRF protection on, and fix the cause instead of excluding routes
  • Use the database or Redis session driver in production, never file on multiple servers
  • Set APP_URL, SESSION_DOMAIN and SESSION_SECURE_COOKIE per environment, not shared
  • Run php artisan config:cache as part of every deploy so settings are consistent
  • Handle TokenMismatchException with a friendly redirect rather than the bare 419 page

Conclusion

The Laravel 419 Page Expired error is always a token that did not arrive or a session that could not be found. Start by checking that the form or request sends the token, then confirm in DevTools that the session cookie survives between requests, and finally check proxy, storage and multi-server settings. In my experience that order finds the cause within a few minutes on almost every project.

Frequently asked questions

What does 419 Page Expired mean in Laravel?

It means the CSRF token sent with the request was missing or did not match the token stored in the session. Laravel rejects the request to protect against cross-site request forgery.

Why do I get 419 Page Expired on the login page?

Usually the login page was open longer than the session lifetime, or the session cookie is not being saved because of SESSION_DOMAIN or SESSION_SECURE_COOKIE settings. Reload the page and check that the session cookie persists in DevTools.

How do I disable CSRF for a route in Laravel 11?

Call $middleware->validateCsrfTokens(except: [...]) inside withMiddleware in bootstrap/app.php. Only do this for routes like webhooks that verify requests with a signature.

Why does Laravel 419 only happen in production?

Production usually runs on https behind a proxy with different session settings. A secure cookie on http, a wrong SESSION_DOMAIN, an untrusted proxy or a cached config are the typical causes.

Does increasing SESSION_LIFETIME fix the 419 error?

Only when the cause is an idle page. If the cookie is not saved or the token is not sent, a longer lifetime changes nothing.