{} CodeLift

Laravel の 419 Page Expired は原因を特定できる

419 Page Expired の対処法を検索すると、だいたい同じリストが出てくる。「@csrf を確認」「セッションドライバを確認」「php artisan config:clear」「APP_KEY を確認」——。

公開 2026-07-23 検証 2026-07-23 更新 2026-08-12

検証環境

  • 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)

419 Page Expired の対処法を検索すると、だいたい同じリストが出てくる。「@csrf を確認」「セッションドライバを確認」「php artisan config:clear」「APP_KEY を確認」——。

このリストが役に立たない理由は、対処法が間違っているからではない。自分がどれに該当するか判定する手段が書かれていないからである。

Docker 上で 6 つの原因をすべて再現して確認した。6 つとも、外から見て完全に同一の応答を返す。 ステータスも本文も同じで、区別する材料が 1 つも無い。

だから「上から順に試す」以外にやりようがなくなる。本記事は、サーバ側で原因を特定する判定器を実装し、6 ケース全部を再生して分類が正しいことを確認した記録である。

スクリプトと実装は laravel-error-lab に置いてある。

検証環境

Laravel 13.21.1
PHP 8.4.23
session driver file
実行環境 Docker Desktop / php:8.4-cli-bookworm

サーバと curl は同一コンテナ内で動かした。ホスト環境の状態が結果に混ざらない。

正常系から一度に 1 つだけ条件を変えて計測している。

6 つの原因はすべて同じ応答を返す

ケース 変更点 HTTP
A (正常系) 200
B _token を送らない 419
C _token を壊れた値にする 419
D _token は正しいが Cookie を送らない 419
E 別セッションが発行したトークンを使う 419
F フォーム表示後に APP_KEY をローテート 419
G セッションをサーバ側で期限切れにする 419
H 同じ POST を api グループのルートへ 200

B〜G の応答に差は無い。 本文はいずれも 419 / Page Expired のあの素っ気ないページである。

H が 200 なのは、api グループに CSRF 検証が入っていないため。419 が出るかどうかはルートの属するミドルウェアグループで決まる。「Laravel だから CSRF がかかる」わけではない。

ちなみに F は、デプロイのたびに key:generate を走らせている環境で全ユーザーに起きる。ログイン画面を開いたままの利用者は、デプロイ後の送信で必ず 419 になる。

検証中に、結論が 2 つ間違いかけた

先に書いておく。最初の実行では F と G が 200 になった。「APP_KEY を変えても 419 にならない」という、広く言われている話を覆す結果である。

これはテスト側のバグだった。

php artisan serve は PHP ビルトインサーバを子プロセスとして起動する。親プロセスだけを kill すると、子がポート 8000 を掴んだまま残る。次の起動確認 curl はその古いサーバに応答するので、APP_KEY を変えても SESSION_LIFETIME を変えても一切反映されない。

対策として、プロセス一族を kill してポートが閉じるまで待つようにし、さらに実行中サーバの設定値と PID を返す /_probe を用意した。

[server] key=a696dfed lifetime=120 pid=344
...
[server] key=a696dfed lifetime=1   pid=544

pid が変わり lifetime が反映されたことを確認して、初めて結果を採用している。そのまま公開していれば、嘘を広めるところだった。

原因を判定する

外から区別できない以上、サーバ側で判定するしかない。決め手になる情報は 3 つある。

  1. セッション Cookie がそもそも送られてきたか
  2. その Cookie が復号できたか
  3. 復号できた ID のセッションが、ストアに実在するか

この 3 つで 5 つの原因が一意に決まる。

判定 意味
NO_TOKEN_SUBMITTED フォームに @csrf が無い / AJAX が X-CSRF-TOKEN を送っていない
NO_SESSION_COOKIE ブラウザが Cookie を送っていない。ブロック、SameSite / Secure 不整合、SESSION_DOMAIN の誤り
COOKIE_UNDECRYPTABLE APP_KEY が発行時と違う。ローテート、またはサーバ間で不一致
SESSION_GONE Cookie は正しいがセッション実体が無い。期限切れ、GC、ストアが共有されていない
TOKEN_MISMATCH セッションは生きている。古いタブ、戻るボタン、別タブでの再ログイン

実装で外せない条件が 2 つある

どちらも最初の実装で踏み、F と G が誤判定(両方 TOKEN_MISMATCH)になった。

1. StartSession より前で観測する

