【完全版】CORSエラーの原因と解決方法まとめ|nginx・Express・Laravel別の設定と落とし穴を徹底解説

【完全版】CORSエラーの原因と解決方法まとめ|nginx・Express・Laravel別の設定と落とし穴を徹底解説

フロントエンドからAPIを叩いた瞬間にコンソールに現れる、あの赤いエラー。

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

CORSエラーは、Web開発でほぼ確実に一度は遭遇する壁です。しかし:

  • Access-Control-Allow-Origin: * を設定したのに直らない
  • nginxで設定したのにLaravelからもヘッダーが出てヘッダーが二重になってしまう
  • credentials: true にしたら別のエラーが出てきた
  • プリフライト(OPTIONS)リクエストだけ失敗する
  • ローカルでは動くのに本番だけ出る
  • 実はCORSエラーではなく500エラーやdump出力が原因だったのにCORSエラーに見えていた

など、設定したつもりなのに解決しない・新しいエラーが出るというケースが後を絶ちません。

本記事では、CORSエラーの仕組みから実務での落とし穴までを完全網羅しました。CORSの基本構造・プリフライトリクエスト・エラーメッセージの読み方から、nginx・Express・Laravel別の正しい設定パターン、credentialsとワイルドカードの禁止ルール、ヘッダー二重付与問題、Laravel特有の誤検知パターン、Docker・本番環境での注意点、デバッグ手順、FAQ まで。この1本をブックマークすれば、どんなCORSエラーにも対処できるようになります。


目次

結論:今すぐ確認すべきチェックリスト

時間がない方向けに、CORSエラー発生時に最初に確認すべきことを先に示します。

□ 1. 本当にCORSエラー? → curlで同じURLを叩いてみる(CORSはブラウザのみの制約)
□ 2. ブラウザのネットワークタブでレスポンスのステータスコードを確認する
□ 3. 500エラー・400エラーが出ていないか(CORSに見えて実はサーバーエラーのケースが多い)
□ 4. OPTIONSメソッド(プリフライト)のリクエストが成功しているか
□ 5. Access-Control-Allow-Origin ヘッダーがレスポンスに含まれているか
□ 6. credentials: true を使っているなら * は使えない(オリジンを明示する必要がある)
□ 7. nginx と アプリ(Laravel/Express)の両方でヘッダーを設定していないか(二重付与)
□ 8. Laravelのキャッシュをクリアしたか(php artisan optimize:clear)

CORSの仕組み(なぜエラーになるのか)

オリジンとは

オリジンは「スキーム + ホスト名 + ポート」の組み合わせです。この3つが1つでも違えば「異なるオリジン(クロスオリジン)」になります。

https://app.example.com:443   ← オリジンA
https://api.example.com:443   ← オリジンB(ホスト名が違う → 異なるオリジン)
http://app.example.com:443    ← オリジンC(スキームが違う → 異なるオリジン)
https://app.example.com:3000  ← オリジンD(ポートが違う → 異なるオリジン)

ブラウザがCORSをブロックする仕組み

