CORSエラーを理解する — なぜブラウザがリクエストをブロックするのか

バイブコーディング入門 — 第11回

前回までのおさらいと、今回のテーマ

前回(第10回)では、HTTPが「前の会話を覚えていない」ステートレスな仕組みであること、そしてそれを補うCookie、セッション、JWTの仕組みを学んだ。

第9回(HTTPヘッダ)で、レスポンスヘッダの一つとして Access-Control-Allow-Origin を紹介したのを覚えているだろうか。そのとき「CORSエラーはバイブコーディングでよく遭遇するので、次回以降で詳しく扱う」と予告していた。

今回がその回だ。

CORS(コルス)エラーは、バイブコーディングで最も遭遇しやすいエラーの一つだ。しかも、初めて見ると何が起きているのか分かりにくい。コードに間違いがあるわけではなく、サーバーも正常に動いているように見えるのに、ブラウザだけがリクエストを拒否する。

このエラーの正体を理解すれば、AIに的確に伝えて解決してもらえるようになる。

CORSとは何か — 異なる住所への手紙をブラウザが検閲する仕組み

CORS(Cross-Origin Resource Sharing、クロスオリジン・リソース・シェアリング)は、ブラウザに組み込まれたセキュリティの仕組みだ。異なるオリジン(後述する)からのリクエストを制限することで、悪意のあるサイトからの不正アクセスを防ぐ。

これまでのシリーズで使ってきた手紙の比喩で考えてみよう。

あなたの会社(フロントエンド)が、取引先の倉庫(バックエンドのサーバー)に手紙を送って荷物を取り寄せたいとする。ところが、手紙を届ける配達員(ブラウザ)が独自のルールを持っている。「この手紙の差出人の住所と、届け先の住所が違う。届け先の倉庫から事前に許可証をもらっていない限り、この手紙は届けられない」と言って手紙を持ち帰ってしまう。

これがCORSの仕組みだ。ブラウザが「配達員」として、差出人(フロントエンド)と届け先(バックエンド)の住所が異なるリクエストを検閲している。

重要なのは、これはブラウザだけの制限だということだ。サーバー同士の通信にはCORSの制限はない。配達員を介さず、倉庫同士が直接やりとりする場合は問題ない。

オリジンとは — 住所の3要素

「異なるオリジン」と言ったが、オリジン(origin)とは何だろうか。

オリジンは、URLの「住所」にあたる部分で、次の3つの要素の組み合わせで決まる。

  1. プロトコル — http:// か https:// か
  2. ドメイン — localhost、example.com など
  3. ポート番号 — :3000、:8080 など

この3つが完全に一致していれば「同じオリジン」、1つでも違えば「異なるオリジン」と判定される。

具体例を見てみよう。

http://localhost:3000http://localhost:8080 — ドメインは同じ localhost だが、ポート番号が 3000 と 8080 で異なる。異なるオリジンだ。

http://localhost:3000https://localhost:3000 — ドメインとポートは同じだが、プロトコルが http と https で異なる。異なるオリジンだ。

https://myapp.vercel.apphttps://api.example.com — ドメインが異なる。異なるオリジンだ。

https://myapp.vercel.app/page1https://myapp.vercel.app/page2 — パス(/page1 と /page2)は違うが、プロトコル・ドメイン・ポートはすべて同じ。同じオリジンだ。

パス(URLのスラッシュ以降の部分)はオリジンの判定には含まれない。あくまでプロトコル、ドメイン、ポートの3つだけで決まる。

なぜCORSが必要なのか — 悪意のあるサイトからの防御

CORSの制限がないと、どんな問題が起きるだろうか。

たとえば、あなたが銀行のWebサイトにログインしている状態で、別のタブで悪意のあるサイトを開いてしまったとする。もしCORSの制限がなければ、その悪意のあるサイトのJavaScript(プログラム)が、あなたのブラウザを通じて銀行のAPIに「送金してください」というリクエストを送ることができてしまう。あなたのブラウザには銀行の認証Cookie(第10回で学んだ)が保存されているので、銀行のサーバーは「本人からのリクエストだ」と判断してしまう。

CORSは、こうした攻撃を防ぐために存在する。悪意のあるサイト(evil.com)から銀行のAPI(bank.com)へのリクエストは、オリジンが異なるのでブラウザがブロックする。銀行のサーバーが「evil.comからのリクエストを許可する」という設定をしていない限り、リクエストは届かない。

手紙の比喩で言えば、配達員が「この差出人は届け先の倉庫から許可をもらっていないので、手紙を届けません」と判断してくれる、頼もしいセキュリティ担当だ。

