DEV Community

Cover image for Laravel 419 Page Expired: Causes and Fixes
Faizan
Faizan

Posted on Originally published at headsntail.com

Laravel 419 Page Expired: Causes and Fixes

The Laravel 419 Page Expired screen shows up when you submit a form or send an AJAX request and Laravel cannot match the CSRF token in the request with the one in the user's session. The message says "expired", but often nothing expired: the token was missing, the session cookie never reached the server, or the session was never saved. On cPanel or behind Cloudflare, cookie and caching settings can make the problem harder to spot. Start with the failed request before changing server settings.

Quick answer: 419 means a CSRF token mismatch. Check three things first: the form has @csrf (or the AJAX request sends X-CSRF-TOKEN / X-XSRF-TOKEN), the browser actually stores and sends the session cookie (DevTools → Application → Cookies), and the session can be written (storage/framework/sessions writable for the file driver, a sessions table for the database driver). On live servers also check SESSION_DOMAIN, SESSION_SECURE_COOKIE, www vs non-www and page caches, and run php artisan config:clear after editing .env.

Why Laravel 419 Page Expired happens

Routes in the web middleware group pass through Illuminate\Foundation\Http\Middleware\ValidateCsrfToken. For any request except GET, HEAD and OPTIONS, it reads a token (the _token field, then the X-CSRF-TOKEN header, then the encrypted X-XSRF-TOKEN header) and compares it with the session's token.

When the token does not match the current session, the middleware throws Illuminate\Session\TokenMismatchException. Laravel's exception handler converts it into an HttpException with status 419, and the built-in error view shows "Page Expired". Laravel does not log 419s by default, so storage/logs/laravel.log is usually empty when users complain.

Most CSRF-related 419 errors fall into one of two situations:

Situation What it means Typical causes
No token in the request The form or JS never sent one Missing @csrf, fetch/axios without a header, wrong axios base URL
Token sent, but no matching session The server cannot find the session that issued the token Session expired, cookie not stored or not sent, session not saved, cached page with an old token

Debug it step by step in the browser

Check which part of the request failed before changing settings. Open the form page in Chrome or Edge and press F12.

  1. Application → Cookies → your domain. You should see XSRF-TOKEN and the session cookie. Its name comes from APP_NAME: recent Laravel 12 installs with APP_NAME=Laravel use laravel-session, apps created on Laravel 11 or older use laravel_session, and SESSION_COOKIE overrides it. No session cookie → see causes 4 and 5.
  2. Check the cookie's Domain and Secure columns. The domain must match the address bar (or be a parent like .example.com). A Secure cookie is not sent back to an http:// page.
  3. Network → reload → click the document request → Response Headers. On a normal session-backed web page, look for the expected Set-Cookie headers. If the session cookie is missing and you see cf-cache-status: HIT or a LiteSpeed cache HIT header, inspect whether a cache served the page (cause 7).
  4. Submit the form → click the POST request. The Cookie request header must contain the session cookie, and Payload must contain _token (or the request must have an X-CSRF-TOKEN / X-XSRF-TOKEN header for AJAX).
  5. Compare the form action with the address bar. A page on https://www.example.com posting to https://example.com (or to http://) is a different cookie scope.

Then open storage/logs/laravel.log. A 419 is not logged, but a broken session store shows up as "Permission denied", "failed to open stream" or "Table 'sessions' doesn't exist".

Causes 1-3: missing token, AJAX headers, expired session

Cause 1: the form has no @csrf

Every POST, PUT, PATCH or DELETE form in Blade needs the directive:

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

@csrf prints a hidden _token input. @method('PUT') only spoofs the method and does not replace it. Forms pasted from plain HTML or old projects are where it usually goes missing.

Cause 2: AJAX, fetch or axios without the token header

Add a CSRF meta tag inside the layout's <head>:

<meta name="csrf-token" content="{{ csrf_token() }}">
Enter fullscreen mode Exit fullscreen mode

With jQuery, attach it to every request once:

$.ajaxSetup({
    headers: {
        'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
    }
});
Enter fullscreen mode Exit fullscreen mode

fetch() does not automatically add a CSRF token header, so include one:

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

fetch('/attendance/check-in', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'X-CSRF-TOKEN': token
    },
    credentials: 'same-origin',
    body: JSON.stringify({ employee_id: 15 })
});
Enter fullscreen mode Exit fullscreen mode

credentials: 'same-origin' sends the session cookie. Accept: application/json makes Laravel return a JSON error instead of the HTML page.