ブラウザ(https://app.example.com)
   │
   │ fetch('https://api.example.com/users')  ← クロスオリジンリクエスト
   ▼
リクエスト送信
   │
   ▼
レスポンス受信
   │
   ▼
ブラウザがレスポンスヘッダーを確認
   │
   ├── Access-Control-Allow-Origin: https://app.example.com  → ✅ 許可、JavaScriptがレスポンスを受け取れる
   └── ヘッダーなし or 別オリジン → ❌ CORSエラー、JavaScriptにレスポンスを渡さない

⚠️ 重要なのは、リクエスト自体はサーバーに届いており、サーバーはレスポンスを返しているという点です。CORSはブラウザがレスポンスをJavaScriptに渡すかどうかを判定する仕組みであり、サーバー側の処理とは無関係です。つまりcurlでリクエストを投げるとエラーにならないのはこのためです。

プリフライトリクエスト(OPTIONSメソッド)

「単純リクエスト」以外(POSTにJSON本文を含む、カスタムヘッダーを付ける等)の場合、ブラウザは本リクエストの前に**OPTIONSメソッドで事前確認(プリフライト)**を送ります。

ブラウザ
   │
   │ 1. OPTIONSリクエスト(プリフライト)
   │    Origin: https://app.example.com
   │    Access-Control-Request-Method: POST
   │    Access-Control-Request-Headers: Content-Type, Authorization
   ▼
サーバー
   │
   │ 2. OPTIONSへのレスポンス
   │    Access-Control-Allow-Origin: https://app.example.com
   │    Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
   │    Access-Control-Allow-Headers: Content-Type, Authorization
   │    Access-Control-Max-Age: 86400
   ▼
ブラウザ(許可を確認)
   │
   │ 3. 本リクエスト(POST)を送信
   ▼
サーバー → レスポンス(CORS ヘッダー付き)

プリフライトが失敗するとその後の本リクエストは送られません。ネットワークタブでOPTIONSリクエストが200/204以外になっていないか確認するのが重要な切り分けポイントです。

単純リクエスト(プリフライトが発生しない条件)

以下を全て満たす場合のみプリフライトが省略されます。

条件詳細
メソッドGET / HEAD / POST のいずれか
Content-Typeapplication/x-www-form-urlencoded / multipart/form-data / text/plain のいずれか
カスタムヘッダーなしAuthorizationなどの独自ヘッダーがない

application/json でPOSTする時点でプリフライトが発生します。モダンなAPIでは事実上ほぼ全てプリフライトが発生すると思っておいてよいでしょう。


エラーメッセージの読み方

CORSエラーのメッセージは長いですが、パターンを覚えると原因が一目でわかります。

パターン1:Access-Control-Allow-Originヘッダーがない

No 'Access-Control-Allow-Origin' header is present on the requested resource.

→ サーバーがCORSヘッダーを返していない。設定が効いていない、またはサーバーエラー(500等)でヘッダーを付ける前に終了している。

パターン2:プリフライトが通らない

Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

→ OPTIONSリクエストに対してサーバーが正しいCORSヘッダーを返していない。OPTIONS専用のルートが必要な場合がある。

パターン3:オリジンが一致しない

The 'Access-Control-Allow-Origin' header has a value 'https://other.com'
that is not equal to the supplied origin.

→ レスポンスの Access-Control-Allow-Origin に実際のオリジンと異なる値が設定されている。

パターン4:credentialsとワイルドカードの競合

The value of the 'Access-Control-Allow-Origin' header in the response must not
be the wildcard '*' when the request's credentials mode is 'include'.

credentials: 'include'(Cookie送信)と Access-Control-Allow-Origin: *絶対に組み合わせられない。オリジンを明示する必要がある。

パターン5:許可されていないヘッダー

Request header field Authorization is not allowed by Access-Control-Allow-Headers
in preflight response.

Access-Control-Allow-HeadersAuthorization が含まれていない。


まず確認:本当にCORSエラー?

CORSエラーに見えて実はCORS以外が原因というケースが非常に多いのが実情です。設定を変更する前にまずこの切り分けを行います。

curlで直接確認する

# CORSはブラウザ専用の制約なのでcurlでは通る
curl -i -X POST https://api.example.com/users \
    -H "Content-Type: application/json" \
    -d '{"name": "test"}'

# プリフライトをcurlで再現して確認
curl -i -X OPTIONS https://api.example.com/users \
    -H "Origin: https://app.example.com" \
    -H "Access-Control-Request-Method: POST" \
    -H "Access-Control-Request-Headers: Content-Type, Authorization"

curlでエラーが出る場合はCORS以前にサーバー側の問題(500エラー、ルーティングミス等)があります。

ブラウザのネットワークタブを確認する

DevTools → Network タブ → 該当リクエストを選択
→ Status(ステータスコード)を確認

500 → サーバーエラー(CORSヘッダーを付ける前に異常終了している)
404 → URLが間違っている
401/403 → 認証エラー(CORSヘッダーが付かない場合も)
200 → レスポンスは返っているが、Headersタブでヘッダーを確認

⚠️ 500エラーが発生するとCORSヘッダーがレスポンスに付かず、ブラウザはCORSエラーとして報告します。本当の原因はサーバーエラーなのに、CORSエラーに見えてしまうため、まず500エラーを潰すことが先決です。


nginx での CORS 設定

基本設定

server {
    listen 80;
    server_name api.example.com;

    location / {
        # プリフライト(OPTIONSメソッド)への応答
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;
            add_header 'Access-Control-Max-Age' 86400 always;
            add_header 'Content-Length' 0;
            return 204;
        }

        # 本リクエストへのCORSヘッダー付与
        add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, X-Requested-With' always;

        proxy_pass http://localhost:3000;
    }
}

