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.
典型的なパターンは以下です。
GETは通るのにPOST/PUT/DELETEが通らない- 認証情報(Cookie)を送るリクエストだけ失敗する
- プリフライト(
OPTIONS)が419や404になる - ローカルは通るが本番だけ失敗する
原因
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点に集約されます。
pathsにCORSを効かせたいパス(Sanctumのcsrf-cookie含む)を漏らしている- 認証付きなのに
allowed_originsが*のまま(supports_credentialsと矛盾) - プリフライト(
OPTIONS)の存在を意識していない - 本番で
config:cacheが古い設定を握っている - WebサーバとLaravelでCORSヘッダを二重付与している
CORSは「ブラウザが強制する」という前提さえ押さえれば、curlでプリフライトを再現して切り分けられます。動かないときはまず OPTIONS を直接叩いて、返ってくるヘッダを目で確認するのが最短ルートです。