Axios works differently. Laravel provides an encrypted XSRF-TOKEN cookie on applicable web responses. With the standard same-origin setup, Axios can use the cookie value in the X-XSRF-TOKEN request header. The default Laravel JavaScript setup relies on this convention rather than reading the meta tag. Two things break it: a baseURL on another host (such as http://127.0.0.1:8000 left in production), and putting the meta tag value into X-XSRF-TOKEN. That header must hold the encrypted cookie value; a plain token fails decryption. Send the meta value as X-CSRF-TOKEN.

Vue components calling Laravel routes follow the same rules. For a separate-domain SPA, follow Laravel Sanctum's documented cookie and CORS setup.

Cause 3: the session really expired

The token lives in the session, which ends after SESSION_LIFETIME minutes of inactivity. Laravel 12's config/session.php reads (int) env('SESSION_LIFETIME', 120), so the default is 120 minutes idle. This often shows up when someone submits a form that has been left open for several hours. The token also goes stale when:

  • Someone logs in or out in another tab. The docs' login code calls $request->session()->regenerate() and logout calls invalidate() plus regenerateToken(), so older forms carry a dead token.
  • The back button shows a cached copy of a form after logout and login.
  • SESSION_EXPIRE_ON_CLOSE=true ends the session when the browser closes, and a restored tab submits a dead token.

A longer lifetime may be appropriate for some internal tools, but a sensitive app still needs an inactivity policy. For long forms, consider saving drafts. A controlled keep-alive is covered later, but avoid extending unattended sessions.

Causes 4-5: the session cookie or the session store is broken

If every POST fails, even right after loading the page, the session does not survive between requests.

Cause 4: the browser does not store or send the session cookie

Safe .env values for a single-domain site on HTTPS:

APP_URL=https://example.com
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=true
Enter fullscreen mode Exit fullscreen mode

What goes wrong with each:

Setting Mistake Result
SESSION_DOMAIN Set to the old domain, to localhost, or to a domain the site is not on Browser rejects the cookie, every POST is 419
SESSION_SECURE_COOKIE=true Site opened over http:// (LAN IP, staging subdomain without SSL) Cookie is Secure-only and never sent back over HTTP
www vs non-www Page on www.example.com, form posts to example.com (or the reverse) With SESSION_DOMAIN=null the cookie belongs to one host only
APP_URL Wrong host or http:// while code builds URLs from config('app.url') Forms and JS post to a different origin than the page
SESSION_DRIVER=cookie Large amounts of data put in the session Browsers cap a single cookie at roughly 4 KB and drop larger ones

SESSION_DOMAIN=null means "this exact host only", which most sites want. Use .example.com only to share the session across subdomains. For www vs non-www, pick one and redirect the other at server level.

Cause 5: the session driver cannot save sessions

Laravel 12 defaults to the database driver. Common failures on shared hosting:

  • file: sessions go to storage/framework/sessions. If the folder is missing, not writable, or the account is over its disk quota, nothing is saved.
  • database: needs a sessions table. Laravel 11 and 12 create it in the default 0001_01_01_000000_create_users_table.php migration, but upgraded apps or partial database imports may lack it.
  • redis: many shared plans have no Redis, and Laravel needs the PhpRedis extension or the predis/predis package for it.

Depending on where it fails you get a 500 or a 419; laravel.log has the real message. Fixes:

# file driver: check the folder exists and is owned by your cPanel user
ls -la storage/framework/sessions

# database driver: check existing migrations first
php artisan migrate:status
# Only if no sessions-table migration exists:
php artisan make:session-table
# Inspect and back up before applying pending migrations:
php artisan migrate
Enter fullscreen mode Exit fullscreen mode

php artisan session:table is the Laravel 10 name; Laravel 11 and 12 use make:session-table. Check phpMyAdmin and php artisan migrate:status first: do not generate a duplicate table migration, and inspect all pending migrations before running them on production. Without Redis, use file or database. Permissions and Terminal steps are in deploying Laravel on cPanel.

Causes 6-8: Cloudflare, proxies, page cache and cached config

Cause 6: proxy or load balancer causes an http/https mismatch

When a proxy ends TLS and talks to your server over plain HTTP, Laravel thinks the request is http:// and builds http:// form URLs. The https:// page then posts to http://, and you get a 419 (Secure cookie not sent), a redirect that turns the POST into a GET, or an "insecure form" warning.

For Cloudflare, prefer SSL mode Full (strict) with a valid origin certificate. That secures Cloudflare-to-origin traffic, but Laravel may still need trusted-proxy configuration behind a separate load balancer or TLS-terminating proxy. Avoid Flexible mode for this setup.

Behind a load balancer or reverse proxy that ends TLS, trust it in bootstrap/app.php (Laravel 11/12 syntax):

->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(at: [
        '10.0.0.0/8',
    ]);
})
Enter fullscreen mode Exit fullscreen mode

Use the proxy's IPs or ranges. Laravel also allows trustProxies(at: '*'). Only consider it when untrusted clients cannot reach the application directly; otherwise, forged X-Forwarded-* headers may be accepted. Verify with a route returning request()->secure(): it should be true live. If Cloudflare shows a challenge page that blocks AJAX calls, that is a separate problem: see Cloudflare JS challenge breaking a Laravel SPA.

Cause 7: a page cache served an old token