複数オリジンを許可する

Access-Control-Allow-Origin には1つのオリジンしか指定できないため、動的に切り替える設定が必要です。

# map を使って許可オリジンを動的に設定
map $http_origin $cors_origin {
    default "";
    "https://app.example.com"    "https://app.example.com";
    "https://admin.example.com"  "https://admin.example.com";
    "https://staging.example.com" "https://staging.example.com";
}

server {
    listen 80;

    location / {
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $cors_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
            add_header 'Access-Control-Max-Age' 86400;
            return 204;
        }

        add_header 'Access-Control-Allow-Origin' $cors_origin always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, PATCH, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;

        proxy_pass http://backend;
    }
}

credentials(Cookie)を使う場合

add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;  # * は使えない
add_header 'Access-Control-Allow-Credentials' 'true' always;

always を付け忘れないこと

# ❌ always がないと 200 以外(エラーレスポンス等)にはヘッダーが付かない
add_header 'Access-Control-Allow-Origin' '*';

# ✅ always を付けるとステータスコードに関わらず常にヘッダーが付く
add_header 'Access-Control-Allow-Origin' '*' always;

nginx の落とし穴:if ブロックは危険

# ⚠️ nginx の if ブロックは「邪悪」と呼ばれるほど挙動が複雑
# 特に if の中の add_header は他の add_header を上書きする
# 可能な限り map + location ベースの設定を使う方が安全

Express での CORS 設定

cors パッケージを使う(推奨)

npm install cors
const express = require('express');
const cors = require('cors');
const app = express();

// 全オリジン許可(開発環境のみ推奨)
app.use(cors());

// オリジンを限定する(本番推奨)
app.use(cors({
    origin: 'https://app.example.com',
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    credentials: true,        // Cookie送信を許可
    maxAge: 86400,            // プリフライト結果のキャッシュ秒数
}));

複数オリジンを許可する

const allowedOrigins = [
    'https://app.example.com',
    'https://admin.example.com',
    'http://localhost:3000',   // 開発環境
];

app.use(cors({
    origin: function (origin, callback) {
        // originがundefined = curlなどブラウザ以外からのリクエスト → 許可
        if (!origin) return callback(null, true);

        if (allowedOrigins.includes(origin)) {
            callback(null, true);
        } else {
            callback(new Error(`CORS policy: origin ${origin} is not allowed`));
        }
    },
    credentials: true,
}));

プリフライト(OPTIONSメソッド)に明示的に応答する

// cors() ミドルウェアは通常OPTIONSも自動処理するが、
// ルーターより前に配置する必要がある点に注意

// ❌ ルーターより後に配置するとOPTIONSが処理される前にルーターに届いてしまう
app.use('/api', apiRouter);
app.use(cors());  // 遅い

// ✅ 必ずルーターより前に配置
app.use(cors());
app.use('/api', apiRouter);

// OPTIONS を明示的にハンドルする場合
app.options('*', cors());  // 全ルートのOPTIONSに応答

cors パッケージを使わず手動で設定する場合

app.use((req, res, next) => {
    const allowedOrigins = ['https://app.example.com', 'http://localhost:3000'];
    const origin = req.headers.origin;

    if (allowedOrigins.includes(origin)) {
        res.setHeader('Access-Control-Allow-Origin', origin);
    }

    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    res.setHeader('Access-Control-Allow-Credentials', 'true');

    // プリフライトはここで終わらせる
    if (req.method === 'OPTIONS') {
        return res.status(204).end();
    }

    next();
});

TypeScript(Express + ts)での設定

import express from 'express';
import cors from 'cors';

const app = express();

const corsOptions: cors.CorsOptions = {
    origin: process.env.ALLOWED_ORIGIN || 'http://localhost:3000',
    methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
    allowedHeaders: ['Content-Type', 'Authorization'],
    credentials: true,
};

app.use(cors(corsOptions));
app.options('*', cors(corsOptions));  // プリフライトも明示的に許可

Laravel での CORS 設定

Laravel 7 以降(標準搭載)

Laravel 7以降はCORSサポートが標準搭載されており、追加パッケージは不要です。

