Laravel『TokenMismatchException』の原因と対処法

Laravel『TokenMismatchException』の原因と対処法

TokenMismatchException は、LaravelのCSRF保護(VerifyCsrfTokenミドルウェア)で「送信されたCSRFトークン」と「セッションに保存されているトークン」が一致しないときに発生する。フォーム送信で @csrf を入れ忘れた、AJAXで X-CSRF-TOKEN ヘッダを付けていない、セッションCookieが送れていない(SameSite/ドメイン/HTTPS/サブドメイン)、ロードバランサ配下でセッションが固定されていない、キャッシュやタブの戻る操作で古いトークンが送られているなどが典型パターン。原因は「トークン不一致」そのものより、セッションが期待通り維持できていないケースが多い。

エラーの出方と発生条件(典型例)

ブラウザ上では 419 Page Expired や TokenMismatchException として出ることが多い。
発生条件の典型:
・POST/PUT/PATCH/DELETE を送るフォームにCSRFトークンが無い
・SPA/Fetch/AxiosでCSRFヘッダやXSRF Cookieが付かない
・セッションが切れた/別サーバに飛んだ/別ドメイン扱いになってCookieが送れない
・HTTP→HTTPS切り替え、www有無、サブドメイン移動でCookieが別物になる
・ブラウザの戻る/キャッシュで古いフォームを送信してトークンが古い
・APIルートにwebミドルウェアを当ててしまい、CSRFが効いているのにトークンを送っていない

CSRFの仕組み(何が一致していないのか)

Laravelのwebルートでは、セッションにCSRFトークンが保存され、フォームやヘッダで送られてきたトークンと照合する。
・セッションCookieが送れない → サーバ側は別セッション扱い → トークンが一致しない
・トークンを送っていない → そもそも照合できず失敗
この2パターンが大半。つまり「フォームにトークンが入っているか」と「同じセッションで送れているか」を確認すると原因が見える。

原因1:Bladeフォームに @csrf が入っていない

フォーム送信で最も多い原因。

<!-- NG -->
<form method="POST" action="/profile/update">
  <input type="text" name="name">
  <button type="submit">Save</button>
</form>

対処:必ず @csrf を入れる。

<!-- OK -->
<form method="POST" action="/profile/update">
  @csrf
  <input type="text" name="name">
  <button type="submit">Save</button>
</form>

PUT/PATCH/DELETE なら method spoofing も必要。

<form method="POST" action="/posts/1">
  @csrf
  @method('PUT')
  <input type="text" name="title">
  <button type="submit">Update</button>
</form>

原因2:AJAX/Fetch/AxiosでCSRFトークンヘッダが付いていない

JavaScriptからPOSTする場合は、X-CSRF-TOKEN を付けるか、XSRF-TOKEN Cookieの仕組みを使う。
metaタグを使う例。

<!-- resources/views/layouts/app.blade.php -->
<meta name="csrf-token" content="{{ csrf_token() }}">

Fetchでヘッダを付ける例。

const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');

fetch('/profile/update', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-TOKEN': token,
    'X-Requested-With': 'XMLHttpRequest',
  },
  body: JSON.stringify({ name: 'Taro' })
});

Axiosの例(よくある設定)。

import axios from 'axios';

axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
axios.defaults.headers.common['X-CSRF-TOKEN'] =
  document.querySelector('meta[name="csrf-token"]').content;

原因3:APIルートにwebミドルウェアが当たっている(CSRFが有効になっている)

本来は stateless にしたいAPIに web ミドルウェア(セッション+CSRF)が混ざると、トークンが無いAPIリクエストがTokenMismatchExceptionになる。
確認ポイント:
・routes/api.php なのに RouteServiceProvider や middleware 設定で web が付いていないか
・routes/web.php に API を置いていないか
対処案:
・APIは routes/api.php に置いて、apiミドルウェアを使う
・セッションが必要なAPIだけweb配下に置き、CSRFトークンを必ず送る
どうしても外部WebhookなどでCSRFが不要なら、CSRF除外を検討する(次のセクション)。

原因4:CSRF除外(VerifyCsrfToken の $except)が必要なエンドポイント

外部サービスのWebhookや、CSRFトークンを付けられないシステム連携は、CSRF対象から外す必要がある場合がある。