A wrongly cached login page may deliver a token from another session or fail to deliver the session cookie. Cloudflare does not cache HTML by default. Custom cache eligibility rules can change this; cookie-bearing responses require particular care. But if a Cache Rule makes HTML eligible and overrides the Edge TTL, Cloudflare can strip Set-Cookie and cache the page, causing 419 errors.

  • Exclude /login, /register, /password/* and other form pages from cache rules, or do not override the Edge TTL for HTML at all.
  • On LiteSpeed hosting with LSCache rules in .htaccess, make sure dynamic Laravel pages are not cached. When unsure, remove the rules and retest.
  • cf-cache-status: HIT on a session-specific form page is strong evidence of misconfigured caching. Confirm that the cached HTML contains session-specific form content.

Cause 8: config is cached with old values

After php artisan config:cache (or optimize), Laravel loads bootstrap/cache/config.php and ignores .env, so .env changes do nothing.

php artisan config:clear
# or clear config, routes, views and cache in one go
php artisan optimize:clear
Enter fullscreen mode Exit fullscreen mode

Run config:cache again if your deployment uses cached configuration. Without terminal access, a competent administrator may remove the generated configuration cache file through File Manager, but should preserve the deployment backup and check permissions first.

Excluding routes from CSRF for webhooks

Payment and SMS webhooks post without a token because they cannot know it. Exclude only those routes in bootstrap/app.php:

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

Laravel also allows webhook routes to be handled outside the web middleware group when appropriate. A browser returning from a payment service by a cross-site POST may not send a cookie with SESSION_SAME_SITE=lax. Keep browser return pages separate from authenticated server-to-server webhooks; verify payment using the provider's API or a signed callback rather than trusting browser parameters. Changing SameSite globally is not a safe first fix.

When not to do this: never put '*' in the list, never remove ValidateCsrfToken, and never exclude login or admin forms "to stop the 419". That removes cross-site request forgery protection. Fix the cause instead.

Show a friendly 419 page and keep sessions alive

Create resources/views/errors/419.blade.php and Laravel uses it for every 419 (swap in your own layout):

@extends('layouts.app')

@section('content')
    <h1>Your session expired</h1>
    <p>The form was not submitted. Go back, refresh the page and try again.</p>
    <a href="{{ url()->previous() }}">Go back</a>
@endsection
Enter fullscreen mode Exit fullscreen mode

Or redirect back with a message, using the docs' respond hook in bootstrap/app.php:

use Symfony\Component\HttpFoundation\Response;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->respond(function (Response $response) {
        if ($response->getStatusCode() === 419) {
            return back()->with([
                'message' => 'The page expired, please try again.',
            ]);
        }

        return $response;
    });
})
Enter fullscreen mode Exit fullscreen mode

back() returns the user to the form without what they typed, so this suits short forms. For long forms (leave requests, payroll entry), add a keep-alive route in routes/web.php:

Route::get('/keep-alive', fn () => response()->json(['token' => csrf_token()]))
    ->middleware('auth');
Enter fullscreen mode Exit fullscreen mode

Call it every 15 minutes on those pages:

setInterval(async () => {
    const res = await fetch('/keep-alive', { credentials: 'same-origin', headers: { 'Accept': 'application/json' } });
    if (!res.ok) return;
    const { token } = await res.json();
    document.querySelector('meta[name="csrf-token"]').content = token;
    document.querySelectorAll('input[name="_token"]').forEach(i => i.value = token);
}, 15 * 60 * 1000);
Enter fullscreen mode Exit fullscreen mode

Each successful call counts as activity, so an unattended tab may remain logged in indefinitely. Only enable this timer if the app's security policy permits activity-based session extension; otherwise save drafts and require reauthentication. A draft-saving approach is often safer. For shared-hosting setup steps, see the Laravel on cPanel deployment checklist.

FAQ

Why does Laravel show 419 Page Expired on login only on the live server?

Check in DevTools that the session cookie appears after loading the login page. If not, the usual causes are a wrong SESSION_DOMAIN, SESSION_SECURE_COOKIE=true on an http:// address, a cached login page, an unwritable storage/framework/sessions folder or a missing sessions table. Run php artisan config:clear after any .env change.

How do I fix CSRF token mismatch in Laravel AJAX requests?

Add the csrf-token meta tag to the layout and send its value as X-CSRF-TOKEN on every POST, PUT, PATCH or DELETE. Axios does this itself via the XSRF-TOKEN cookie on same-origin requests. With fetch, add the header and credentials: 'same-origin'.

What is the default session lifetime in Laravel 12?

120 minutes of inactivity, from SESSION_LIFETIME in config/session.php. After that the session and its token are gone, and the next submit returns 419.

Is it safe to disable CSRF protection to stop the 419 error?

No. Exclude only routes called by outside services, such as payment or SMS webhooks, with validateCsrfTokens(except: [...]), and verify them with the provider's signature. Disabling CSRF for normal forms lets other sites submit them on behalf of logged-in users.

Sources and documentation


Originally published at headsntail.com. Free tools for PHP, Laravel and cPanel work: headsntail.com/tools.

Top comments (0)