Laravel CORSの設定を理解する:フロントとAPIを分離するときの落とし穴

概要

SPA(React / Vue など)とLaravel APIを別オリジンで運用すると、ブラウザのCORS(Cross-Origin Resource Sharing)に必ずぶつかります。「ローカルでは動くのに本番で blocked by CORS policy が出る」「認証付きリクエストだけ落ちる」「プリフライトが419になる」——これらは設定の一部だけを直して残りを見落とすと再発します。

本記事では、Laravelの config/cors.php を軸に、CORSが実際にどう動くのか、そしてフロントとAPIを分離するときに踏みやすい落とし穴を、原因と確認方法つきで整理します。

環境

  • PHP 8.2
  • Laravel 10 / 11(CORS処理は Laravel 9.2 以降フレームワーク本体の Illuminate\Http\Middleware\HandleCors に統合済み)
  • フロント: 別オリジンで動作するSPA(例: https://app.example.com)
  • API: https://api.example.com
  • 設定ファイル: config/cors.php

発生した問題

ブラウザのコンソールに次のようなエラーが出て、APIリクエストが失敗します。

Access to fetch at 'https://api.example.com/api/user'
from origin 'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

典型的なパターンは以下です。

  1. GET は通るのに POST / PUT / DELETE が通らない
  2. 認証情報(Cookie)を送るリクエストだけ失敗する
  3. プリフライト(OPTIONS)が 419 や 404 になる
  4. ローカルは通るが本番だけ失敗する

原因

CORSはサーバではなくブラウザが強制する

CORSは「サーバが弾いている」わけではありません。サーバは普通にレスポンスを返しているが、ブラウザが Access-Control-Allow-Origin ヘッダを見て、条件を満たさなければJSからのレスポンス参照を遮断しているのです。curlで叩くと成功するのにブラウザだと失敗するのはこのためです。

プリフライト(preflight)の存在

Content-Type: application/json を伴う POST や、カスタムヘッダ(Authorization など)を含むリクエストは「単純リクエスト」ではないため、ブラウザは本番リクエストの前に OPTIONS メソッドでプリフライトを送ります。GET は通るのに POST だけ落ちるのは、多くの場合このプリフライトが正しく処理されていないからです。

paths の指定漏れ

// config/cors.php
'paths' => ['api/*'],

この設定だと /api/user にはCORSヘッダが付きますが、/login や /sanctum/csrf-cookie には付きません。Laravel Sanctumでクッキー認証を使う場合、sanctum/csrf-cookie にヘッダが付かず認証が通らない、という落とし穴があります。

認証付きリクエストと supports_credentials

Cookieを伴うリクエスト(credentials: 'include')では制約がさらに厳しくなります。

  • サーバは Access-Control-Allow-Credentials: true を返す必要がある
  • かつ Access-Control-Allow-Origin に *(ワイルドカード)を使えない。具体的なオリジンを返す必要がある

「認証付きだけ落ちる」ケースの多くは、allowed_origins を ['*'] にしたまま supports_credentials => true にしている矛盾が原因です。

対応方法

1. config/cors.php を正しく設定する

<?php

return [
    'paths' => ['api/*', 'sanctum/csrf-cookie', 'login', 'logout'],
    'allowed_methods' => ['*'],
    'allowed_origins' => [
        'https://app.example.com', // '*' は認証時NG
    ],
    'allowed_origins_patterns' => [
        // '#^https://.*\.example\.com$#',
    ],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 3600,
    'supports_credentials' => true,
];

2. 環境ごとにオリジンを切り替える

本番だけ失敗する原因の大半は、開発用のオリジン(http://localhost:3000)をハードコードしていることです。環境変数で切り替えます。

'allowed_origins' => array_filter(
    explode(',', env('CORS_ALLOWED_ORIGINS', ''))
),
# .env(本番)
CORS_ALLOWED_ORIGINS=https://app.example.com
# .env.local(開発)
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173

設定を変えたら必ず設定キャッシュを再生成します。

php artisan config:clear
php artisan config:cache

本番で「直したはずなのに直らない」原因の常連が、config:cache の効いた古い設定です。

3. プリフライトが419になるとき

OPTIONS リクエストにCSRF検証がかかると 419 Page Expired になります。HandleCors はミドルウェアスタックの十分早い段階で処理される必要があり、通常はグローバルミドルウェアとして登録されています。Laravel 11なら bootstrap/app.php、Laravel 10以前なら app/Http/Kernel.php の $middleware を確認します。正しく登録されていれば、HandleCors が OPTIONS を検知してCSRF等の後続処理を待たずに空レスポンス+ヘッダを返します。

4. フロント側の設定を合わせる

サーバで supports_credentials => true にしても、フロントが credentials を送らなければCookieは飛びません。

// fetch
await fetch('https://api.example.com/api/user', { credentials: 'include' });

// axios
axios.defaults.withCredentials = true;

実装例

<?php
// config/cors.php

$origins = array_filter(array_map('trim',
    explode(',', env('CORS_ALLOWED_ORIGINS', ''))
));

return [
    'paths' => ['api/*', 'sanctum/csrf-cookie', 'broadcasting/auth'],
    'allowed_methods' => ['*'],
    'allowed_origins' => $origins,
    'allowed_origins_patterns' => array_filter(array_map('trim',
        explode(',', env('CORS_ALLOWED_ORIGINS_PATTERNS', ''))
    )),
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 3600,
    'supports_credentials' => env('CORS_SUPPORTS_CREDENTIALS', false),
];

確認方法

プリフライトを直接叩く

curl -i -X OPTIONS 'https://api.example.com/api/user' \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: content-type, authorization'

期待するレスポンスヘッダ:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
  • Access-Control-Allow-Origin が * になっていないか(認証時はNG)
  • リクエストした Origin がそのまま返っているか
  • Access-Control-Allow-Credentials: true が付いているか

ブラウザのNetworkタブで確認する

該当リクエストの直前に OPTIONS リクエストが記録されているかを見ます。プリフライトが失敗していれば、本番リクエストは送信すらされません。

注意点

  • Access-Control-Allow-Origin: * と supports_credentials => true は共存できない。 これは仕様であり、Laravelの問題ではありません。
  • CORSは認可の仕組みではない。CORSを許可しても、APIの認証・認可は別途必要です(curlは素通りします)。
  • 開発用の localhost を本番の allowed_origins にそのまま残さない。
  • ヘッダを二重に付けない。Webサーバ(Nginx / Apache)側でもCORSヘッダを付けていると、Access-Control-Allow-Origin が2つ返り、かえってブラウザが弾きます。付ける場所はどちらか一方に統一します。
  • 設定変更後は必ず config:clear → config:cache。

まとめ

Laravel CORSの落とし穴は、ほぼ次の5点に集約されます。

  1. paths にCORSを効かせたいパス(Sanctumのcsrf-cookie含む)を漏らしている
  2. 認証付きなのに allowed_origins が * のまま(supports_credentials と矛盾)
  3. プリフライト(OPTIONS)の存在を意識していない
  4. 本番で config:cache が古い設定を握っている
  5. WebサーバとLaravelでCORSヘッダを二重付与している

CORSは「ブラウザが強制する」という前提さえ押さえれば、curlでプリフライトを再現して切り分けられます。動かないときはまず OPTIONS を直接叩いて、返ってくるヘッダを目で確認するのが最短ルートです。

\ 最新情報をチェック /

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください。