フロントエンド・API連携

Laravel SanctumによるSPA認証:クッキーとトークンのどちらを選ぶべきか

Laravel 12で会員サイトを組むとき、APIをLaravel + Sanctum、フロントエンドをVue 3に分ける構成は珍しくない。そこでほぼ必ず出てくるのが、認証方式をどうするかという問題だ。…

Laravel SanctumによるSPA認証:クッキーとトークンのどちらを選ぶべきか

Sanctumには大きく分けて、Laravelのセッションとクッキーを使うSPA認証と、Bearerトークンを使うAPIトークン認証がある。公式ドキュメントは、自社で管理するSPAにはクッキーベースの認証を推奨している。一方で、フロントエンドとAPIが別の運用単位に分かれていたり、モバイルアプリや外部サービスからAPIを呼び出したりするなら、トークン認証の方が自然なこともある。

ややこしいのは、app.example.comapi.example.comのような構成が、単純に「同一ドメイン」でも「完全な別ドメイン」でもないことだ。ブラウザのサイト判定、オリジン、クッキーのDomain属性、CORS、Sanctumのstateful設定がそれぞれ別の基準で動く。ここを一つの概念として扱うと、ログインは成功するのにAPIだけ401になる、といった典型的な事故につながる。

Sanctumの選択で大切なのは、クッキーとトークンの優劣を決めることではない。どのクライアントが、どの境界を越えて、どのように認証情報を保持するのかを先に決めることだ。

SanctumがSPAにクッキー認証を推奨する理由

Laravel Sanctumが自社SPAにクッキー認証を勧める理由は、SPAだから特別なトークンを使う必要がないからだ。ブラウザ上で動く自社フロントエンドとLaravel APIの間であれば、Laravelがもともと持っているセッション認証を利用できる。

この方式では、ログイン後の認証状態をセッションで管理する。ブラウザはセッションIDをクッキーとして保持し、以後のリクエストに自動的に送信する。SPAのJavaScriptがアクセストークンを直接保持し、毎回Authorizationヘッダーを組み立てる必要はない。

ここで注意したいのは、「Laravelが標準でセッションIDを一定周期で自動再生成する」といった説明を、Sanctumの特徴として扱わないことだ。LaravelにはセッションIDを再生成する仕組みがあり、ログイン処理などでセッション固定攻撃への対策として再生成を行う実装もある。しかし、Sanctumが一定周期でセッションIDやアクセストークンを自動ローテーションするわけではない。ログイン時に再生成するか、セッションの有効期間をどう設定するか、ログアウト時にどう破棄するかは、Laravelの認証処理とアプリケーションの設定に依存する。

クッキー認証の利点は、認証情報をブラウザのJavaScriptから切り離しやすいことにある。セッション用クッキーにHttpOnlyを設定すれば、通常のJavaScriptからその値を読み取れない。XSSが発生した場合でも、localStorageに保存したBearerトークンをそのまま抜き取られる構成に比べれば、認証情報の持ち出しに対する耐性を高められる。

もちろん、これでXSSの問題が消えるわけではない。攻撃者がページ上で任意のJavaScriptを実行できれば、ユーザーの権限でAPIリクエストを送信することは可能だ。クッキー認証はXSSを無効化する仕組みではなく、認証情報そのものをJavaScriptから直接取得しにくくする仕組みだと考える方が正確だ。

その代わり、クッキーを自動送信するブラウザの性質に対して、CSRF対策が必要になる。クッキー認証は、クライアント側でトークンを管理しなくてよい反面、リクエストが本当に自分のSPAから送られたものかをサーバー側で確認しなければならない。

Sanctumのクッキー認証は、認証情報をSPAに持たせるのではなく、Laravelのセッション機構に預ける方式だ。扱いやすさの裏側にあるCSRF対策まで含めて、ひとつの認証設計として考える必要がある。

クッキー認証とAPIトークン認証の違い

二つの方式を、実装上の違いに絞って並べると次のようになる。

項目クッキーベースのSPA認証APIトークン認証
認証状態Laravelのセッションで管理Sanctumのパーソナルアクセストークンで管理
送信方法ブラウザがセッションクッキーを自動送信Authorization: Bearerヘッダーで送信
JavaScriptからの参照セッション用クッキーをHttpOnlyにできる保存場所によってはJavaScriptから読み取れる
CSRF対策必要クッキー自動送信を使わない構成なら基本的に同じ形では不要
向いているクライアント自社管理のブラウザSPAモバイルアプリ、外部連携、機械間通信
主な設定セッション、CORS、statefulドメイントークン発行、能力、失効、保存方法
失効単位セッション単位トークン単位で管理しやすい