# config/cors.php が存在するか確認(なければ公開)
php artisan vendor:publish --tag=laravel-cors
// config/cors.php
return [
    // CORSを適用するパスのパターン
    'paths' => ['api/*', 'sanctum/csrf-cookie'],

    // 許可するHTTPメソッド
    'allowed_methods' => ['*'],

    // 許可するオリジン(本番では明示的に指定する)
    'allowed_origins' => ['https://app.example.com'],

    // 正規表現によるオリジン指定(allowed_originsより優先度低)
    'allowed_origins_patterns' => [],

    // 許可するリクエストヘッダー
    'allowed_headers' => ['*'],

    // クライアントに公開するレスポンスヘッダー
    'exposed_headers' => [],

    // プリフライト結果のキャッシュ秒数
    'max_age' => 0,

    // Cookie・認証情報の送信を許可するか
    'supports_credentials' => false,
];

ミドルウェアとして登録されているか確認

// app/Http/Kernel.php
protected $middleware = [
    // ...
    \Illuminate\Http\Middleware\HandleCors::class,  // ← これが必要
    // ...
];

⚠️ このミドルウェアはグローバルミドルウェアとして登録する必要があります。$middlewareGroups['api'] 内だけに入れていると、ルートグループの外のリクエスト(プリフライト等)に適用されないことがあります。

設定変更後は必ずキャッシュクリア

# 設定キャッシュをクリア(最頻出の「設定したのに効かない」原因)
php artisan optimize:clear
# または
php artisan config:clear
php artisan cache:clear

複数オリジンを許可する

// config/cors.php
'allowed_origins' => [
    'https://app.example.com',
    'https://admin.example.com',
],

// 開発環境と本番環境で分ける場合(.env経由)
'allowed_origins' => array_filter([
    env('FRONTEND_URL'),
    env('ADMIN_URL'),
]),

credentials(Cookie/Sanctum)を使う場合

// config/cors.php
'supports_credentials' => true,
'allowed_origins' => ['https://app.example.com'],  // * は絶対に使えない
// config/sanctum.php
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
    '%s%s',
    'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
    Sanctum::currentApplicationUrlWithPort()
))),
# .env
SANCTUM_STATEFUL_DOMAINS=app.example.com
SESSION_DOMAIN=.example.com
FRONTEND_URL=https://app.example.com

paths の設定ミスに注意

// ❌ api/* だけだと sanctum/csrf-cookie に適用されない
'paths' => ['api/*'],

// ✅ Sanctumを使う場合は明示的に追加
'paths' => ['api/*', 'sanctum/csrf-cookie'],

Laravel特有の落とし穴:dump/dd がヘッダーを破壊する

最も見落とされがちな原因の1つです。

// ❌ コントローラーに dump や dd が残っていると HTML が先に出力され
// その後に CORS ヘッダーを付けようとしても "headers already sent" になる
public function users() {
    $users = User::all();
    dd($users);  // ← これがCORSエラーの原因になる
    return response()->json($users);
}
# curlでHTTPヘッダーを確認する
curl -i https://api.example.com/api/users

# Content-Type: text/html が返っていたら dump/dd 等の出力が原因の可能性
# Content-Type: application/json が正しい
// ✅ デバッグはLogクラスを使う
\Log::debug($users);
return response()->json($users);

response() を使わない return も危険

// ❌ response() を使わないと CORS ヘッダーが付かないことがある
return 'Hello World';
return ['key' => 'value'];

// ✅ 必ず response() または json() を使う
return response()->json(['key' => 'value']);
return response('Hello World')->header('Content-Type', 'text/plain');

URLの末尾スラッシュ問題

// ❌ 末尾スラッシュがあるとリダイレクトが発生しCORSヘッダーが失われる
fetch('https://api.example.com/api/users/')

// ✅ 末尾スラッシュなし
fetch('https://api.example.com/api/users')

nginx + Laravel 構成での注意点

nginx と Laravel の両方でCORSヘッダーを設定するとヘッダーが二重になります。これはCORSエラーの原因になります。

# ヘッダーが二重になった場合のエラー
The 'Access-Control-Allow-Origin' header contains multiple values
'https://app.example.com, https://app.example.com', but only one is allowed.

推奨:責任レイヤーを1箇所に絞る

構成A: Laravelのみで設定する(nginx はCORSヘッダーを付与しない)
  → アプリレベルでの制御が細かくできる、推奨

構成B: nginxのみで設定する(Laravel の HandleCors ミドルウェアを無効化)
  → 静的ファイルなども含めて一元管理できる

❌ 両方で設定する → ヘッダーが二重になる

nginx側でヘッダーを除去してLaravelに任せる

# nginx側でヘッダーを付けず、Laravelのみに任せる場合
location /api/ {
    proxy_pass http://php-fpm;
    # add_header は書かない
}

nginx側で全て管理してLaravelを無効化する

# nginx側で設定する場合
location /api/ {
    if ($request_method = 'OPTIONS') {
        add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
        add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
        add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization' always;
        return 204;
    }
    add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
    proxy_pass http://php-fpm;
}
// LaravelのHandleCorsを無効化(Kernel.phpから削除)
protected $middleware = [
    // \Illuminate\Http\Middleware\HandleCors::class,  ← コメントアウト
];

credentials(Cookie・認証情報)を使う場合のルール

CookieやAuthorizationヘッダーを使った認証でcredentials: 'include'を指定する場合、通常の設定とは異なる制約があります。

クライアント側(フロントエンド)

// fetch
fetch('https://api.example.com/api/user', {
    method: 'GET',
    credentials: 'include',  // Cookieを送信する
    headers: {
        'Content-Type': 'application/json',
    },
});

// axios
axios.defaults.withCredentials = true;

// または個別リクエストで
axios.get('https://api.example.com/api/user', {
    withCredentials: true,
});

サーバー側に必要なヘッダー(全ての設定に共通)

Access-Control-Allow-Origin: https://app.example.com   ← * は絶対に使えない
Access-Control-Allow-Credentials: true
// Laravel cors.php
'supports_credentials' => true,
'allowed_origins' => ['https://app.example.com'],  // ← * は絶対に使えない
// Express
app.use(cors({
    origin: 'https://app.example.com',  // ← * は絶対に使えない
    credentials: true,
}));
# nginx
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;  # * は使えない
add_header 'Access-Control-Allow-Credentials' 'true' always;

⚠️ credentials: true* の組み合わせはブラウザが拒否します。これは仕様です。認証情報を含むリクエストにワイルドカードでアクセス許可を出すと悪意のあるサイトからCookieが読み取れてしまうためです。


Docker・本番環境での注意点

Docker Compose での環境変数の使い方

# docker-compose.yml
services:
  api:
    environment:
      - FRONTEND_URL=https://app.example.com
      - SANCTUM_STATEFUL_DOMAINS=app.example.com

  nginx:
    volumes:
      - ./nginx/default.conf:/etc/nginx/conf.d/default.conf

本番環境で*を使ってはいけない理由

// ❌ 開発環境のまま本番に持ち込む最悪パターン
'allowed_origins' => ['*'],
'supports_credentials' => true,  // この組み合わせはそもそも動かない

// ❌ credentialsなしでもAPIキーを要求しないエンドポイントに * は危険
'allowed_origins' => ['*'],  // 任意のWebサイトからAPIを叩ける状態
// ✅ 本番では必ずオリジンを明示
'allowed_origins' => [env('FRONTEND_URL', 'https://app.example.com')],

HTTPSとHTTPの混在(Mixed Content)

# ローカル開発でありがちな問題
フロントエンド: https://app.example.com(HTTPS)
API: http://api.local(HTTP)

→ ブラウザがMixed Contentとしてブロック、CORSエラーと混同しやすい
# 対策
- ローカルでもHTTPSを使う(mkcert等でローカル証明書を発行)
- 開発環境では全てHTTPで統一する

トラブルシューティング

設定したのに全く効かない

# 1. Laravelのキャッシュをクリア(最頻出の原因)
php artisan optimize:clear

# 2. curlでプリフライトを再現して確認
curl -i -X OPTIONS https://api.example.com/api/users \
    -H "Origin: https://app.example.com" \
    -H "Access-Control-Request-Method: POST" \
    -H "Access-Control-Request-Headers: Content-Type, Authorization"

# 3. レスポンスヘッダーを確認
# Access-Control-Allow-Origin が含まれているか

ローカルでは動くのに本番だけ出る

確認ポイント:
□ 本番のAllowed Originsに本番のフロントエンドURLが含まれているか
□ 本番のnginx設定が更新・リロードされているか(sudo nginx -s reload)
□ CDN(CloudFront等)がCORSヘッダーをキャッシュしていないか
□ HTTPSとHTTPが混在していないか