// app/Http/Middleware/VerifyCsrfToken.php
protected $except = [
    'webhook/*',
    'payment/callback',
];

注意点:
・除外すると第三者サイトからの不正POSTを防げなくなる
・除外するのは「外部からの正当なPOSTが来る」など理由が明確なものだけ
・代替として署名検証(HMAC)やIP制限、Basic認証、トークン認証を入れる方が安全

原因5:セッションCookieが送れていない(SameSite / Secure / ドメイン)

トークンは正しく埋め込んでいるのに419になる場合、セッションCookieが送れていないケースが多い。
よくある原因:
・HTTP/HTTPSが混在している(Secure Cookieが送れない)
・wwwあり/なし、サブドメイン移動でCookieドメインが一致していない
・SameSite=Lax/Strict により、別サイト経由のPOSTでCookieが落ちる
・SPAが別ドメインで動いていて、CORS/credentials未設定でCookieが送れない

代表的な確認点(.env)。

APP_URL=https://example.com
SESSION_DOMAIN=.example.com
SESSION_SECURE_COOKIE=true
SESSION_SAME_SITE=lax

クロスドメインSPAの場合は、SameSite=None + Secure が必要になることが多い。

SESSION_SAME_SITE=none
SESSION_SECURE_COOKIE=true

さらに、フロント側で credentials を有効にする必要がある。

// fetchの場合
fetch('https://api.example.com/profile/update', {
  method: 'POST',
  credentials: 'include',
  headers: { 'X-CSRF-TOKEN': token },
  body: JSON.stringify(data),
});

CORS設定(Laravel側)も合わせて整合させる。

原因6:ロードバランサ配下でセッションが固定されていない(別サーバに飛ぶ)

複数台構成で、リクエストが毎回別サーバに振り分けられ、サーバローカルのfileセッションを使っていると、セッションが維持できずトークン不一致が起きやすい。
典型:
・SESSION_DRIVER=file のまま複数台
・sticky session が無い
対処:
・SESSION_DRIVER=redis / database にして共有ストレージへ

# .env
SESSION_DRIVER=redis

・どうしても file のままなら sticky session(セッション固定)を導入
運用の安定性を考えると、セッション共有の方がトラブルが減る。

原因7:ブラウザの戻る/キャッシュで古いフォームを送信している

フォームページを開いたまま長時間放置、別タブでログアウト/ログインし直し、戻るで古いページを再送信、などでCSRFが古くなり419が出ることがある。
対処案:
・419エラー時にフォームへリダイレクトして再表示する(ユーザー体験改善)
・重要フォームは「再表示時にトークンを必ず最新にする」
・長時間操作する画面は定期的にページ更新/トークン更新を設計する

サンプル:AJAX送信のための共通セットアップ(Blade + Fetch)

Bladeレイアウトでmetaタグを置き、JSで毎回ヘッダを付与する。

<!-- layout -->
<meta name="csrf-token" content="{{ csrf_token() }}">

// common.js
export function csrfToken() {
  return document.querySelector('meta[name="csrf-token"]').getAttribute('content');
}

export async function postJson(url, data) {
  const res = await fetch(url, {
    method: 'POST',
    credentials: 'same-origin',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-TOKEN': csrfToken(),
      'X-Requested-With': 'XMLHttpRequest',
    },
    body: JSON.stringify(data),
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}`);
  }
  return await res.json();
}

チェックリスト(上から順に確認する)

1) どのHTTPメソッドで落ちているか(POST/PUT/PATCH/DELETE)を確認したか
2) Bladeフォームに @csrf が入っているか(PUT/DELETEなら @method も)を確認したか
3) AJAXなら X-CSRF-TOKEN ヘッダ or XSRFトークンが送られているかをDevToolsで確認したか
4) セッションCookieがリクエストに付いているか(SameSite/Domain/Secure/HTTPS)を確認したか
5) APIルートにwebミドルウェアが当たっていないか(routes/api.php と middleware)を確認したか
6) WebhookなどCSRFを付けられないエンドポイントだけを $except に入れているか(安全対策込み)を確認したか
7) 複数台構成ならセッション共有(redis/database)またはsticky session が有効か確認したか
8) 長時間放置や戻る操作で古いトークンを送っていないか(再表示/再試行で直るか)を確認したか