どちらを選んでも、認証に成功した後の認可は別の問題だ。ログイン済みかどうかと、そのユーザーが特定の操作を実行できるかどうかは分けて考える必要がある。APIトークンならabilitiesを使った権限制御が見えやすいが、クッキー認証でもLaravelのGateやPolicyで認可を組み立てられる。

クッキー認証の仕組みとCSRF保護

SanctumのSPA認証では、ログイン前に/sanctum/csrf-cookieへアクセスする流れがよく使われる。このエンドポイントの役割は、ログインや状態変更リクエストに使うCSRFトークンをクッキーとして取得することだ。

典型的な流れは次のようになる。

1. SPAが/sanctum/csrf-cookieへGETリクエストを送る。

2. LaravelがCSRFトークン用のXSRF-TOKENクッキーを返す。

3. SPAがログインエンドポイントへPOSTする。

4. リクエストにCSRFトークンを示すヘッダーを付ける。

5. ログイン成功後、Laravelがセッション用クッキーを返す。

6. 以後のリクエストでブラウザがセッションクッキーを送信する。

XSRF-TOKENは、フロントエンドが値を読み取ってリクエストヘッダーに反映できるように扱われる。一方、実際のログイン状態を保持するセッション用クッキーは、JavaScriptから読み取らせない設計にするのが一般的だ。この二つのクッキーを同じものとして扱うと、設定の意味が分からなくなる。

Axiosを使う場合は、Cookieに入っているCSRFトークンをどのヘッダー名で送るかをライブラリ側が認識できるようにする。Laravelの標準的な構成や公式のスターターキットを使えば、この処理の一部が用意されていることもある。ただし、Vue側で独自のAxiosインスタンスを作る場合は、標準設定が引き継がれているとは限らない。

CSRFトークンの不一致では、Laravelから419が返ることが多い。認証に失敗したときの401とは原因が異なるため、ステータスコードを手掛かりに切り分けるとよい。

段階ブラウザ・SPAの動作Laravel側の役割
CSRF準備/sanctum/csrf-cookieへGETCSRFトークン用クッキーを発行
ログインCSRFヘッダー付きでログインAPIへPOSTトークンを検証し、認証処理を実行
認証後セッションクッキー付きでAPIを呼ぶセッションからユーザーを復元
ログアウトログアウトAPIへリクエストセッションを無効化または破棄

セッションの寿命をどう考えるか

クッキー認証の説明でよくある誤解が、「ブラウザを閉じるとセッションが切れる」という断定だ。

Laravelでセッションがいつまで有効かは、config/session.phpの設定とセッションストレージ、ブラウザ側のクッキーの扱いによって決まる。SESSION_LIFETIMEは、基本的にはセッションをアイドル状態のまま保持する時間を分単位で指定する設定だ。ブラウザを閉じた瞬間にセッションを破棄するかどうかは、expire_on_closeの設定に左右される。

expire_on_closeが有効なら、ブラウザ終了時にセッション用クッキーが保持されない形にできる。無効なら、セッションの有効期限内でブラウザを再起動してもクッキーが残る場合がある。ただし、ブラウザのセッション復元機能や管理ポリシーも関係するため、「閉じれば必ずログアウト」「閉じても必ずログイン状態が残る」と一律には言えない。

また、セッションの有効期限が切れることと、ユーザーが明示的にログアウトすることも別だ。機密性の高い管理画面では、アイドルタイムアウト、明示的なログアウト、全端末からのログアウトなどを要件として決めておく必要がある。

サブドメイン構成で混同しやすい「サイト」と「オリジン」

APIがapi.example.com、フロントエンドがapp.example.comという構成は、Sanctumの設定で頻繁に登場する。

この二つはホスト名が異なるため、ブラウザから見れば別オリジンだ。スキーム、ホスト、ポートのいずれかが異なれば、同一オリジンではない。そのため、JavaScriptからAPIを呼び出す場合はCORSの対象になる。

一方、同じHTTPSを使い、同じ親ドメインの下にあるサブドメインであれば、通常はブラウザの「同一サイト」という判定では同じサイトとして扱われる。app.example.comapi.example.comが別オリジンだからといって、直ちに別サイトになるわけではない。ここを混同すると、「サブドメインだからSameSite属性でクッキーが送られない」という誤った診断をしやすい。