OPTIONSだけが404になる

# nginxがOPTIONSメソッドを受け付けていない場合
# → limit_except でOPTIONSが除外されていないか確認
location /api/ {
    # ❌
    limit_except GET POST { deny all; }

    # ✅ OPTIONSを含める
    limit_except GET POST OPTIONS { deny all; }
}
// Laravelでルーティングが GET/POST のみで OPTIONS に未対応の場合
// HandleCors ミドルウェアがグローバルに登録されていれば自動処理されるはず
// → Kernel.php の $middleware を確認

ヘッダーが二重になっている

# curlで重複確認
curl -i -X OPTIONS https://api.example.com/api/users \
    -H "Origin: https://app.example.com" | grep -i "access-control"

# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Origin: https://app.example.com  ← 2行出ていたら二重

→ nginx と Laravel の両方で設定されている。どちらか一方に絞る。

Sanctum(Laravel)でCOOKIEが送られない

確認チェックリスト:
□ SANCTUM_STATEFUL_DOMAINS にフロントエンドのドメインが含まれているか
□ SESSION_DOMAIN が正しく設定されているか(サブドメイン共有なら .example.com)
□ supports_credentials が true になっているか
□ フロントエンド側で withCredentials: true / credentials: 'include' が設定されているか
□ CSRF初期化(/sanctum/csrf-cookie)が事前に呼ばれているか
□ SameSite=None; Secure がCookieに設定されているか(クロスサイトCookieの場合)

CDN(CloudFront等)がCORSを壊す

CloudFrontがCORSレスポンスをキャッシュする場合、
Originヘッダーをキャッシュキーに含めないとオリジンが異なるリクエストに
間違ったAccess-Control-Allow-Originが返ることがある
対策:
CloudFrontのキャッシュポリシーでOriginヘッダーをキャッシュキーに含める、
または CORSのレスポンスヘッダーポリシーを設定する

デバッグ手順まとめ

Step 1:ブラウザのDevToolsでエラー全文を確認

Console タブ → エラーメッセージの全文をコピー
Network タブ → 該当リクエストのStatusとHeadersを確認

Step 2:curlでCORSを切り離して確認

# サーバーエラーかどうかを切り分け
curl -i https://api.example.com/api/users

# プリフライトを再現
curl -i -X OPTIONS https://api.example.com/api/users \
    -H "Origin: https://app.example.com" \
    -H "Access-Control-Request-Method: POST" \
    -H "Access-Control-Request-Headers: Content-Type, Authorization"

Step 3:レスポンスヘッダーを確認

必要なヘッダーが揃っているか:
✓ Access-Control-Allow-Origin: https://app.example.com
✓ Access-Control-Allow-Methods: POST, GET, ...
✓ Access-Control-Allow-Headers: Content-Type, Authorization, ...
✓ Access-Control-Allow-Credentials: true (credentialsを使う場合)
✓ ヘッダーが1つだけ(二重になっていないか)

Step 4:OPTIONSリクエストを個別確認

# ステータスコードが 200 または 204 であることを確認
curl -o /dev/null -s -w "%{http_code}" -X OPTIONS \
    https://api.example.com/api/users \
    -H "Origin: https://app.example.com"
# → 204 が理想

実用パターン集

フロントエンド(React/Vue/Next.js)での設定例

// axios のグローバル設定
import axios from 'axios';

axios.defaults.baseURL = process.env.NEXT_PUBLIC_API_URL;
axios.defaults.withCredentials = true;
axios.defaults.headers.common['Content-Type'] = 'application/json';
// fetch のラッパー
const apiFetch = (path, options = {}) =>
    fetch(`${process.env.NEXT_PUBLIC_API_URL}${path}`, {
        ...options,
        credentials: 'include',
        headers: {
            'Content-Type': 'application/json',
            ...options.headers,
        },
    });

開発環境でのプロキシ設定(CORSを回避する方法)

CORSエラーを回避する根本的な方法として、フロントエンド開発サーバーにプロキシを設定する方法があります。

// Vite(vite.config.ts)
export default {
    server: {
        proxy: {
            '/api': {
                target: 'http://localhost:8000',
                changeOrigin: true,
            },
        },
    },
};
// Next.js(next.config.js)
module.exports = {
    async rewrites() {
        return [
            {
                source: '/api/:path*',
                destination: 'http://localhost:8000/api/:path*',
            },
        ];
    },
};
// Create React App(package.json)
{
    "proxy": "http://localhost:8000"
}

