On this page
- What a 419 actually is
- Cause 1: the form has no CSRF token
- Cause 2: the session expired while the page was open
- Cause 3: the token changed because the user logged in or out
- Cause 4: the session cookie is not coming back
- Cause 5: the session is not saved on the server
- Cause 6: a cached page contains someone else's token
- Cause 7: AJAX, fetch or SPA requests without a token
- Cause 8: an external service posts to your route
- Laravel 13: the 403 variant and the origin settings
- Debug a 419 in five minutes
- Things not to do
- Quick reference
A Laravel 419 "Page Expired" error means one thing: the CSRF check failed. The framework compared the token that came with your request against the token stored in the user's session, and they did not match, or one of them was missing. The error page says "expired", but a session timeout is only one of about eight ways to get there. This guide goes through each cause in the order you are most likely to hit it, how to confirm it, and the fix. Behavior was checked against the Laravel documentation and the framework source (12.x and tag v13.35.0).
What a 419 actually is
In the web middleware group, the CSRF middleware runs right after the session starts. If the request fails the check, it throws TokenMismatchException with the message "CSRF token mismatch.", and the exception handler turns that into an HTTP 419 response. Browsers see the "Page Expired" page. JSON requests get {"message": "CSRF token mismatch."} instead, because the message of an HTTP exception is returned as-is when debug mode is off.
Two details are worth knowing before you debug:
- It is not written to your log.
TokenMismatchExceptionis in the handler's built-in list of exceptions that are not reported, sostorage/logs/laravel.logstays empty and you have to debug from the browser side. - GET, HEAD and OPTIONS are never checked. Only POST, PUT, PATCH and DELETE can return 419.
Which middleware, in which version
The check lives in Illuminate\Foundation\Http\Middleware\ValidateCsrfToken in Laravel 12 (it was VerifyCsrfToken before that). In Laravel 13 it is PreventRequestForgery, and the old class names are kept as deprecated subclasses. Laravel 13 also adds a first layer: if the browser sends Sec-Fetch-Site: same-origin, the request is accepted without looking at a token at all. Otherwise it falls back to the token check. The Laravel documentation notes that browsers only send that header over HTTPS, so on plain HTTP you always go through the token check.
I ran the Laravel 13.35.0 middleware directly against a test session to confirm exactly what passes and what fails:
| Request (POST unless noted) | Result |
|---|---|
| GET, no token | Pass |
No token, no Sec-Fetch-Site header (HTTP, curl, older browser) | 419 |
Correct _token field | Pass |
Wrong _token | 419 |
Correct X-CSRF-TOKEN header | Pass |
Valid encrypted X-XSRF-TOKEN header | Pass |
Garbage X-XSRF-TOKEN header | 419 |
| Old token, but the session is a new one | 419 |
Token from before session()->regenerate() | 419 |
Token from before session()->invalidate() | 419 |
Sec-Fetch-Site: same-origin, no token | Pass |
Sec-Fetch-Site: same-site, no token | 419 |
Sec-Fetch-Site: cross-site, valid token | Pass |
| URI listed in the exclusion list | Pass |
The rows about a new session, regenerate() and invalidate() explain a surprising number of real-world 419 errors, as you will see below.
Cause 1: the form has no CSRF token
The most common cause is also the simplest. Any form that sends POST, PUT, PATCH or DELETE needs the hidden token field:
<form method="POST" action="/profile">
@csrf
<!-- Equivalent to: -->
<!-- <input type="hidden" name="_token" value="{{ csrf_token() }}"> -->
...
</form>Check the rendered HTML in your browser's page source. If the _token input is missing, the form was written as plain HTML, copied from a static page, or built by a JavaScript snippet that never added it. For PUT, PATCH and DELETE forms you also need @method('PUT') and so on, since HTML forms only send GET and POST. In Laravel 13.35 a strict form request that rejects unknown fields ignores both _token and _method, so those two keys do not break it.
Cause 2: the session expired while the page was open
Someone opens a form, goes to lunch, comes back and submits. By then the session is gone. The default session lifetime in a fresh application is SESSION_LIFETIME=120, measured in minutes, so the form must be submitted within two hours of the last request to keep the same session. When the session has expired, the server starts a new one with a new token, and the old token on the page no longer matches.
Fixes, from least to most invasive:
- Show a friendly message and send the user back to the form with their input kept (code below), instead of the default error page.
- Raise
SESSION_LIFETIMEin.envif long forms are normal in your app. Remember a longer lifetime also keeps stale sessions around longer. - Keep long forms alive with a small request every few minutes while the page is open, since every request through the
webgroup refreshes the session.
// bootstrap/app.php
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->render(function (HttpException $e, Request $request) {
if ($e->getStatusCode() !== 419 || $request->expectsJson()) {
return null; // let every other error keep its normal handling
}
return redirect()
->back()
->withInput($request->except(['password', 'password_confirmation']))
->with('status', 'Your session expired. Please submit the form again.');
});
})Important: type-hint HttpException here, not TokenMismatchException. I checked the handler source: it converts the token exception into an HttpException with status 419 before it runs your custom render callbacks, so a callback typed for TokenMismatchException never fires. Returning null from a render callback tells Laravel to carry on with its default handling, which is why the early return above is safe. Also keep passwords out of the flashed input, as shown.
Cause 3: the token changed because the user logged in or out
Laravel's session guard calls session()->regenerate(true) when a user logs in. That regenerates both the session ID and the CSRF token. Logout code that calls invalidate(), which the Laravel documentation shows for clearing a session, does the same (my test above confirms an old token fails after it). Any page that was already open before that moment holds a token that is now dead.
The classic reproduction: open the login page in two tabs, log in through the first, then submit the form in the second. The second tab gets a 419 even though nobody waited long. Another variant is a user who logs out in one tab and keeps working in another.
There is nothing to "fix" in the framework here, because regenerating on login is a deliberate security measure. The right response is the friendly redirect from Cause 2, so people land on a message instead of an error. Avoid calling session()->regenerate() yourself on every request, since that kills every token you have just issued.
Cause 4: the session cookie is not coming back
If the browser does not send the session cookie, the server starts a fresh, empty session on every request, and no token can ever match. Open DevTools, go to the Network tab, click the failing POST and look at its request headers. If the cookie named by SESSION_COOKIE (by default the snake-cased APP_NAME followed by _session) is not in the request, you have found it. Common reasons, all controlled by config/session.php:
| Setting | Default | How it breaks sessions |
|---|---|---|
SESSION_SECURE_COOKIE | not set | If set to true while the site is opened over plain HTTP, the browser will not store or send the cookie. This mostly bites staging servers and custom dev domains. |
SESSION_DOMAIN | not set | If it is set to a domain that does not match the host in the address bar (for example .example.com while you browse localhost or a different domain), the cookie is rejected. |
SESSION_SAME_SITE | lax | Fine for normal forms. If your app is embedded in an iframe on another site, or the form is posted from another site, the cookie is withheld. |
SESSION_PATH | / | A non-root path means the cookie only goes to URLs under that path. |
See the values the application is really using:
php artisan config:show sessionOne more local-development trap: localhost and 127.0.0.1 are different hosts to a browser, with separate cookies. Starting the page on one and posting to the other loses the session.
Cause 5: the session is not saved on the server
The cookie can be perfectly fine and the session data still missing on the next request. Check these:
- Several servers with the
filedriver. File sessions live instorage/framework/sessionson one machine. A load balancer that sends the GET to server A and the POST to server B means server B has never heard of that session. Use a shared driver (redis,database,memcached) or sticky sessions. The Laravel documentation lists these drivers. - Storage not writable. If the web server user cannot write to
storage/framework/sessions, sessions are never persisted. Fix the ownership ofstorageandbootstrap/cache. - Database driver without the table. The default driver in a new application is
database, which needs thesessionstable from the default migrations. A missing or failed migration breaks it. APP_KEYchanged. The session cookie is encrypted. When the key changes, the cookie can no longer be decrypted, the framework treats it as absent (I confirmed that in the cookie middleware source), and everyone gets a new session. After a deploy that rotates the key, expect 419s for anyone with a form already open.
Cause 6: a cached page contains someone else's token
The token is embedded in the HTML. If a CDN, reverse proxy or full-page cache stores a page that contains @csrf, every visitor receives the same HTML with the same token, which belongs to a session that is not theirs. The result is a 419 for nearly everyone, usually starting right after caching was switched on.
Do not cache pages that contain forms for logged-in or session-bound users, or load the token separately through an uncached request. A page rendered from an old browser tab or the back button can also carry a stale token for the same reason.
Cause 7: AJAX, fetch or SPA requests without a token
Laravel accepts the token from the _token input, the X-CSRF-TOKEN header, or the X-XSRF-TOKEN header. The second option pairs with a meta tag:
<meta name="csrf-token" content="{{ csrf_token() }}">const token = document.querySelector('meta[name="csrf-token"]').content;
await fetch('/profile', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-TOKEN': token,
},
body: JSON.stringify({ name: 'Amal' }),
});Laravel also sends an encrypted XSRF-TOKEN cookie with each response, and libraries such as Axios copy it into the X-XSRF-TOKEN header automatically on same-origin requests. That is why Axios-based code often works with no extra setup while a hand-written fetch call does not.
If your frontend lives on a different origin, use Laravel Sanctum's SPA flow. Per its documentation: request /sanctum/csrf-cookie first, send the X-XSRF-TOKEN header with the URL-decoded cookie value (Axios does this for you), enable withCredentials and withXSRFToken on Axios, set supports_credentials to true in config/cors.php, and make the session cookie domain cover your subdomains, for example 'domain' => '.domain.com'.
Cause 8: an external service posts to your route
Stripe, PayPal, GitHub and similar services send webhooks that cannot know your CSRF token. Such routes should live outside the web group, such as in routes/api.php, with their own signature check. If a route must stay in web, exclude it explicitly. In Laravel 13:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->preventRequestForgery(except: [
'stripe/*',
]);
})On Laravel 12 the method is validateCsrfTokens(except: [...]) with the same argument. Laravel 13 keeps validateCsrfTokens() as a deprecated method that calls the new one. Excluding a route removes CSRF protection for it, so verify the sender's signature in your controller. Never exclude a broad pattern like * to make an error go away.
Laravel 13: the 403 variant and the origin settings
Laravel 13 can run in origin-only mode, which relies solely on the Sec-Fetch-Site header and skips tokens:
$middleware->preventRequestForgery(originOnly: true);In that mode a failed check returns 403 instead of 419, and the framework stops sending the XSRF-TOKEN cookie. In my test, a cross-site POST with a perfectly valid token was rejected, and so was a POST with no Sec-Fetch-Site header at all, such as one made over plain HTTP. If you enable this and start seeing 403 where you expected 419, that is the reason. Requests from a subdomain have same-site, not same-origin, and are rejected unless you also pass allowSameSite: true.
Debug a 419 in five minutes
- Confirm the route is in the
webgroup:php artisan route:list -v --path=profilelists the middleware, which should include the CSRF middleware. - Look at the failing request in DevTools. Is there a
_tokenin the form data or anX-CSRF-TOKEN/X-XSRF-TOKENheader? If not, it is Cause 1 or 7. - Check that the session cookie is in the request headers. If not, it is Cause 4. Check the response of the earlier GET for a
Set-Cookieheader and any browser warning about a rejected cookie. - Reload the page and submit again right away. If it works, it was an expired session or a token regenerated by a login (Causes 2 and 3). If it still fails, it is Cause 5, or Cause 6 if you use a cache.
- Print the effective config:
php artisan config:show session.
You can also reproduce the failure and the fix with curl using a cookie jar, which separates browser problems from server problems:
# 1. Load the form, keep the cookies, extract the token
curl -s -c jar.txt https://app.test/profile \
| sed -n 's/.*name="_token" value="\([^"]*\)".*/\1/p' | head -1 > token.txt
# 2. POST without the token: expect 419
curl -s -o /dev/null -w '%{http_code}\n' -b jar.txt -c jar.txt \
-X POST https://app.test/profile -d 'name=Amal'
# 3. POST with the token and the same cookies: expect your normal response
curl -s -o /dev/null -w '%{http_code}\n' -b jar.txt -c jar.txt \
-X POST https://app.test/profile -d "_token=$(cat token.txt)" -d 'name=Amal'If step 3 works with curl but fails in the browser, the problem is on the browser side: cookies, caching or JavaScript. If step 3 also fails, look at the server side: sessions, storage, APP_KEY or multiple servers.
Things not to do
- Do not disable CSRF protection globally to silence the error. It exists to stop other sites from making your logged-in users submit requests.
- Do not clear cookies and call it fixed. That only gives you a new session, and the cause comes back.
- Do not rely on tests catching it. The CSRF middleware is skipped when running tests, which the Laravel documentation states and the source confirms, so a test suite can be green while production returns 419. Test real browser flows or curl against a staging server.
Quick reference
| Symptom | Likely cause | Fix |
|---|---|---|
| Every submit fails | No @csrf or token header | Add @csrf or X-CSRF-TOKEN |
| Fails after the page was left open | Session expired | Friendly redirect, longer lifetime, keep-alive |
| Fails after login or logout in another tab | Token regenerated | Friendly redirect |
| Fails every time, session cookie missing | Domain, secure or same-site settings | Fix config/session.php values |
| Fails randomly behind a load balancer | File sessions on several servers | Use Redis or database sessions |
| Fails for everyone after a deploy | APP_KEY changed, or page cache | Keep the key stable, do not cache form pages |
| Webhook returns 419 | Route is in web group | Move to API routes or exclude it |
| 403 instead of 419 (Laravel 13) | Origin-only mode | Remove originOnly or allow same-site |
Â
References: Laravel: CSRF Protection, Laravel: HTTP Session, Laravel Sanctum: SPA Authentication and Laravel 12.x: CSRF Protection.
Free resources for this topic
Code snippets and fixes for this topic
Get one practical tip every week
New tutorials, fixes and free cheat sheets from DEV Talk, straight to your inbox. No spam, unsubscribe in one click.
Free download: Laravel interview questions (PDF)
Have a question about this post?
Ask the community: other developers and the author can answer.
Get notified about new articles
One click, no email needed. Turn it off anytime with the bell at the top.
Get new articles on your iPhone
Add DEV Talk to your Home Screen, open it, then tap the bell.
Enjoying the content?
If this article helped you, you can support the author with a coffee. It keeps new posts coming.
Comments 0
No comments yet
Be the first to share your thoughts.