ただし、同一サイトであることは、クッキーが自動的に正しく共有されることを意味しない。クッキーには発行元ホスト、DomainPathSecureSameSiteなどの条件がある。API側で発行したセッション用クッキーを別サブドメインへのリクエストでも利用したいなら、アプリケーションのドメイン構成に合わせてセッションのドメイン設定を確認する必要がある。

さらに、ブラウザのCORS制約はオリジン単位で動く。したがって、同一サイトのサブドメイン間であっても、フロントエンドから別ホストのAPIを呼ぶなら、CORSと認証情報の送信設定が必要になる。

要するに、次の三つは別々に確認する。

  • オリジンが同じか。異なればCORSが関係する。
  • サイトが同じか。主にSameSite属性の判断に関係する。
  • クッキーのDomainと送信条件が適切か。セッションをどのホストへ送るかに関係する。

同じ親ドメインだからCORSは不要、ということにはならない。逆に、別オリジンだから必ずクロスサイトクッキーになる、ということでもない。

APIトークン認証が適している場面

クッキー認証がSPAに向いているからといって、APIトークン認証が劣っているわけではない。クライアントの性質が変われば、トークンの方が設計しやすい。

モバイルアプリ

iOSやAndroidのネイティブアプリでは、ブラウザのセッションをそのまま前提にするより、アプリが認証情報を保持してAPIへ送信する方が扱いやすい。Sanctumでは、ユーザーモデルにHasApiTokensを組み込み、ログイン後にパーソナルアクセストークンを発行する構成が取れる。

発行したトークンをどこへ保存するかは、アプリ側の重要な設計になる。OSが提供する安全な保管領域を使い、平文のままログや設定ファイルへ出さないことが前提だ。トークン認証を選んだから安全になるのではなく、保存・更新・失効まで含めて管理できる場合に適している。

外部サービスとの連携

外部パートナーや社内の別システムがAPIを呼び出す場合、ブラウザのセッションとCSRFトークンを前提にすると連携が複雑になる。Bearerトークンなら、呼び出し側が明示的に認証情報を付けられる。

この場合は、トークンに必要な能力だけを与えることが重要だ。読み取り専用の連携に、更新や削除まで可能なトークンを渡す必要はない。発行先を区別し、不要になったトークンを個別に失効できるようにしておくと、漏洩時の対応もしやすい。

Sanctumのトークン有効期限を利用する場合も、期限切れ後の再発行手順まで決めておく必要がある。期限を設定するだけで運用が完成するわけではない。自動更新するのか、管理者が再発行するのか、連携先にどのように通知するのかで、必要な実装は変わる。

ブラウザを自社管理の範囲から外す場合

同じSPAでも、ユーザーが利用するフロントエンドを自社のLaravelアプリケーションと別製品として配布する場合は、単純なSPA認証とは扱いが変わる。複数のフロントエンドを異なる組織が管理し、認証情報の保管責任も分かれるなら、クッキー共有を前提にした構成は慎重に検討した方がよい。

この場合、OAuthなど別の認可基盤が適していることもある。SanctumのAPIトークンは便利だが、複数の外部クライアントを長期的に管理するための認証・認可基盤をすべて代替するものではない。

「SPAならクッキー、モバイルならトークン」と覚えるだけでは足りない。判断すべきなのは、認証情報を誰が保持し、どの範囲のクライアントへ渡し、どの単位で失効させたいかだ。

Laravel Sanctumのstateful設定

SanctumのSPA認証では、リクエスト元をステートフルなフロントエンドとして扱うかどうかが重要になる。Sanctumは、対象ドメインからのリクエストをLaravelのセッション認証と組み合わせて処理する。

この対象ドメインは、config/sanctum.phpstateful設定で定義できる。環境変数を使う構成では、SANCTUM_STATEFUL_DOMAINSにフロントエンドのホスト名を指定する。開発環境でポート番号付きのURLを使うなら、ポートを含めた値が必要になることがある。

本番環境でapp.example.comからアクセスするなら、たとえば次のように対象を整理する。

  • 本番のSPAで使うホスト名
  • 開発時のlocalhostとポート番号
  • ステージング環境のフロントエンド
  • 必要に応じて、別の管理用フロントエンド

