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.
-
Application → Cookies → your domain. You should see
XSRF-TOKENand the session cookie. Its name comes fromAPP_NAME: recent Laravel 12 installs withAPP_NAME=Laraveluselaravel-session, apps created on Laravel 11 or older uselaravel_session, andSESSION_COOKIEoverrides it. No session cookie → see causes 4 and 5. -
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 anhttp://page. -
Network → reload → click the document request → Response Headers. On a normal session-backed web page, look for the expected
Set-Cookieheaders. If the session cookie is missing and you seecf-cache-status: HITor a LiteSpeed cache HIT header, inspect whether a cache served the page (cause 7). -
Submit the form → click the POST request. The
Cookierequest header must contain the session cookie, and Payload must contain_token(or the request must have anX-CSRF-TOKEN/X-XSRF-TOKENheader for AJAX). -
Compare the form
actionwith the address bar. A page onhttps://www.example.composting tohttps://example.com(or tohttp://) 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>
@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() }}">
With jQuery, attach it to every request once:
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
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 })
});
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 callsinvalidate()plusregenerateToken(), 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=trueends 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
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
sessionstable. Laravel 11 and 12 create it in the default0001_01_01_000000_create_users_table.phpmigration, 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/predispackage 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
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',
]);
})
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: HITon 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
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',
]);
})
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
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;
});
})
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');
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);
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
- Laravel 12.x: CSRF Protection: https://laravel.com/docs/12.x/csrf
- Laravel 12.x: HTTP Session: https://laravel.com/docs/12.x/session
- Laravel 12.x: HTTP Requests (Configuring Trusted Proxies): https://laravel.com/docs/12.x/requests#configuring-trusted-proxies
- Laravel 12.x: Error Handling (custom error pages, respond): https://laravel.com/docs/12.x/errors
- Laravel 12.x: Configuration (configuration caching): https://laravel.com/docs/12.x/configuration#configuration-caching
- Laravel 12.x: Authentication (logout, regenerateToken): https://laravel.com/docs/12.x/authentication
- Laravel framework 12.x source: Exceptions Handler (TokenMismatchException → 419): https://github.com/laravel/framework/blob/12.x/src/Illuminate/Foundation/Exceptions/Handler.php
- Laravel framework 12.x source: VerifyCsrfToken middleware: https://github.com/laravel/framework/blob/12.x/src/Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php
- Laravel 12.x skeleton: config/session.php: https://github.com/laravel/laravel/blob/12.x/config/session.php
- Cloudflare: Default cache behavior: https://developers.cloudflare.com/cache/concepts/default-cache-behavior/
- Cloudflare: Set-Cookie and cache behavior: https://developers.cloudflare.com/cache/concepts/cache-behavior/
Originally published at headsntail.com. Free tools for PHP, Laravel and cPanel work: headsntail.com/tools.
Top comments (0)