Laravel's 419 Page Expired Can Be Diagnosed
Search for how to fix 419 Page Expired and you get the same list every time. Check @csrf. Check the session driver. Run php artisan config:clear. Check APP_KEY.
Verification environment
- Laravel 13.21.1
- PHP 8.4.23
- Session driver file
- Causes reproduced 6 — all return an identical response
- Classification 5 causes, all verified by replay
- OS Docker Desktop (php:8.4-cli-bookworm)
Search for how to fix 419 Page Expired and you get the same list every time. Check @csrf. Check the session driver. Run php artisan config:clear. Check APP_KEY.
That list fails you not because the fixes are wrong, but because nothing tells you which one you have.
All six causes were reproduced in Docker. Every one of them returns an identical response — same status, same page, no signal to separate them.
Which leaves you trying fixes from the top. So the second half of this article builds a diagnostic that identifies the cause server-side, then replays all six scenarios to confirm the classification is actually right.
Scripts and implementation: laravel-error-lab.
Environment
| Laravel | 13.21.1 |
| PHP | 8.4.23 |
| Session driver | file |
| Runtime | Docker Desktop / php:8.4-cli-bookworm |
Server and client run in the same container, so nothing about a host setup leaks into the results. Each case changes exactly one thing from the working case.
All six causes look the same
| Case | Change | HTTP |
|---|---|---|
| A | (working case) | 200 |
| B | _token omitted |
419 |
| C | _token malformed |
419 |
| D | valid _token, session cookie withheld |
419 |
| E | token minted by a different session | 419 |
| F | APP_KEY rotated after the form was served |
419 |
| G | session expired server-side | 419 |
| H | same POST against an api-group route |
200 |
B through G are indistinguishable. Each returns that same bare 419 / Page Expired page.
H returns 200 because the api group carries no CSRF verification. Whether you get a 419 is decided by the middleware group the route belongs to — not by "Laravel does CSRF."
Worth noting about F: it hits every user of any deployment that runs key:generate on deploy. Anyone sitting on an open login page gets a 419 on their next submit.
Two conclusions were nearly wrong
Stating this up front. On the first run, F and G returned 200 — a result that would have overturned the widely-repeated warning about rotating APP_KEY.
It was a bug in the harness.
php artisan serve spawns the PHP built-in server as a child process. Killing only the parent leaves the child holding port 8000. The next readiness check then succeeds against the old server, so changing APP_KEY or SESSION_LIFETIME had no effect whatsoever.
The fix was to kill the whole process family, wait for the port to actually close, and add a /_probe endpoint reporting the running server's config and pid:
[server] key=a696dfed lifetime=120 pid=344
...
[server] key=a696dfed lifetime=1 pid=544
Results were only accepted once the pid had changed and the new lifetime was visible. Published as-is, that run would have spread a falsehood.
Identifying the cause
If the outside can't tell them apart, the server has to. Three facts decide it:
- Did a session cookie arrive at all?
- Did it decrypt?
- Does the session it points at still exist in the store?
Those three pin down five distinct causes.
| Verdict | Meaning |
|---|---|
NO_TOKEN_SUBMITTED |
form missing @csrf, or AJAX not sending X-CSRF-TOKEN |
NO_SESSION_COOKIE |
browser sent none — blocked cookies, SameSite/Secure mismatch, wrong SESSION_DOMAIN |
COOKIE_UNDECRYPTABLE |
APP_KEY differs from the one that issued it — rotated, or servers disagree |
SESSION_GONE |
cookie fine, session absent — expired, garbage-collected, or store not shared |
TOKEN_MISMATCH |
session alive — stale tab, back-button replay, second login elsewhere |
Two requirements you cannot skip
Both were discovered by getting them wrong: F and G were misclassified, both as TOKEN_MISMATCH.
1. Observe before StartSession
Diagnosing inside the exception handler does not work. By then both pieces of evidence are gone.
EncryptCookiesnulls a cookie it cannot decrypt rather than removing it.$request->cookies->has()keeps returning true, so a rotatedAPP_KEYis indistinguishable from a healthy request.StartSessionhas already created a replacement session, complete with a fresh token — erasing any trace that the original was gone.
So the middleware goes at the front of the web group:
$middleware->web(prepend: [
\App\Http\Middleware\DiagnoseCsrf::class,
]);
Register it later and everything reports TOKEN_MISMATCH — right back to the useless answer this was meant to replace.
The check reads the session store directly:
$id = CookieValuePrefix::remove(Crypt::decrypt($raw, false));
// StartSession is about to mint a replacement.
// This is the last moment the answer still exists.
$payload = app('session')->driver()->getHandler()->read($id);
2. Do not type the render callback on TokenMismatchException
This is the natural thing to write:
// never fires
$exceptions->render(function (TokenMismatchException $e, Request $request) {
// ...
});
It can never match. The framework source is the evidence:
Handler.php:710 $e = $this->prepareException($e);
Handler.php:768 $e instanceof TokenMismatchException => new HttpException(419, $e->getMessage(), $e)
Handler.php:712 $this->renderViaCallbacks($request, $e)
prepareException() rewrites it to HttpException(419) before the callbacks are consulted. Nothing is typed TokenMismatchException by the time they run.
The original survives as the previous exception, so match on that:
$exceptions->render(function (HttpException $e, Request $request) {
if ($e->getStatusCode() !== 419
|| ! $e->getPrevious() instanceof TokenMismatchException) {
return null;
}
return response('DIAGNOSIS: '.CsrfDiagnosis::explain($request)."\n", 419);
});
Matching on the status code alone would be sloppy — 419 can come from elsewhere.
Confirming the classification is right
Building it is not the end. The same six scenarios were replayed against the diagnostic and the verdicts measured.
| Case | Expected | Measured |
|---|---|---|
B no @csrf |
NO_TOKEN_SUBMITTED |
✅ |
| D cookie withheld | NO_SESSION_COOKIE |
✅ |
| E another session's token | TOKEN_MISMATCH |
✅ |
F APP_KEY rotated |
COOKIE_UNDECRYPTABLE |
✅ |
| G session expired | SESSION_GONE |
✅ |
| A working case | still 200 | ✅ |
Every case matched, and the working case still works.
Using this in production
The implementation above returns the cause in the response body. That is fine for a lab and wrong for production — it tells an attacker about your session state.
Log it instead:
Log::warning('CSRF failure', [
'cause' => CsrfDiagnosis::explain($request),
'path' => $request->path(),
]);
return response()->view('errors.419', [], 419);
Users keep seeing the ordinary page, and when someone reports "I keep getting a 419," the log already says why.
FAQ
Q. Can I tell which cause I have by looking at the error page?
No. All six causes reproduced in this article return an identical response — same status, same page. Nothing in the response distinguishes them, which is why the usual advice leaves you trying fixes from the top. Identifying the cause requires instrumenting the server.
Q. We regenerate APP_KEY on every deploy. Does that cause 419s?
Yes, and it is measured here. Rotating APP_KEY after a form has been served makes that submission fail with 419, because the session cookie can no longer be decrypted with the new key.
It is not one unlucky user either: everyone sitting on an open form gets a 419 on their next submit after the deploy.
Q. Does an expired session produce a 419?
Yes. Submitting a form whose session has passed SESSION_LIFETIME returns 419 — and it looks exactly like the rotated-APP_KEY case from the outside.
Q. Can a route in the api group return 419?
No. The same POST against an api-group route returns 200, because that group carries no CSRF verification. Whether you get a 419 is decided by the middleware group the route belongs to, not by the fact that you are using Laravel.
Q. My render callback typed on TokenMismatchException never fires. Why?
It cannot match. Handler::render() calls prepareException() first, and that rewrites TokenMismatchException into HttpException(419) before the render callbacks are consulted.
The original survives as the previous exception, so type the callback on HttpException and check getPrevious(). The working version is in the section above.
Reproducing this
git clone https://github.com/codelift-dev/laravel-error-lab
cd laravel-error-lab
docker compose build
docker compose run --rm lab bash 419-page-expired.sh # reproduce every cause
docker compose run --rm lab bash 419-diagnose.sh # verify the diagnosis
work/CsrfDiagnosis.php and work/DiagnoseCsrf.php drop into an application unchanged.
Verified on Laravel 13.21.1. The Handler.php line numbers will drift in future releases, but the ordering — prepareException() running before the render callbacks — has held since Laravel 11.
Related articles
- We Actually Ran the Laravel 12 to 13 Upgrade There is no shortage of Laravel 13 upgrade guides. The problem is that most of the warnings in them were written without running the upgrade.
- Vite Manifest Not Found: Four Different Causes When Vite manifest not found appears right after a deploy, the answer you find is almost always "run npm run build". Measuring six cases in Docker shows that the build fixes one of the four causes, and that two of them never produce an err…
- Implementing Content-Security-Policy in Laravel Content-Security-Policy (CSP) is the last defense layer that stops XSS damage in the browser. The server declares "these are the only scripts allowed to run and resources allowed to load on this page," and the browser rejects everything el…