{} CodeLift

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.

Pub 2026-07-23 Verified 2026-07-23 Upd 2026-08-12

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:

  1. Did a session cookie arrive at all?
  2. Did it decrypt?
  3. 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.

  • EncryptCookies nulls a cookie it cannot decrypt rather than removing it. $request->cookies->has() keeps returning true, so a rotated APP_KEY is indistinguishable from a healthy request.
  • StartSession has 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