バイブコーディングでCORSエラーに遭遇する3つのパターン

CORSの仕組みを理解したところで、バイブコーディングで実際にCORSエラーが起きる典型的なパターンを見てみよう。

パターン1: フロントエンドとバックエンドのポートが違う

最も多いパターンだ。AIにWebアプリを作ってもらうと、フロントエンド(画面)が http://localhost:3000 で動き、バックエンド(API)が http://localhost:8080 で動くことがある。同じパソコン上でも、ポートが違えば異なるオリジンだ。

フロントエンドからバックエンドのAPIにリクエストを送ると、ブラウザが「オリジンが違う」と判断してCORSエラーを出す。

パターン2: ブラウザから外部APIを直接呼び出す

天気予報API、地図API、翻訳APIなど、外部のサービスをブラウザから直接呼び出そうとするケースだ。たとえば、http://localhost:3000 のページから https://api.weather.example.com にリクエストを送る。当然オリジンが異なるので、CORSエラーになる。

外部APIの中には、ブラウザからの直接アクセスを許可していないものも多い。その場合、フロントエンドから直接呼ぶのではなく、自分のサーバーを経由してアクセスする必要がある(後述の解決方法で詳しく説明する)。

パターン3: デプロイ後のドメイン不一致

ローカル開発中は問題なかったのに、Vercel(バーセル)にデプロイ(公開)した後にCORSエラーが発生するケースだ。

たとえば、フロントエンドが https://myapp.vercel.app にデプロイされ、バックエンドが https://api.myapp.com で動いている。開発中は両方とも localhost だったので問題なかったが、デプロイ後はドメインが異なるのでCORSエラーが起きる。

バックエンド側のCORS設定で、localhost だけを許可していて、Vercelのドメインを許可し忘れているというパターンも多い。

DevToolsでのCORSエラーの見え方

CORSエラーが起きたとき、DevToolsにはどのように表示されるだろうか。確認する場所は2つある。

Consoleタブ — 赤いエラーメッセージ

DevToolsのConsoleタブに、赤い文字でこのようなメッセージが表示される。

Access to fetch at 'http://localhost:8080/api/tasks' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

このメッセージを分解すると、こうなる。

CORSエラーのデバッグ手順

図: CORSエラーのデバッグ手順

Networkタブ — ステータスが表示されない

Networkタブでは、CORSエラーになったリクエストは通常のステータスコード(200や404)ではなく、ステータスが空欄になっていたり「(failed)」「CORS error」と表示されたりする。

これは、ブラウザがレスポンスを受け取る前にブロックしているためだ。サーバーは正常にレスポンスを返している(かもしれない)のに、ブラウザが「このレスポンスはオリジンが異なるから、フロントエンドのコードには渡さない」と判断している。

手紙の比喩で言えば、倉庫から返事の手紙は送られてきたのに、配達員が「差出人の許可証が見当たらないので、この返事はお届けできません」と持ち帰ってしまう状態だ。

プリフライトリクエスト — 事前に許可を確認する手紙

CORSの仕組みには、もう一つ知っておくと便利な概念がある。プリフライトリクエスト(preflight request)だ。

手紙の比喩で言えば、本命の手紙を送る前に、配達員が「こういう手紙を送っていいですか」と倉庫に問い合わせる「事前確認の手紙」のようなものだ。

プリフライトが送られる条件

単純なGETリクエストでは、プリフライトは送られない。以下のような条件に当てはまると、ブラウザは自動的にプリフライトリクエストを送る。

  • PUTやDELETEなどのメソッドを使う場合
  • Content-Type が application/json の場合(POSTリクエスト)
  • カスタムヘッダ(独自のヘッダ)を付けている場合

バイブコーディングでは、APIにJSONデータを送るリクエスト(POST、PUT、DELETE)のほとんどがプリフライト対象になる。

プリフライトの仕組み

プリフライトリクエストは、HTTPメソッドがOPTIONSのリクエストだ。第7回で学んだGET、POST、PUT、DELETEとは別の、確認専用のメソッドだ。

流れはこうなる。

  1. ブラウザが本来のリクエスト(例: POST /api/tasks)を送る前に、OPTIONS /api/tasks というリクエストを先に送る
  2. このOPTIONSリクエストには「このオリジンから、このメソッドで、このヘッダ付きのリクエストを送ってもいいですか」という情報が含まれている
  3. サーバーが「いいですよ」と応答する(Access-Control-Allow-Origin ヘッダなどを含んだレスポンスを返す)
  4. ブラウザが「許可が出た」と判断し、本来のリクエストを送る