この方法ではフロントエンドからAPIへのリクエストが同一オリジンからのリクエストとして扱われるため、開発環境でのCORSを完全に回避できます。


よくある質問(FAQ)

Q1. Access-Control-Allow-Origin: * にしたのになぜ直らないのですか?

最頻出の原因は2つです。①Laravelのキャッシュが古い(php artisan optimize:clear)、②500エラーが発生していてヘッダーを付ける前に終了している(curl -iでステータスコードを確認)。

Q2. credentialsを使いたいのに * は使えないと言われます

仕様です。Cookie等の認証情報を含むリクエストではAccess-Control-Allow-Originにワイルドカードは使えず、オリジンを明示する必要があります。複数オリジンを動的に許可するにはmap(nginx)または関数形式のorigin指定(Express/Laravel)を使います。

Q3. curl では通るのにブラウザだけ失敗します

正常です。CORSはブラウザが実施するセキュリティ制約です。curlはブラウザではないためCORSの影響を受けません。ブラウザのDevToolsでネットワークタブとコンソールのエラー全文を確認してください。

Q4. ローカルでは動くのに本番だけ出ます

本番のallowed_originsにフロントエンドの本番URLが含まれているか確認してください。php artisan optimize:clearでキャッシュクリアも必須です。CDN(CloudFront等)を使っている場合はCORSヘッダーのキャッシュ設定を確認してください。

Q5. プリフライト(OPTIONS)が404になります

Laravelの場合、HandleCorsミドルウェアがグローバルミドルウェアとして登録されているか確認します。nginxの場合、limit_exceptでOPTIONSが除外されていないか確認してください。

Q6. nginx と Laravel どちらで設定すべきですか?

どちらか一方だけにするのが鉄則です。両方で設定するとヘッダーが二重になります。Laravelアプリが自身でAPIレスポンスを制御する場合はLaravel側(config/cors.php)で管理するのが推奨です。静的ファイルも含めて一元管理したい場合やLaravel以外のバックエンドも混在する場合はnginxで管理します。

Q7. Sanctumを使っているのにCookieが送られません

withCredentials: trueがクライアント側にあるか、②supports_credentials: trueがLaravel側にあるか、③SANCTUM_STATEFUL_DOMAINSに正しいドメインが設定されているか、④CSRF初期化エンドポイント(/sanctum/csrf-cookie)を先に叩いているか、の4点を順に確認してください。

Q8. 開発環境でCORSを完全に無効化したいです

サーバー側の設定を変えるよりもフロントエンドのプロキシ設定(Vite/Next.js/CRA)を使う方が安全かつ簡単です。本番に近い設定のままCORSだけ解決できます。


参考リンク・関連資料

公式ドキュメント

関連記事(本サイト)


まとめ

CORSエラーは仕組みを正しく理解した上で、設定すべきレイヤーを1箇所に絞ることで確実に解決できます。要点を再整理します。

  • まず切り分け: curlで叩いて500エラーが出ないか確認、ブラウザのネットワークタブでステータスコードを確認
  • CORSはブラウザのみの制約: curlが通るのは正常、ブラウザでだけ失敗する
  • プリフライト(OPTIONS)を忘れずに: GET/POSTだけでなくOPTIONSへの応答も必須
  • credentials: true* は共存不可: 認証情報を使うなら必ずオリジンを明示
  • ヘッダーの二重付与に注意: nginx と Laravel/Express 両方で設定しない
  • Laravelはキャッシュが最大の落とし穴: 設定変更後は必ず php artisan optimize:clear
  • always を忘れずに(nginx): ないとエラーレスポンスにヘッダーが付かない
  • dump/ddがヘッダーを壊す(Laravel): デバッグ出力が残っていないか確認
  • 末尾スラッシュに注意(Laravel): リダイレクトが発生しCORSヘッダーが消える
  • 開発環境はプロキシで回避が簡単: Vite/Next.js/CRAのプロキシ設定でCORSをそもそも発生させない

本記事は2026年6月時点の情報をもとに、nginx 1.26系、Express 4.x、Laravel 10.x/11.x での動作確認に基づき作成しています。バージョンによって一部の挙動や設定方法が異なる場合があるため、最新の情報は各公式ドキュメントもあわせてご確認ください。