例外ハンドラの中で判定しようとすると失敗する。その時点では、区別に必要な証拠が 2 つとも消えているためである。

  • EncryptCookies は復号に失敗した Cookie を削除せず、値を null にセットする。 だから $request->cookies->has() は true を返し続ける。APP_KEY ローテートが正常なリクエストと見分けられない
  • StartSession は既に新しいセッションを作り終えている。 新品のトークンを持っているので、「セッションが消えていた」痕跡が残らない

したがってミドルウェアを prepend で web グループの先頭に置く。

$middleware->web(prepend: [
    \App\Http\Middleware\DiagnoseCsrf::class,
]);

後ろに置くとすべてが TOKEN_MISMATCH になる。置き換えたかった、役に立たない答えに戻ってしまう。

判定側はセッションストアを直接読む。

$id = CookieValuePrefix::remove(Crypt::decrypt($raw, false));

// StartSession がこの直後に代替セッションを作る。
// 答えが残っている最後の瞬間がここ。
$payload = app('session')->driver()->getHandler()->read($id);

2. TokenMismatchException 型で render を登録してはいけない

一番自然に書く形はこれである。

// 発火しない
$exceptions->render(function (TokenMismatchException $e, Request $request) {
    // ...
});

構造上、絶対に一致しない。 フレームワークのソースが根拠になる。

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()HttpException(419)書き換えた後でコールバックが参照される。コールバックが動く頃には TokenMismatchException 型のものは存在しない。

ただし元の例外は第 3 引数で previous として保持されている。そこを見ればよい。

$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);
});

ステータスコードだけで判定していないのは、419 は他の理由でも返しうるためである。

分類が正しいことを確認する

実装しただけでは終わらない。Phase 1 と同じ 6 シナリオを再生して、判定が一致するか計測した。

ケース 期待 実測
B @csrf 無し NO_TOKEN_SUBMITTED
D Cookie 未送信 NO_SESSION_COOKIE
E 別セッションのトークン TOKEN_MISMATCH
F APP_KEY ローテート COOKIE_UNDECRYPTABLE
G セッション期限切れ SESSION_GONE
A 正常系 200 のまま

全ケース一致。正常系も壊れていない。

本番で使うなら

上の実装は原因をレスポンス本文に出している。検証用なのでそうしているが、本番でこれを晒すべきではない。攻撃者にセッション状態を教えることになる。

本番ではログに出す。

Log::warning('CSRF failure', [
    'cause' => CsrfDiagnosis::explain($request),
    'path'  => $request->path(),
]);

return response()->view('errors.419', [], 419);

これで、利用者には従来どおりのページを見せたまま、「なぜか 419 が出る」という報告を受けたときにログを見れば原因が分かる状態になる。

よくある質問

Q. 419 Page Expired の原因は、画面を見て特定できますか

できません。本記事で再現した 6 つの原因は、ステータスも本文もまったく同じ応答を返します。区別する材料がレスポンスに存在しないので、「対処法を上から順に試す」以外の方法が無くなります。特定するにはサーバ側で判定を仕込む必要があります。

Q. デプロイのたびに APP_KEY を変えていますが、419 の原因になりますか

なります。実測で確認しました。フォームを表示した後に APP_KEY をローテートすると、そのユーザーの送信は 419 になります。セッション Cookie が新しい鍵で復号できなくなるためです。

影響は 1 人ではありません。ログイン画面などを開いたままの利用者全員が、デプロイ直後の送信で 419 になります。

Q. セッションの有効期限が切れると 419 になりますか

なります。SESSION_LIFETIME を過ぎたセッションでフォームを送信すると 419 が返ります。ただし画面上は APP_KEY ローテートの場合と区別がつきません

Q. api のルートでも 419 は起きますか

起きません。同じ POST を api グループのルートに送ると 200 が返りますapi グループには CSRF 検証が入っていないためです。419 が出るかどうかは、Laravel を使っているかではなくルートが属するミドルウェアグループで決まります。

Q. TokenMismatchExceptionrender で捕まえたのに呼ばれません

構造上、その型では一致しません。Handler::render() は先に prepareException() を呼び、そこで TokenMismatchExceptionHttpException(419)書き換えられてからコールバックが参照されます。

元の例外は previous として保持されているので、HttpException を受けて getPrevious() を見てください。実装は本文の「原因を判定する」節にあります。

再現

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   # 6 原因の再現
docker compose run --rm lab bash 419-diagnose.sh       # 判定の検証

work/CsrfDiagnosis.phpwork/DiagnoseCsrf.php はそのままアプリにコピーして使える。

Laravel 13.21.1 で確認している。Handler.php の行番号は将来のバージョンでずれる可能性があるが、prepareException() が render コールバックより先に呼ばれる構造自体は Laravel 11 以降変わっていない。

関連記事