Short answer: a Laravel 419 Page Expired error means the CSRF token sent with your form or AJAX request doesn't match the token stored in the user's session. If everything works locally and you only get 419 on the server, the problem is almost always the session cookie, not your code: APP_URL and HTTPS disagree, Laravel doesn't know it's behind an HTTPS proxy, SESSION_DOMAIN or SESSION_SECURE_COOKIE is wrong, sessions aren't shared between servers, or a page cache is serving old HTML with an old token. This guide quickly rules out the simple local causes first, then goes through the production and Livewire branch.
Who this guide is for
You're in the right place if:
- you work with Laravel 10+ and know Blade and
@csrf, - your form or Livewire component works locally,
- but on the real server you see
419 Page Expiredor Livewire's "This page has expired" dialog.
If you get 419 locally too, start with the "Simple branch". If it only happens in production, jump to the "Production branch".
How is the CSRF token tied to the session?
According to the official Laravel CSRF documentation:
Laravel automatically generates a CSRF "token" for each active user session managed by the application. This token is used to verify that the authenticated user is the person actually making the requests to the application. Since this token is stored in the user's session and changes each time the session is regenerated, a malicious application is unable to access it.
So the token is bound to the session, and the session is bound to a cookie. A typical production request path:
Browser
│ Cookie: laravel_session=... + XSRF-TOKEN=...
↓
CDN / Cloudflare (optional)
↓
Nginx (TLS termination or reverse proxy)
↓
PHP-FPM → web middleware group
↓
StartSession → session loaded from the cookie
↓
ValidateCsrfToken (Laravel 11/12) / PreventRequestForgery (Laravel 13)
↓
Request token vs. session token → OK or 419
Anywhere along this path, if the session cookie doesn't reach the app, the session changes, or the page HTML carries another session's token, you get 419.
Simple branch: @csrf and AJAX (usually local)
Scenario A: @csrf is missing from the form
{{-- ❌ Wrong: 419 --}}
<form method="POST" action="/profile">
<input type="text" name="name">
<button type="submit">Submit</button>
</form>
{{-- ✅ Correct --}}
<form method="POST" action="/profile">
@csrf
<input type="text" name="name">
<button type="submit">Submit</button>
</form>
@csrf simply renders this hidden field:
<input type="hidden" name="_token" value="{{ csrf_token() }}">
Scenario B: a fetch request without a token
// ❌ Wrong: 419
fetch('/profile/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'ali' })
});
// ✅ Correct: token from the meta tag
fetch('/profile/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({ name: 'ali' })
});
To make the token available to JavaScript, add this meta tag to <head>. @csrf only works inside a form, so it isn't enough for AJAX:
<head>
<meta name="csrf-token" content="{{ csrf_token() }}">
</head>
Note: routes in routes/api.php don't use the web middleware group (and therefore CSRF) by default. 419 comes from routes in the web group.
Scenario C: Axios
Per the Laravel docs, Axios reads the XSRF-TOKEN cookie on same-origin requests and sends it as the X-XSRF-TOKEN header automatically, so usually there's nothing to do. For cross-origin requests (e.g. a frontend on another subdomain), you also need to send cookies:
// resources/js/bootstrap.js
axios.defaults.withCredentials = true;
axios.defaults.withXSRFToken = true; // Axios 1.6+, for cross-origin
Production branch: APP_URL, HTTPS and proxies
APP_URL must match the real site URL
# ❌ Wrong: the site runs on https but APP_URL says http
APP_URL=http://example.com
# ✅ Correct
APP_URL=https://example.com
APP_URL doesn't build the session cookie directly, but Laravel uses it to generate URLs (from the CLI, queued jobs and some packages), and a mismatch with the real URL is the first clue in many http/https problems. After changing .env, run php artisan config:cache again, or the old value keeps being read from the config cache. If your deploys are automated, make sure that step runs after .env is written.
Laravel must know the request was HTTPS (trustProxies)
HTTPS often terminates at a CDN or Nginx, and the request reaches the app over plain HTTP:
Browser (HTTPS) → CDN or Nginx (HTTPS) → Laravel app (HTTP)
If Laravel doesn't trust the proxy, it thinks the request was HTTP. As a result, url(), route() and asset() generate http:// URLs, so your form action or the Livewire update endpoint becomes http://. If SESSION_SECURE_COOKIE=true, the browser won't send the Secure cookie on that http:// request, a fresh session is created, and you get 419. And if the server redirects http to https, the POST body is lost in the redirect.
If Nginx sits in front of another server or container, forward the headers:
location / {
proxy_pass http://laravel_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme; # ← the key line
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
And in Laravel 11+ (bootstrap/app.php):
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(at: '*'); // or only your proxy IPs/ranges
})
⚠️ at: '*' is only safe if PHP isn't reachable directly from the internet; otherwise anyone can spoof X-Forwarded-Proto. List your proxy IPs or ranges when you can.
On Laravel 13 there's one more reason this matters: per the Laravel 13 docs, the Sec-Fetch-Site origin check only runs on secure connections. If Laravel sees the request as HTTP, that layer is skipped and everything falls back to the token check.
Cloudflare in Flexible SSL mode
In Flexible mode, Cloudflare talks HTTPS to the browser but plain HTTP to your server:
Browser (HTTPS) → Cloudflare (HTTPS) → Origin (HTTP)
That's the same scenario as above with one extra layer. The fix: switch SSL mode to Full (strict) with a certificate on the origin, and configure trustProxies correctly.
A common case: the site goes behind a CDN or proxy (Cloudflare, for example) and suddenly every Livewire form starts returning 419, even though no code changed. The cause is almost always that trustProxies isn't configured, so Laravel sees the request as HTTP, and combined with SESSION_SECURE_COOKIE=true the session cookie never arrives. That's why these two settings are the first things to check after any CDN or proxy change.
Production branch: the session cookie (Domain, Secure, SameSite)
SESSION_DOMAIN and subdomains
# Default: the cookie is host-only (just the host that served the page)
SESSION_DOMAIN=null
# If the session must be shared between app.example.com and admin.example.com
SESSION_DOMAIN=.example.com
A point that's often explained wrong: once you set a cookie Domain, whether example.com or .example.com, modern browsers send the cookie to all subdomains (the leading dot is ignored). Only an empty (null) SESSION_DOMAIN gives you a host-only cookie. So:
- If
SESSION_DOMAINdoesn't match the real site domain (e.g. a staging value left in production), the browser rejects the cookie and every request starts a new session → 419. - If an old host-only session cookie and a new domain cookie with the same name both exist, the browser may send both and Laravel may read the wrong one. After changing
SESSION_DOMAIN, also rename the cookie (SESSION_COOKIE) or clear cookies.
Per the Laravel session documentation, all session cookie settings live in config/session.php and are read from .env.
SESSION_SECURE_COOKIE and SESSION_SAME_SITE
The right combination for an HTTPS-only site:
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax
SESSION_HTTP_ONLY=true
Two cases that lead to 419:
SESSION_SECURE_COOKIE=trueon a site still served over HTTP (e.g. staging without SSL): the browser won't store a Secure cookie over HTTP, so every form returns 419.SESSION_SAME_SITE=nonewithoutSecure: modern browsers reject the cookie.
SESSION_SECURE_COOKIE=false on HTTPS usually doesn't cause 419 by itself, but it's a security weakness. As StackShield's guide to Laravel session security explains, the Secure flag makes the browser send the cookie only over HTTPS, so it can't be sniffed on shared networks.
Per the same source, SameSite has three values:
| Value | Behavior | When to use |
|---|---|---|
lax (Laravel default) |
Sent on top-level navigation, not on cross-site POSTs | Most apps |
strict |
Never sent on cross-site requests | Sensitive apps; users arriving from an email link appear logged out |
none |
Always sent, requires Secure |
iframes or genuinely cross-site setups |
SESSION_HTTP_ONLY defaults to true. Leave it, or JavaScript (and any XSS) can read the session cookie.
Production branch: session driver, multiple servers and page caching
Sessions across multiple servers or containers
In Livewire discussion #4799, one user reported 419 errors on Kubernetes with two pods and SESSION_DRIVER=file: the session was stored on one pod's disk, and the next request hit the other pod. The same applies to multiple PHP containers behind a load balancer. The fix: a shared driver such as redis or database.
On a single server with the file driver, make sure storage/framework/sessions is writable by the PHP-FPM user.
Full-page caching or a CDN
This one is rarely mentioned: if you added full-page caching for speed, or your CDN caches HTML pages, users receive HTML containing another session's csrf_token(). When they submit the form: 419. Exclude pages with forms or Livewire components from full-page caching, or refresh the token with a separate request after load.
Session lifetime
If a user leaves the page open and submits much later, the session may have expired. Laravel's default SESSION_LIFETIME is 120 minutes. For this case, create a friendly resources/views/errors/419.blade.php that tells the user to refresh.
Diagnosis table: 8 scenarios and how to check them
| # | Scenario | Symptom | How to check | Healthy result |
|---|---|---|---|---|
| 1 | @csrf missing |
Form always 419 | View Source of the form | <input name="_token"> present |
| 2 | csrf-token meta tag missing |
Only AJAX 419 | View Source → <head> |
<meta name="csrf-token"> present |
| 3 | Wrong APP_URL |
Links/redirects use http:// |
php artisan tinker --execute="echo config('app.url');" |
https://... |
| 4 | Laravel doesn't see HTTPS | Form action or Livewire URL is http:// |
View Source, or a temporary route returning request()->isSecure() (always false in tinker) |
true and https:// URLs |
| 5 | Wrong SESSION_DOMAIN |
Session cookie isn't stored | DevTools → Application → Cookies | Cookie present with the right Domain |
| 6 | Secure cookie over HTTP | Everything 419 on staging without SSL | DevTools → Cookies (Secure column) and the page URL | Secure only over HTTPS |
| 7 | Sessions not shared / permissions | New session_id on every request | SESSION_DRIVER and server count; permission errors on storage/framework/sessions |
Shared driver, no permission errors |
| 8 | Cached HTML / expired session | 419 after a while, or for all users | Response headers (Cache-Control, CDN cache headers), config('session.lifetime') |
Pages with forms aren't cached |
Livewire: why does it return 419?
Livewire sends the token itself: the script that @livewireScripts (or automatic injection in Livewire 3) adds to the page carries the current csrf_token(), and every wire:click and wire:submit request uses it. So you don't need @csrf for Livewire actions. If you get 419, it's almost always one of the session problems above. When a 419 comes back, Livewire shows its "This page has expired" dialog and offers a refresh.
Other fixes that worked in discussion #4799:
- After updating Livewire, if you published its assets, publish them again (
php artisan livewire:publish --assets); old JS doesn't match the new backend. - Check
SESSION_DOMAINto confirm the session cookie actually gets stored. - If you define Livewire routes in a package, make sure they have the
webmiddleware group.
Some people in that discussion suggest excluding
livewire/*from CSRF. Don't. It hides the real problem and leaves every Livewire action unprotected.
Laravel 13: origin-aware CSRF protection
Per the Laravel 13 docs, the middleware is now PreventRequestForgery with two layers: it first checks the Sec-Fetch-Site header and allows same-origin requests without a token check; if that check isn't possible (older browser, or an insecure connection), it falls back to normal token validation.
If you're on Laravel 13 and getting 419:
- Check the request's
Sec-Fetch-Sitevalue in the Network tab. If it'scross-siteorsame-site, the request comes from another origin (subdomain, iframe) and needs a token. - For subdomains, consider
preventRequestForgery(allowSameSite: true). - If Laravel sees the request as HTTP (see trustProxies), the origin layer never runs.
Note: in originOnly: true mode, rejected requests get 403 instead of 419.
Why you should never disable CSRF
Never do this:
// ❌ Never
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: ['*']);
})
It lets another site submit forms on behalf of your logged-in users (changing their email, deleting their account, and so on).
The right exception: webhooks
Only for third-party webhooks that can't send a token (Laravel 11 and 12; on Laravel 13 the method is preventRequestForgery):
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: [
'stripe/*',
'webhooks/*',
]);
})
Per the Laravel documentation:
Sometimes you may wish to exclude a set of URIs from CSRF protection. For example, if you are using Stripe to process payments and are utilizing their webhook system, you will need to exclude your Stripe webhook handler route from CSRF protection since Stripe will not know what CSRF token to send to your routes.
⚠️ Even after excluding a webhook, always verify its signature (e.g. the Stripe-Signature header).
FAQ
1. What's the difference between ValidateCsrfToken and PreventRequestForgery?
ValidateCsrfToken is the middleware name in Laravel 11 and 12, and it only checks the token. In Laravel 13 it's renamed PreventRequestForgery and checks the Sec-Fetch-Site header before the token.
2. Why does it work locally but not in production?
Locally you usually have one domain, plain HTTP and one server. Production adds HTTPS, a proxy or CDN, subdomains, multiple servers and caching. The most common causes: trustProxies not configured, wrong SESSION_DOMAIN or SESSION_SECURE_COOKIE, a non-shared session driver, and cached HTML.
3. Can I disable CSRF for just one route?
Yes, with validateCsrfTokens(except: [...]) (or preventRequestForgery(except: [...]) on Laravel 13). But only for webhooks and third-party endpoints, never for user-facing routes.
4. Where do I see cookies in DevTools?
Chrome: F12 → Application → Storage → Cookies. Firefox: F12 → Storage → Cookies. Safari: Develop → Show Web Inspector → Storage → Cookies.
5. Livewire 3 returns 419 in production. Where do I start?
- View Source and check that the Livewire endpoint URL is
https://(trustProxies). 2) In DevTools, check that the session cookie gets stored (SESSION_DOMAIN,SESSION_SECURE_COOKIE). 3) With multiple servers, use a shared session driver. 4) Make sure the page isn't served from a full-page cache or CDN.
6. Why does Livewire sometimes return 419 even with a valid token?
Because the token is valid, but for a different session: the session expired, the request reached a server that doesn't have the session, or the HTML came from a cache. Check those three first.
If you go through the two branches separately and work down the diagnosis table, it usually becomes clear quickly where the session gets lost. And remember: the fix for 419 is never disabling CSRF. It's one of your application's core defenses.
Still stuck on 419, or don't have time to debug it? Give the error, your .env (without keys and passwords) and your Nginx config to Nyx, Captain Code's AI coding assistant, and debug it step by step. Or if you'd rather hand the whole thing off, from custom Laravel web development to security and maintenance, you can bring your project to Captain Code.
New to backend work? See the best programming languages to learn in 2026.
Good luck. If you have a question or get stuck, ask in the comments.