この事前確認に失敗すると、本来のリクエストは送られずにCORSエラーになる。

DevToolsでのプリフライトの見え方

Networkタブで、本来のリクエストの直前にOPTIONSメソッドのリクエストが表示されていることがある。これがプリフライトだ。プリフライトのステータスコードが200以外(または表示されない)の場合、プリフライトの段階でブロックされていることが分かる。

解決方法 — 3つのアプローチ

CORSエラーの解決方法を3つ紹介する。どの方法が適切かは状況によるが、バイブコーディングではAIに状況を伝えれば最適な方法を選んでくれる。

方法1: サーバー側でAccess-Control-Allow-Originヘッダを設定する

もっとも基本的な方法だ。バックエンドのサーバーが「このオリジンからのリクエストは許可する」と明示的に宣言する。

手紙の比喩で言えば、倉庫の管理者が「この差出人住所からの手紙は受け付けてください」と配達員に許可証を渡すようなものだ。

サーバーのレスポンスに、次のようなヘッダを付ける。

Access-Control-Allow-Origin: http://localhost:3000

これは「http://localhost:3000 からのリクエストを許可する」という意味だ。

複数のメソッドやヘッダも許可する場合は、追加のヘッダも必要になる。

Access-Control-Allow-Methods: GET, POST, PUT, DELETE Access-Control-Allow-Headers: Content-Type, Authorization

注意点として、Access-Control-Allow-Origin: *(アスタリスク、すべてのオリジンを許可)という設定がある。開発中は手軽だが、本番環境では特定のオリジンだけを許可するのが望ましい。すべてのオリジンを許可すると、どんなサイトからでもAPIにアクセスできてしまうためだ。

方法2: Next.jsのAPI Routes(またはRoute Handlers)をプロキシとして使う

外部APIを呼び出す場合に有効な方法だ。フロントエンドから直接外部APIを呼ぶのではなく、自分のサーバー(Next.jsのAPI Routes / Route Handlers)を経由させる。

手紙の比喩で言えば、直接外部の倉庫に手紙を送る代わりに、自社の総務部(API Routes)に「外部の倉庫から荷物を取り寄せてほしい」と依頼する。総務部がサーバー間通信で外部倉庫とやりとりするので、ブラウザのCORS制限には引っかからない。

