同じ API を叩いているのに、なぜ CORS エラーが出たり出なかったりするのか?
Postman で叩けば 200 が返ってくるのに、ブラウザの fetch だけ真っ赤な CORS エラーで落ちる——一度は経験しているはずだ。しかもそのエラーはサーバーが返したレスポンスではなく、開発者コンソールに表示されている。あの赤い文字は、いったい誰が何のために出しているのか。
- CORS は同一オリジンポリシーの例外を許可する仕組みで、ブラウザだけが強制するため curl や Postman では再現しない
- GET などの「シンプルリクエスト」はブラウザが実際に送信し終えたあとでレスポンスの中身だけを隠す。JSON の POST/PUT/DELETE が大抵プリフライトを伴うのは、それらが「シンプルではない」形をしているからだ
- Cookie などの資格情報を伴うリクエストには
Access-Control-Allow-Origin: *が使えず、サーバーは呼び出し元のオリジンを名指しで許可しなければならない
同一オリジンポリシー — ブラウザだけが引く境界線
2 つの URL が「同じオリジン」と言えるのは、スキーム・ホスト・ポートの 3 つがすべて一致するときだけだ。https://app.example と https://api.example はホストが違うので別オリジン、https://app.example と http://app.example はスキームが違うので別オリジンになる。ブラウザはこの一致を確認し、異なるオリジンへの JavaScript からのリクエストで返ってきたレスポンスの中身を、呼び出し元のスクリプトに読ませないようにする——これが同一オリジンポリシーだ。
この制限が要るのは、ブラウザがリクエスト先のホストに紐づく Cookie を、そのリクエストを誰が発行したかに関係なく自動で付けて送るからだ。もし制限が無ければ、開いているだけの無関係なページの JavaScript が、あなたがログイン済みの銀行の API を裏で呼び出し、ログインセッションの Cookie に便乗して残高データを読み出せてしまう。同一オリジンポリシーは「送る」こと自体は止めず、「送った結果を読む」ことだけを塞いでいる。
CORS(Cross-Origin Resource Sharing)は、サーバーがこの制限を特定の相手にだけ解除してよいと宣言するための仕組みにすぎない。しかも判定と遮断はすべてブラウザの中で完結し、サーバーはただレスポンスヘッダーを返すだけで判定には関与しない。curl や Postman はブラウザの Cookie を共有せず、この判定ロジックも持たないので、サーバーが正しく動いていてもそのまま素通りする——「サーバー側は動いているのにブラウザだけ落ちる」現象の正体はここにある。
「シンプルリクエスト」はなぜ静かに素通りするのか
すべてのクロスオリジンリクエストがブラウザに止められるわけではない。メソッドが GET / HEAD / POST のいずれかで、ヘッダーが Accept や Content-Language など決められた安全なものだけ、かつ Content-Type が application/x-www-form-urlencoded ・ multipart/form-data ・ text/plain のいずれかであれば、それはシンプルリクエストとして扱われ、事前確認なしにそのまま送られる。
「CORS エラーが出た=リクエストはサーバーに届いていない」と考えたくなる。しかし実際は、シンプルリクエストの場合サーバーは処理を最後まで終えている。ブラウザは裏でリクエストを送信し、サーバーはそれを受け取って通常どおり処理し、レスポンスも返す。CORS が塞ぐのはその後の一手だけで、返ってきたレスポンスの中身を JavaScript に渡さずに握りつぶす。副作用のある GET リクエストを不用意に置いてはいけない理由がここにある。
逆に言えば、モダンな JSON API の POST/PUT/DELETE がほぼ必ずブラウザの Network タブに OPTIONS を出しているのは偶然ではない。Content-Type: application/json は安全なリストに含まれていないため、JSON を送る時点でシンプルリクエストの条件から外れ、次に説明する事前確認の対象になる。データを書き換える操作の大半が自然とこの仕組みの内側に入る設計になっている。
オフィスビルの受付に近い。社員証(Cookie)を持った来訪者が他社のオフィスへ向かおうとすると、受付(ブラウザ)は先方に電話をかけ「この人を通していいか」を確認してから通す——これがプリフライトだ。ただし郵便物を渡すだけのような軽い用件(シンプルリクエスト)は、受付がその場で先に渡してしまい、渡していい相手だったかはあとから確認する。渡した事実そのものはもう取り消せない。
プリフライト — 実行前にサーバーへ確認を取る
シンプルリクエストの条件から外れるリクエストを送ろうとすると、ブラウザは本番のリクエストの前に OPTIONS メソッドのプリフライトリクエストを自動で送る。ここには「これから POST を送りたい」という Access-Control-Request-Method と、使うヘッダーを申告する Access-Control-Request-Headers が乗る。サーバーが Access-Control-Allow-Origin ・ Access-Control-Allow-Methods ・ Access-Control-Allow-Headers で許可を返して初めて、ブラウザは本番のリクエストを送信する。
Browser Server │ OPTIONS(確認) │ ├────────────────────────▶│ │ 204 + Allow-Origin 等 │ │◀────────────────────────┤ │ POST(本番リクエスト) │ ├────────────────────────▶│ │ 200 OK + データ │ │◀────────────────────────┤
この往復は毎回発生すると無駄なので、サーバーは Access-Control-Max-Age で確認結果をブラウザにキャッシュさせられる。設定しない場合のデフォルトは 5 秒で、同じエンドポイントへの連続リクエストのたびに往復が積み重なる。長く指定すればキャッシュは効くが、ブラウザ側にも上限があり、それを超えて指定しても切り詰められる(Chromium は v76 以降で 7200 秒=2 時間、Firefox は 86400 秒=24 時間が上限)。
Credentials と Access-Control-Allow-Origin: * の非互換
不特定多数に公開する API は Access-Control-Allow-Origin: * を返しておけば、どのオリジンから呼ばれても手間なく許可できる。しかしリクエストが Cookie や TLS クライアント証明書などの資格情報を伴う場合、ブラウザはこのワイルドカードを拒否する。サーバーは呼び出し元のオリジンを https://app.example のように名指しで返し、加えて Access-Control-Allow-Credentials: true を返さない限り、リクエスト自体は成功していてもレスポンスは JavaScript に渡らない。
クライアント側にも対の設定が要る。fetch で credentials: "include" を指定しない限り、そもそもクロスオリジンの Cookie は送られない。「サーバー側は名指しでオリジンを許可した、クライアント側も credentials を指定した」の両方が揃って初めて、ログイン状態を保ったクロスオリジン API 呼び出しが成立する。
フロントとバックエンドを別ドメイン・別ポートで動かす構成——Next.js のフロントから別ホストの API を呼ぶ、社内ツールを別オリジンの iframe に埋め込む、ローカル開発で localhost:3000 から localhost:8080 の API に fetch する——のいずれでも初めて CORS の壁にぶつかる。エラーが出たらまず、失敗したリクエストが「シンプルリクエストか」「Cookie を伴うか」を切り分けると、サーバー側のどのヘッダーを疑うべきか絞り込める。
ポートが違うだけの 2 つのローカルサーバー間で、シンプルリクエストがブラウザに届く前に本当にサーバー側で処理されているかを確認する。
# ターミナル1: API 役(ポート 8000)
mkdir -p /tmp/cors-demo/api && cd /tmp/cors-demo/api
echo '{"msg":"hello"}' > data.json
python3 -m http.server 8000
# ターミナル2: フロント役(ポート 5500)
mkdir -p /tmp/cors-demo/front && cd /tmp/cors-demo/front
cat > index.html <<'EOF'
EOF
python3 -m http.server 5500
ブラウザで http://localhost:5500 を開くと、開発者コンソールに No 'Access-Control-Allow-Origin' header is present という CORS エラーが出て画面には何も表示されない。だが Network タブでは data.json へのリクエストがステータス 200 で成功しているのが見える。サーバーはすでにデータを返し終えており、ブラウザがそれを JavaScript に渡すことだけを拒んでいる様子がそのまま観察できる。
- CORS はサーバーを外部の攻撃から守るためのセキュリティ機構だ — 判定と遮断はすべてブラウザの中で行われ、curl のようなブラウザ以外のクライアントには何の効力も持たない。守っているのはサーバーではなく、ユーザーの Cookie を勝手に使われないためのブラウザ利用者側だ。サーバー自体を守るのは認証・認可の役目である。
- CORS エラーが出た時点で、リクエストはサーバーに届いていない — シンプルリクエストに該当する場合、サーバーは処理を最後まで終えてレスポンスも返している。ブラウザが遮っているのはそのレスポンスを JavaScript に渡す最後の一手だけであり、副作用のある処理はすでに実行済みになっている。
Access-Control-Allow-Origin: *を設定しておけばどんな場合でも通る — Cookie や認証情報を伴うリクエストではワイルドカードは無効化され、呼び出し元オリジンを名指しした値とAccess-Control-Allow-Credentials: trueが必要になる。
- 同一オリジンポリシー
- スキーム・ホスト・ポートが一致しないオリジンからのレスポンスの中身を JavaScript に読ませないブラウザの制限。
- シンプルリクエスト
- メソッド・ヘッダー・Content-Type が決められた条件を満たし、プリフライトなしにそのまま送信されるリクエスト。
- プリフライトリクエスト
- シンプルリクエストの条件から外れるリクエストの前に、ブラウザが自動で送る
OPTIONSによる事前確認。 - Access-Control-Allow-Origin
- サーバーがどのオリジンからのクロスオリジンリクエストにレスポンスの閲覧を許可するかを示すヘッダー。
- 資格情報付きリクエスト
- Cookie や TLS クライアント証明書など、ユーザー固有の認証情報を伴うリクエスト。ワイルドカードの Origin 許可が使えない。
- Cross-Origin Resource Sharing (CORS) - MDN — シンプルリクエストとプリフライトの条件を定義する一次ソース
- Access-Control-Max-Age header - MDN — プリフライトのキャッシュ時間とデフォルト値・ブラウザ上限の解説
- Reason: Credential is not supported if the CORS header 'Access-Control-Allow-Origin' is '*' - MDN — ワイルドカードと資格情報付きリクエストが両立しない理由