You hit submit, and instead of your data being saved you get a blank page that just says 419 | Page Expired. No stack trace, no hint, nothing useful. The Laravel 419 Page Expired error is one of those problems that looks scary but almost always comes down to a single thing: a CSRF token that didn't match. Once you know what Laravel is checking, the fix takes a few minutes.
I've debugged this on production forms, on AJAX-heavy dashboards, and on a webhook endpoint that had no business enforcing CSRF at all. The cause was different each time, but the mechanism was identical. Let me walk you through what's actually happening and how to fix it for good.
What the 419 error actually means
HTTP 419 isn't a standard status code. Laravel made it up. When you see "Page Expired", the framework threw a TokenMismatchException from its CSRF protection layer.
Here's the short version of CSRF (Cross-Site Request Forgery): a malicious site could trick your logged-in browser into submitting a form to your app without your knowledge. To block that, Laravel generates a unique token per session and requires every state-changing request (POST, PUT, PATCH, DELETE) to include it. The VerifyCsrfToken middleware compares the token in the request against the one stored in the session.
If they don't match, or the token is missing entirely, you get a 419.
So the error isn't really about a page being expired. It's about a token being wrong. Read it that way and every fix below stops feeling like guesswork.
Why the CSRF token mismatch happens
The token comparison can fail for a handful of reasons, and they're worth separating because the fix differs for each:
-
The form never sent a token. Most common. A
@csrfdirective is missing from a Blade form. - The session genuinely expired. You left a tab open for two hours, the session lifetime passed, and the stored token is gone. Reloading fixes it.
-
The session cookie isn't being read. Wrong
SESSION_DOMAINorSESSION_SECURE_COOKIEmeans the browser never sends the cookie back, so there's no session token to compare against. -
An AJAX request has no token header. JavaScript POSTs need to attach the token manually via
X-CSRF-TOKENorX-XSRF-TOKEN. - Stale cache or cookies. An old cached page holds a token that no longer belongs to the current session.
Let's fix each one.
The fixes
1. Add @csrf to every Blade form
This is the first thing to check, every single time. Any HTML form that isn't a plain GET needs the CSRF field:
<form method="POST" action="/profile">
@csrf
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ old('name') }}">
<button type="submit">Save</button>
</form>
The @csrf directive expands to a hidden input like <input type="hidden" name="_token" value="...">. Without it, the middleware finds no token in the request and rejects it immediately.
If you're using a method other than POST, you still need @csrf alongside @method:
<form method="POST" action="/profile/5">
@csrf
@method('PUT')
<!-- fields -->
</form>
Forgetting @csrf is easily the number one cause I run into. If a specific form throws 419 and others don't, look here first.
2. Send the token with AJAX requests
Forms handled by JavaScript don't get @csrf for free. You have to pass the token yourself. The standard pattern is to drop it into a meta tag in your layout's <head>:
<meta name="csrf-token" content="{{ csrf_token() }}">
Then configure your HTTP client to send it on every request. With axios, set it once globally:
import axios from 'axios';
const token = document.head.querySelector('meta[name="csrf-token"]');
if (token) {
axios.defaults.headers.common['X-CSRF-TOKEN'] = token.content;
} else {
console.error('CSRF token meta tag not found');
}
Now every axios request carries the X-CSRF-TOKEN header, and the middleware is happy.
A couple of things trip people up here. Older Laravel scaffolding (the Mix-era resources/js/bootstrap.js) wired axios to read this exact meta tag for you, so on those projects the fix is often just adding the missing <meta> line. The newer Vite scaffolding dropped that snippet (it only sets X-Requested-With), so on a fresh Laravel 9+ app you add the header yourself, as above.
There's a second path that skips the meta tag entirely. Laravel also honors an X-XSRF-TOKEN header, and axios reads that automatically from the XSRF-TOKEN cookie Laravel sets on each response (the value there is encrypted, unlike the raw csrf-token meta value). If your requests cross to a different subdomain, whether that cookie comes back depends on your session domain config, which is the next thing to check.
For a raw fetch call, attach the header manually:
const token = document.querySelector('meta[name="csrf-token"]').content;
fetch('/profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': token,
},
body: JSON.stringify({ name: 'Ada' }),
});
3. Check your session configuration
If @csrf is present and the error still shows up intermittently (or only in production), the session cookie itself is probably the problem. The browser stores the session cookie, and if it can't send it back, Laravel starts a fresh empty session with no matching token.
Two config values in config/session.php (driven by your .env) cause most of this:
SESSION_DOMAIN=.oatllo.com
SESSION_SECURE_COOKIE=true
-
SESSION_DOMAINmust match the domain serving the app. If it's set to.oatllo.combut you're testing onlocalhost, the cookie is scoped wrong and never comes back. Leave it null for local dev. -
SESSION_SECURE_COOKIE=truetells the browser to send the cookie only over HTTPS. If you set this to true but serve over plain HTTP, the cookie vanishes and every POST returns 419. In production behind HTTPS it should be true; locally it should usually be false.
I once spent an afternoon on a 419 that only appeared after a deploy. The culprit was SESSION_SECURE_COOKIE=true on a staging box that wasn't fully on HTTPS. The form was fine; the cookie just never made the round trip.
4. Clear stale cache and cookies
If a page was cached (by the browser, a CDN, or a response()->cache() layer) it may serve an old CSRF token that belongs to a dead session. Symptoms: the error clears on a hard refresh but comes back later.
Fixes:
- Do a hard reload (Ctrl+Shift+R) and clear cookies for the site.
- Don't cache authenticated, form-bearing pages.
- Clear framework caches after config changes:
php artisan config:clear
php artisan cache:clear
php artisan view:clear
5. Exclude routes that legitimately can't send a token
Some endpoints can't provide a CSRF token because the caller isn't your frontend - think Stripe webhooks or a third-party callback. For those specific routes, add them to the except array so the middleware skips them.
In Laravel 11+ you configure this in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'stripe/webhook',
'webhooks/*',
]);
})
In older versions (Laravel 10 and below), it lives in app/Http/Middleware/VerifyCsrfToken.php:
protected $except = [
'stripe/webhook',
'webhooks/*',
];
Only exclude the exact routes that need it. This is a scalpel, not a hammer.
Why disabling CSRF globally is the wrong fix
When you're frustrated, the tempting shortcut is to gut the VerifyCsrfToken middleware entirely, or add '*' to the except array. Please don't.
CSRF protection exists to stop a genuine attack class. Turning it off site-wide means any malicious page on the internet can make your logged-in users perform actions - change their email, delete data, transfer funds - without consent. You'd be trading a five-minute fix for a real security hole.
The 419 error is annoying, but it's the framework doing its job. The correct response is to make your token flow correct, not to remove the check.
Cause and fix checklist
Run through this in order:
-
Missing
@csrf→ add@csrf(and@methodif needed) to the Blade form. -
AJAX has no token → add the
<meta name="csrf-token">tag and sendX-CSRF-TOKEN. -
Wrong
SESSION_DOMAIN→ match it to your actual domain, null for local. -
SESSION_SECURE_COOKIEon HTTP → set false locally, true only under HTTPS. -
Stale cache/cookies → hard refresh, clear cookies, run the
artisan *:clearcommands. -
Legit tokenless endpoint (webhook) → add just that route to the
exceptarray. -
Session truly expired → reload the page; consider a longer
SESSION_LIFETIMEif it happens often.
FAQ
Why do I get 419 only after leaving a form open for a while?
Your session expired. The default SESSION_LIFETIME is 120 minutes. Once it passes, the stored token is gone and the token you're submitting no longer matches. Reloading the page mints a fresh token. If it's a recurring annoyance for slow forms, raise SESSION_LIFETIME or refresh the token client-side before submit.
Is 419 a security problem I need to worry about?
The error itself isn't a vulnerability - it's your protection working. What you should avoid is the temptation to disable CSRF to make it go away. Fix the token flow instead.
Why does my API return 419 instead of 401?
Routes in routes/api.php normally don't use the CSRF middleware (they use token or Sanctum auth instead). If an API route throws 419, it's probably going through the web middleware group by mistake, or you're using Sanctum's stateful SPA mode where CSRF does apply. Check which middleware group the route belongs to.
Does @csrf work with PUT and DELETE forms?
Yes. HTML forms only support GET and POST, so you use method="POST" plus @method('PUT') (or DELETE). The @csrf token is still required - Laravel checks it for every non-read verb.
Conclusion
The Laravel 419 Page Expired error is a CSRF token mismatch wearing a confusing label. Nine times out of ten it's a missing @csrf in a form or a missing token header on an AJAX call. The rest of the time it's session cookie config: usually SESSION_DOMAIN or SESSION_SECURE_COOKIE set wrong for the environment.
Work through the checklist top to bottom, fix the token flow rather than switching the guard off, and reserve the except array for the handful of endpoints that genuinely can't send a token. Do that and the blank "Page Expired" screen stops being a mystery.