流れはこうなる。

  1. フロントエンド(http://localhost:3000)が自分のAPIルート(http://localhost:3000/api/weather)にリクエストを送る — 同じオリジンなのでCORSエラーにならない
  2. API Routeの中で、外部API(https://api.weather.example.com)にサーバー間通信でリクエストを送る — サーバー間なのでCORSの制限がない
  3. 外部APIからのレスポンスを受け取り、フロントエンドに返す

この方法のメリットは、外部APIのAPIキー(認証用の鍵)をサーバー側に隠せることだ。フロントエンドのコードに含めると誰でも見えてしまうAPIキーを、サーバー側で安全に管理できる。

方法3: next.config.jsのrewritesを使う

Next.jsを使っている場合に使える方法だ。next.config.js(Next.jsの設定ファイル)にrewritesの設定を書くと、特定のパスへのリクエストを別のURLに転送してくれる。

手紙の比喩で言えば、総務部が「/api/external/ 宛ての手紙はすべて外部倉庫に転送する」というルールを事前に設定しておく方式だ。

設定のイメージはこうなる。

// next.config.js rewrites の設定で /api/external/:path* へのリクエストを https://api.example.com/:path* に転送する

フロントエンドからは /api/external/tasks にリクエストを送るだけで、実際には https://api.example.com/tasks にリクエストが転送される。フロントエンドから見ると同じオリジンへのリクエストなので、CORSエラーは起きない。

この方法は、API Routeを1つずつ作る手間が省ける反面、リクエストやレスポンスの加工がしにくいという制約がある。

AIへの伝え方テンプレート

CORSエラーに遭遇したとき、AIに伝えると効果的な情報をテンプレートにまとめた。


(症状)〇〇の操作をすると画面に何も表示されない / データが取得できない。 (Consoleのエラー)「Access to fetch at '[送り先URL]' from origin '[送り元URL]' has been blocked by CORS policy」と表示されている。 (送り元と送り先)フロントエンドは [オリジン] で動いている。リクエスト先は [オリジン] だ。 (環境)Next.js を使っている / 外部APIを呼んでいる / Vercelにデプロイしている。 (Networkタブ)OPTIONSリクエストが [成功している / 失敗している / 見当たらない]。


具体例を2つ示す。


例1(ローカル開発でのフロント⇔バックエンド分離): (症状)タスク一覧が表示されない。 (Consoleのエラー)「Access to fetch at 'http://localhost:8080/api/tasks' from origin 'http://localhost:3000' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource」 (送り元と送り先)フロントエンドは http://localhost:3000、バックエンドは http://localhost:8080。 (環境)Next.jsのフロントエンドとExpress(Node.js)のバックエンドを別々に起動している。 (Networkタブ)OPTIONSリクエストが見当たらない。



例2(デプロイ後のドメイン不一致): (症状)Vercelにデプロイしたら、ローカルでは動いていたAPI呼び出しがすべてエラーになった。 (Consoleのエラー)「Access to fetch at 'https://api.myapp.com/tasks' from origin 'https://myapp.vercel.app' has been blocked by CORS policy」 (送り元と送り先)フロントエンドは https://myapp.vercel.app、バックエンドは https://api.myapp.com。 (環境)Next.jsでVercelにデプロイ。バックエンドは別サーバー。 (Networkタブ)OPTIONSリクエストが 403 を返している。


よくある不安と答え

CORSエラーはコードのバグなのか

バグではない。CORSはブラウザのセキュリティ機能が正常に動作した結果だ。設定が足りないだけであり、正しく設定すれば解消する。AIにConsoleのエラーメッセージを貼り付けて伝えれば、適切な設定方法を教えてくれる。

「Access-Control-Allow-Origin: *」にすれば全部解決するのでは

開発中はそれで動くことが多い。ただし、本番環境では避けた方がよい。すべてのオリジンを許可すると、悪意のあるサイトからもAPIにアクセスできてしまう。本番では、自分のフロントエンドのドメインだけを許可する設定にするのが安全だ。AIに「本番用のCORS設定をしたい」と伝えれば、適切な設定を提案してくれる。

CORSエラーがサーバーのログに出ないのはなぜか

CORSはブラウザ側の制限だからだ。サーバーはリクエストを受け取って正常にレスポンスを返しているが、ブラウザが「許可されていないオリジンからのリクエストなので、レスポンスをフロントエンドに渡さない」と判断している。そのため、サーバーのログには正常な通信として記録されることがある。

Postmanやcurlではエラーにならないのにブラウザではエラーになるのはなぜか

Postman(API検証ツール)やcurl(コマンドラインの通信ツール)はブラウザではないので、CORSの制限を受けない。CORSはあくまでブラウザだけが適用するセキュリティルールだ。「Postmanでは動くのにブラウザでは動かない」は、CORSエラーの典型的な症状だ。

Next.jsだけで開発している場合もCORSエラーは起きるのか

Next.jsのフロントエンドとAPI Routes(またはRoute Handlers)を同じプロジェクト内で使っている場合、同じオリジンでの通信になるのでCORSエラーは起きにくい。CORSエラーが起きるのは、外部のAPIを直接呼び出すか、バックエンドを別のサーバーやポートで動かしている場合だ。

まとめ

CORSエラーは、ブラウザが異なるオリジン(プロトコル+ドメイン+ポートの組み合わせ)へのリクエストをセキュリティのためにブロックする仕組みから起きる。バイブコーディングでは「フロント⇔バックエンドのポート違い」「外部APIの直接呼び出し」「デプロイ後のドメイン不一致」の3パターンで遭遇しやすい。解決方法はサーバー側のヘッダ設定、API Routeによるプロキシ、next.config.jsのrewritesの3つ。DevToolsのConsoleタブに表示される赤いエラーメッセージをそのままAIに貼り付けて、送り元と送り先のオリジンを伝えれば、AIが適切な解決方法を提案してくれる。

次回は「REST APIの設計パターン — エンドポイントの規則性を知る」。APIのURL設計にどんなルールがあるのかを学び、AIが生成したAPIの構造を読み解けるようになる。

参考リファレンス

  • MDN Web Docs「オリジン間リソース共有 (CORS)」— Mozilla が提供するCORSの技術リファレンス
  • MDN Web Docs「同一オリジンポリシー」— CORSの基盤となるブラウザのセキュリティポリシー
  • 前回: 第10回「ステートレスとは何か — Cookie、セッション、JWTで状態を覚える仕組み」(/articles/vc-010)
  • 次回: 第12回「REST APIの設計パターン — エンドポイントの規則性を知る」(/articles/vc-012)
  • バイブコーディング入門 カリキュラム(/vibe-coding)