wwwの有無やポート番号違いを別のホストとして扱う構成では、設定漏れがそのまま認証失敗になる。環境変数を設定した後は、設定キャッシュを利用している環境で古い値が残っていないかも確認したい。

ただし、statefulドメインに追加しただけでクッキーが共有されるわけではない。セッション用クッキーのDomain設定、HTTPS環境でのSecure属性、フロントエンドから認証情報を送る設定も揃っている必要がある。

CORS設定で確認すべき場所

CORSの設定で最初に確認するのは、config/cors.phpのトップレベルにある項目だ。supports_credentialsは、フロントエンド向けサービスの配列の中に置く設定ではない。標準的なLaravelのCORS設定では、allowed_originsallowed_headersなどと同じトップレベルの設定項目として扱う。

SPAからクッキーを含むリクエストを送るなら、サーバー側で認証情報付きCORSを許可し、クライアント側でも認証情報を送る設定が必要になる。片方だけを有効にしても動かない。

確認する項目は次の通りだ。

  • supports_credentialstrueになっているか。
  • allowed_originsに、実際のSPAのオリジンをスキーム込みで指定しているか。
  • https://app.example.comhttp://app.example.comを同じものとして扱っていないか。
  • 開発用ポートと本番用ホストを取り違えていないか。
  • OPTIONSのプリフライトに必要なパスがCORS対象になっているか。
  • X-XSRF-TOKENなど、実際に送信するヘッダーが許可されているか。
  • 認証情報付きCORSで、allowed_origins*を使っていないか。

認証情報を許可する場合、任意のオリジンを意味するワイルドカードと組み合わせることはできない。許可するフロントエンドを具体的に列挙する必要がある。開発中に一時的に広い設定へ変更し、そのまま本番へ持ち込むのは避けたい。

フロントエンド側では、Axiosなら認証情報を送信する設定を有効にする。Fetchを使う場合は、リクエストごとにcredentials: 'include'を指定する構成が基本になる。これを忘れると、サーバーがクッキーを発行していても、APIリクエストにセッションが付かない。

401、419、CORSエラーを分けて見る

Sanctumのトラブルは、似た症状に見えて原因が異なる。

ログイン後のAPIが401を返すなら、セッション用クッキーが送信されていない、statefulドメインとして扱われていない、セッションを保存したサーバーと参照するサーバーで状態を共有できていない、といった原因が考えられる。

ログインや更新処理が419になるなら、CSRFトークンの取得・送信・照合に問題がある可能性が高い。/sanctum/csrf-cookieを先に呼んでいるか、XSRFヘッダーが付いているか、Cookieの値とヘッダーの値が対応しているかを確認する。

ブラウザのコンソールでCORSエラーが出ている場合は、Laravelの認証処理に到達する前にブラウザがリクエストを止めている可能性がある。APIのログだけを見ていると、原因が見えないことがあるため、Networkパネルでプリフライトと本リクエストの両方を確認するのが早い。

セッションを使うか、トークンを使うか

実装前に、次のような質問へ答えると方式を選びやすい。

  • 利用クライアントは、自社が管理するブラウザSPAだけか。
  • モバイルアプリや外部システムも同じAPIを使うか。
  • フロントエンドとAPIは、同じ組織・同じ運用チームが管理するか。
  • 認証情報をクライアント側で保存する必要があるか。
  • ユーザー単位ではなく、端末や連携先単位でトークンを失効させたいか。
  • サブドメインや別オリジンの構成を、運用チームが継続して管理できるか。
  • ロードバランサーや複数台構成で、セッションの保存先を共有できるか。

自社管理のブラウザSPAで、Laravelとフロントエンドを一体のサービスとして運用するなら、まずクッキー認証を検討するのが自然だ。CSRF対策やCORS設定は必要だが、認証情報をJavaScriptで直接保持しない設計にしやすい。

モバイルアプリや外部システムが中心なら、トークン認証の方が境界を明確にしやすい。トークンをクライアント単位で発行し、能力を絞り、不要になったら失効させるという運用が組み立てやすいからだ。

管理画面だけを別のサブドメインに分ける場合は、最初からトークンに逃げる必要はない。SANCTUM_STATEFUL_DOMAINS、セッションのDomain、CORS、HTTPS、クライアント側の認証情報送信を正しく揃えれば、クッキー認証を継続できる構成も多い。

