こんにちは、フロントエンド担当の中井です。 本記事ではウェブエンジニアなら知っておきたい CORS を解説します。
この記事は次のような構成です。
- ウェブエンジニアなら知っておきたい CORS について
はじめに
CORS の読み方
皆さんは「CORS」をどう読んでいますか?
明確な決まりはありませんが、英語圏では "Course" に近い発音が多く、日本の開発現場では「コーズ」と呼ばれることが一般的です。ローマ字読みのように「コルス」と呼ぶ方もいます。
どちらでも通じますが、現場では「コーズエラーが出た」と耳にすることが多い印象です。
こんな経験ありませんか?
API 連携の実装中、Postman や curl では正常に API が呼び出せているのにブラウザからだと動かない。開発者ツールを開くとコンソールが真っ赤になっている......
Postman などで動くのは、CORS が「ブラウザ独自のセキュリティ機能」だからです。ブラウザを通さないツールではこの機能は適用されません。
「設定が面倒」「邪魔」と思われがちな CORS ですが、この記事を読んだ後は仲良く付き合っていきましょう。
CORS エラーと「同一生成元ポリシー」
コンソールに出現する赤いエラー
CORS エラーの代表的なメッセージは以下のようなものです。
Access to XMLHttpRequest at 'https://api.example.com/data' from origin 'http://localhost:3000' has been blocked by CORS policy...
これは「Origin が違う場所にあるリソースには、許可なくアクセスできません」と言われています。オリジンとはスキーム(プロトコル) + ホスト(ドメイン) + ポート番号の組み合わせのことです(例: https://example.com:443)。これが 1 つでも違うとクロスオリジン(異なるオリジン)とみなされます。
例えば以下のケースはすべて異なるオリジンです。
- ドメインが違う:
example.comとapi.example.com - スキームが違う:
http://とhttps:// - ポートが違う:
localhost:3000とlocalhost:8080
※ パス(/users など)の違いはオリジンには影響しません。
なぜエラーになるのか
「同じ会社で作っているシステムなのに」と思うかもしれませんが、これはブラウザによるセキュリティ機能です。 ブラウザはユーザーを攻撃から守るため、デフォルトで他人のドメインへのアクセスを制限しています。
同一生成元ポリシー(SOP)とは
この挙動の根底にあるのが「同一生成元ポリシー(Same-Origin Policy: SOP)」という大原則です。 これはあるサイトのスクリプトは、別のサイトのデータに勝手にアクセスしてはいけないというルールです。
もし SOP がなかったら
例えば、ユーザーが悪意のある偽サイトにアクセスしてしまったとします。
SOP がない世界では、その悪意あるサイトの JavaScript が裏で勝手にユーザーの銀行サイトの API を呼び出して、口座残高や取引履歴などのプライベートな情報を盗み見ることが可能になってしまいます。(ブラウザは銀行サイトのログイン Cookie を自動的に送信してしまうため、銀行側はユーザー本人からの正規のリクエストだと判断してデータを返してしまう)
このような事故を防ぐために、ブラウザはオリジンが違う通信を厳しく監視しています。
CORS の歴史
では、CORS はどのように誕生したのでしょうか。大まかな流れを見てみましょう。
SOP の誕生と Ajax の登場
ウェブがまだ静的な HTML ドキュメントの集まりだった時代、セキュリティはまだシンプルで、自分のサイトのデータは自分のサイト内だけで使うという SOP のルールだけで十分安全でした。
しかし、Google Maps や Gmail の登場により、JavaScript を使って非同期でサーバーからデータを取得する技術(Ajax)が爆発的に普及します。 「外部の天気予報 API を表示したい」「別ドメインにある画像解析サーバーを使いたい」といったニーズが急増しましたが、ここで古き良き SOP が外部アクセス禁止の壁として立ちはだかりました。
CORS 以前の工夫
CORS が標準化される前は、SOP の抜け穴を利用していました。代表例が JSONP です。<script> タグなら別ドメインのファイルを読み込める仕様を使い、関数呼び出しの形でデータを受け取る方法ですが、セキュリティリスクやエラーハンドリングの難しさがあり、あくまで暫定対応でした。
CORS の誕生
「抜け穴を使うのは危険だから、ちゃんとした仕組みを作ろう」ということで、W3C によって策定されたのが CORS です。
これはサーバー側が「このオリジン(ドメイン)からならアクセスしてもいいよ」という 「許可証」 を発行することで、ブラウザが例外的に SOP の壁を越えることを許す仕組みです。
CORS のしくみ
CORS はブラウザとサーバーのコミュニケーションです。
オリジン間リソース共有(CORS)とは
CORS は Cross-Origin Resource Sharing の略称で、日本語では「オリジン間リソース共有」と訳されます。本来 SOP で制限されている異なるオリジン間のリソース共有を安全に行うためのルールです。
2 種類のリクエスト
ブラウザはリクエストを送る際、2 種類の方法を使い分けています。
単純リクエスト(Simple Request)
GET や HEAD、POST などが該当しますが、条件は厳密です。特にContent-Typeはapplication/x-www-form-urlencoded、multipart/form-data、text/plainのいずれかである必要があります。この場合、ブラウザはいきなりリクエストを送ります。ただし、レスポンスに許可証が含まれていなければ、ブラウザはそのデータを JavaScript に渡しません(リクエスト自体は届く点に注意)。プリフライトリクエスト(Preflight Request)
Content-Type: application/jsonでデータを送信する場合や、認証トークンなどのカスタムヘッダーを付与する場合、ブラウザは いきなり送るのは危険かもしれない と判断します。そこで本番のリクエストを送る前に、プリフライトと呼ばれる確認のための通信を行います。
なぜ2 種類に分けるのか
なぜ単純リクエストはいきなり送って良いのに、プリフライトは事前の確認が必要なのでしょうか。 その理由は、歴史的経緯(後方互換性)とサーバーの保護にあります。
単純リクエストは後方互換性(歴史的経緯)のため 単純リクエストがプリフライトなしで飛ぶのは、CORS が登場する前の仕様に対応するためです。昔から HTML フォームを使えば、JavaScript なしでも別ドメインへ POST リクエストを送ることは可能でした。CORS 導入時にこれをブロックしてしまうと、既存のウェブの仕組みが壊れてしまいます。そのため、単純リクエストは リクエスト自体はサーバーに届いて処理されてしまいます(CSRF リスク)。ブラウザは送信を止めませんが、レスポンスを隠すことでデータの読み取りを防いでいます。
プリフライトはサーバーを守るため プリフライトが必要なのは、過去ブラウザからは送れなかったようなリクエストからサーバーを守るためです。JavaScript(Fetch API)が登場して初めて、
DELETEメソッドやapplication/json、カスタムヘッダーなどがブラウザから送れるようになりました。これらプリフライト対象のメソッドは、サーバーが想定していない可能性があるため、リクエストがサーバーに届いて処理されること自体がリスクとなります。だからこそ、実際に本番リクエストを行う前に、プリフライトで「OK」をもらわない限り、ブラウザは本番の送信を行わない仕組みになっているのです。
OPTIONS メソッド
プリフライトで使われるのが OPTIONS メソッドです。
アクセスログに身に覚えのない OPTIONS リクエストが記録されているのを見たことがある方もいると思います。 これはブラウザがサーバーに対して「これから POST を送りたいのですが、許可してくれますか?」と許可を確認する通信です。
ここでサーバーが「OK」という許可証(ヘッダー)を返さない限り、ブラウザは本番の POST リクエストを送信しません。
これがプリフライトにおける CORS エラーの正体 です。
サーバーが OPTIONS に対して正しい許可証を返していないため、本番通信がブロックされています。 ※単純リクエストの場合は、通信後にレスポンスが隠されます
解決策
CORS エラーはフロントエンドだけでは解決できません。許可証を発行するのはサーバー(バックエンド・インフラ)の役割だからです。
CORS を制御する主なレスポンスヘッダー
サーバー側でレスポンスに以下の HTTP ヘッダーを含めます。すべてを常に返すわけではなく、リクエストの種類(OPTIONS かそれ以外か)に応じて使い分けます。
Access-Control-Allow-Origin
どのドメインからのアクセスを許可するかを指定します。 プリフライト(OPTIONS)と本番リクエストの両方のレスポンスで必須です。開発中は*にしがちですが、本番や Cookie を扱う場合はhttps://app.example.comのように具体的に指定します。Access-Control-Allow-Credentials: trueの場合、*は使用できません。Access-Control-Allow-Methods
GET, POST, OPTIONS など、許可するメソッドを指定します。主にプリフライトのレスポンスで必要です。Access-Control-Allow-Headers
Authorization等の独自ヘッダーを許可するかどうかの設定で、これも主にプリフライトリクエスト(OPTIONS)のレスポンスで必要になります。Access-Control-Allow-Credentials(Cookie を使う場合)
Cookie 等の認証情報を送る場合(フロントでwithCredentials: trueの場合)、このヘッダーを true で返す必要があります。Access-Control-Expose-Headers
フロント側で参照できるレスポンスヘッダーを追加で公開する場合に設定します。Access-Control-Max-Age
プリフライト結果をブラウザにキャッシュして良い時間を秒で指定します。
設定を入れる場所
これらのヘッダーをどこで返すかはシステム構成によって異なります。
- バックエンド: Laravel や Rails のミドルウェアで設定する
- ウェブサーバー/インフラ: Nginx、ALB、API Gateway、S3 などで返す
- 手前のロードバランサーや WAF がブロックしていないか確認する
アプリで許可したはずなのにエラーが出る場合、手前のロードバランサーや WAF がブロックしている可能性もあります。チーム内でどこで CORS を制御するかを合意しておくとトラブルを避けられます。
CORS でハマりやすい「落とし穴」と「パフォーマンス改善」
ここからはパフォーマンスや隠れたバグを防ぐためのテクニックです。
Access-Control-Max-Age で高速化
Preflight request(OPTIONS)は理論上 API 通信の回数を 2 倍にします。毎回「POST していいですか?」「いいですよ」とやり取りするのはレイテンシの観点で無駄かもしれません。
Access-Control-Max-Age: 86400
これをレスポンスに含めると、ブラウザに対して「この許可証(プリフライトの結果)は 86400 秒(24 時間)キャッシュしていいよ」と伝えることができます。これにより、2 回目以降の通信では OPTIONS が省略され、アプリケーションのパフォーマンスが向上します。
※ ただし、ブラウザごとにキャッシュできる最大時間には上限があります。(例: Chromium は最大 2 時間など)
Access-Control-Expose-Headers
よくあるトラブルとして、Network タブではレスポンスヘッダーが見えるのに、JavaScript で取得すると undefined になるという現象があります。CORS 仕様上、JavaScript が読み取れるレスポンスヘッダーは基本的なものに限られるため、独自ヘッダーを公開する場合は Access-Control-Expose-Headers: X-Total-Count, X-Request-ID のように明示します。
Vary: Origin
特定のドメインのみ許可する場合、サーバー側でリクエストの Origin ヘッダーを見て、それをそのまま Access-Control-Allow-Origin にセットして返すという動的な実装を行うことがあります。
この場合、必ず Vary: Origin というヘッダーも一緒に返すようにしましょう。
これがないと、CDN やブラウザのキャッシュがある Origin 向けの許可証を別の Origin からのアクセスに対して間違って使い回してしまい、予期せぬ CORS エラーやセキュリティ事故につながる可能性があります。
モダン開発環境ならではのパターン
皆さんの中には Next.js や Nuxt 等を使っている方も多いと思います。 実はモダンフレームワークならではの CORS の落とし穴 が存在するので、そちらも併せて紹介します。
SSR(サーバーサイド)ではエラーにならない!?
Next.js の Server Components などサーバーサイドから API を呼ぶと CORS エラーが出ないのに、useEffect や SWR(クライアントサイド)で同じ API を呼ぶとエラーになることがあります。
「同じ API なのになぜ!?」 となりがちですが、理由はシンプルです。
クライアントサイド(ブラウザ): Origin が違うため、ブラウザが CORS チェックを行う。
サーバーサイド(Node.js): バックエンドへの通信はサーバー to サーバーの通信なので、ブラウザのセキュリティ機能(CORS)は発動しない。
サーバー側で呼び出せたから OKと安心していても、クライアント側の実装時にエラーが発生するかもしれません。 ブラウザから呼び出す時だけ CORS が必要という原則を忘れないようにしましょう。
開発環境の「Proxy」マジックに注意
Vite や Next.js で開発している時、vite.config.ts や next.config.js に proxy(rewrites)設定を書いていませんか?
これを使っていると、開発中はブラウザが同じオリジン(localhost:3000)にアクセスしていると錯覚するため、CORS エラーが出ません。 しかし、本番環境にデプロイした途端、Proxy がなくなって CORS エラーが多発するという事故が起きます。
「開発環境で動いたからヨシ!」ではなく、本番構成(S3 + API Gateway など) でどうなるかを意識することが大切です。
まとめ
ここまで読んでいただきありがとうございました。 以上でわかるとおり、CORS は敵ではありません。ユーザーを悪意ある攻撃から守る頼もしいガードマンのような存在です。
そして、エラーの解決には協力が必要です。 エラーが出たら、フロントエンドだけで悩まず「サーバー(インフラ)さん、許可証(ヘッダー)の設定をお願いします」とコミュニケーションを取りましょう。 「CORS の許可をお願いします!」というコミュニケーションの意味は、上記までの説明の流れになっています。
仕組みと成り立ちさえ分かれば、CORS は怖くありません。ブラウザとサーバーのコミュニケーションを正しく成立させて、安全なウェブアプリケーションを開発していきましょう。
参考文献
- オリジン間リソース共有 (CORS) - HTTP | MDN
- 同一オリジンポリシー - セキュリティ | MDN
- Access-Control-Allow-Origin ヘッダー - HTTP | MDN
- Access-Control-Allow-Methods - HTTP | MDN
- Access-Control-Allow-Headers - HTTP | MDN
- Access-Control-Allow-Credentials - HTTP | MDN
- Access-Control-Expose-Headers - HTTP | MDN
- Access-Control-Max-Age - HTTP | MDN
- Access-Control-Request-Headers - HTTP | MDN
- Access-Control-Request-Method - HTTP | MDN
- Vary - HTTP | MDN
- CORS のエラー - HTTP | MDN
- Fetch Standard