ただし、設定を合わせること自体が大きな負担になるなら、認証方式を分ける判断もあり得る。ユーザー向けSPAはクッキー、外部連携や特定の管理クライアントはトークンという構成だ。重要なのは、同じユーザーの権限を複数の認証方式で無制限に複製しないこと。方式を混在させるなら、どのクライアントがどのGuardを使い、どの権限を持つのかを明文化しておく必要がある。

実装より先に決めておきたい運用の境界

クッキー認証を選ぶ場合、コードだけでなくセッション運用を決めておく。SESSION_LIFETIMEexpire_on_close、ログアウト時の処理、パスワード変更時の既存セッションの扱いは、要件として確認しておきたい。

複数端末のログイン管理も同じだ。SanctumのSPA認証を使ったからといって、ユーザーの全ログイン端末を一覧表示したり、端末ごとに強制ログアウトしたりできるわけではない。必要なら、セッション管理の仕組みを別途設計する。APIトークン認証ではトークン単位の失効を扱いやすいが、それも発行・命名・保管・削除の運用があって初めて機能する。

また、複数台のアプリケーションサーバーで運用する場合は、セッションの保存先にも注意が必要だ。ローカルファイルセッションを使ったままサーバーを分散させると、ログインしたリクエストと次のAPIリクエストが異なるサーバーへ振り分けられたとき、セッションを参照できないことがある。Redisやデータベースなど、構成に合った共有ストレージを選ぶ必要がある。

結局、認証方式の選択は「クッキーの方が安全」「トークンの方が柔軟」という一言では終わらない。クッキー認証は、ブラウザとLaravelのセッション機構を自然につなげられる。その代わり、CSRF、CORS、statefulドメイン、クッキー属性を正確に揃えなければならない。

APIトークン認証は、モバイルや外部連携のように、クライアントが認証情報を明示的に扱う場面で力を発揮する。その代わり、トークンの保存場所、漏洩時の影響、期限、能力、失効手順をアプリケーション側で引き受けることになる。

Laravel SanctumのSPA認証でクッキーを選ぶべきか迷ったら、まずフロントエンドが自社管理のブラウザアプリかどうかを見る。そのうえで、同一サイトかどうかではなく、オリジン、クッキーの送信範囲、stateful設定、CORSの関係を分けて確認する。別サブドメインだから即トークン、という判断も、SPAだからクッキー一択、という判断も早い。

現場で安定するのは、方式を先に決めて要件を合わせることではない。クライアントの種類、ドメイン構成、権限の境界、失効の単位を整理し、その結果としてクッキーとトークンを使い分ける設計だ。Sanctumはその選択肢を一つに固定する仕組みではなく、Laravelのセッション認証とAPIトークン認証を、用途に応じて組み合わせるための道具として見るのがよい。

Related reading: Laravel SanctumとPassport:API認証における選定基準の最適解 and Docker環境のLaravelとSPA連携で発生したCORSエラーの解決手順.

よくある質問

Laravel SanctumでSPAにクッキー認証が推奨される理由は?
ブラウザ上で動作する自社フロントエンドとLaravel APIの間であれば、Laravel標準のセッション認証を利用できるためです。また、HttpOnly属性を設定することで、JavaScriptから認証情報を直接読み取られるリスクを低減できます。
クッキー認証でCSRF対策が必要なのはなぜですか?
ブラウザがクッキーを自動送信する性質上、リクエストが正当なSPAから送られたものかを確認する必要があるためです。ログイン前に/sanctum/csrf-cookieへアクセスし、CSRFトークンを取得・送信する仕組みが用いられます。
サブドメイン構成でAPIが401エラーになる原因は何ですか?
statefulドメインの設定漏れ、セッション用クッキーのドメイン設定不備、またはCORS設定で認証情報付きリクエストが許可されていない可能性が高いです。ブラウザのNetworkパネルでプリフライトリクエストと本リクエストを確認してください。
APIトークン認証はどのような場面に適していますか?
モバイルアプリや外部システムとの連携など、ブラウザのセッション機構を前提としないクライアントに適しています。トークン単位で発行や失効を管理できるため、境界を明確にしたい場合に有効です。
ログイン後のAPIで419エラーが出るのはなぜですか?
主にCSRFトークンの取得、送信、または照合に問題がある場合に発生します。/sanctum/csrf-cookieを事前に呼び出しているか、XSRFヘッダーが正しく付与されているかを確認してください。

参考情報