はじめに
こんばんは、mirukyです。
ブラウザからAPIを呼んだら、コンソールに赤い英文が出る。なのにAPIのログを確かめると、ちゃんと 200 OK で返している。CORSのエラーは、この食い違いがほどけると、あとは芋づる式に理解できます。
今回は、手元に小さなカフェの注文アプリを立てて、わざとCORSエラーを出しながら、名前の由来から回避の方法まで確かめていきます。いきましょう!
目次
- CORSの正式名称と呼び方
- オリジンはURLのどこまでか
- 同一オリジンポリシーと、CORSが生まれた理由
- 検証に使うカフェの注文アプリ
- CORSエラーは、届いた応答をブラウザが渡さない状態
- Access-Control-Allow-Originを1行足すと受け取れる
- JSONのPOSTでは、先にプリフライトが飛ぶ
- Cookie付きのリクエストで増える2つの条件
- サブドメインが違うと、オリジンは別でもサイトは同じ
- CORSを回避する実例
1. CORSの正式名称と呼び方
CORSはCross-Origin Resource Sharingの頭文字です。オリジン(Origin)をまたいで(Cross)、リソース(Resource)を共有する(Sharing)ので、日本語ではオリジン間リソース共有と訳されます。この記事の訳語はMDN(Firefoxを作っているMozillaが運営する、HTMLやJavaScript、HTTPの解説サイト)の日本語版に合わせています。
呼び方は仕様にもMDNにも決まりがなく、コルス、シーオーアールエス、コースなど人によって違います。この記事ではコルスと呼びます。やっていることは、別のオリジンから取ってきたデータをページのJavaScriptに渡してよいかを、サーバーがHTTPのヘッダーでブラウザに伝えることです。まずは、このオリジンが何を指すのかから確かめます。
2. オリジンはURLのどこまでか
オリジンはURLのうちスキーム(http や https)、ホスト(ドメイン名)、ポートの3つを組にしたものです。4章で使う注文画面のURLでいえば、http://app.cafe.localhost:5173 までがオリジンで、後ろのパスの /api/menu やクエリは判定の外です。
3つのうち1つでも違えば、別のオリジンです。ポートを書かないURLは、httpなら80、httpsなら443として扱われるので、http://example.com と http://example.com:80 は同じオリジンになります。実際の判定を、注文画面のURLを基準に並べてみます。
とくに注意したいのが、ホストの違いです。app.cafe.localhost と api.cafe.localhost のように、先頭のサブドメイン(ドメイン名の前に付け足した部分)だけが違う2つも別のオリジンですし、api.cafe.localhost とその親の cafe.localhost も別のオリジンです。4章のアプリではAPIを api.cafe.localhost に置くので、ページとAPIは最初から別のオリジン同士になります。
3. 同一オリジンポリシーと、CORSが生まれた理由
CORSより先に、同一オリジンポリシー(Same-Origin Policy、SOP)というブラウザの決まりがあります。あるオリジンのページで動くスクリプトが、別のオリジンから取ってきた応答の中身を取り出すことを、原則として許さない決まりです。
なぜ必要かはネットバンキングの場面で考えると腑に落ちるので、bank.example にログインしたまま、別のタブでたまたま evil.example を開いたとします。ブラウザには bank.example のログインのCookieが保存されているので、evil.example のスクリプトが bank.example へ問い合わせると、そのリクエストにはCookieが付きます。その応答をスクリプトがそのまま受け取れたら、残高は evil.example に筒抜けです。
同一オリジンポリシーが止めるのは、主に応答の中身を取り出すほうです。リンクをたどる、フォームを送信する、別のオリジンの画像やスクリプトをページに埋め込む、といった使い方は基本的に許されていて、別のオリジンから届いた応答の中身をスクリプトで取り出すことが禁じられています。リクエストそのものはサーバーまで届く、という点は5章の画面で確かめます。
同一オリジンポリシーは、1995年のNetscape Navigator 2.02から入っている古い決まりです。そのあと、ページのJavaScriptから別のドメインのAPIを呼ぶ作りが当たり前になり、どのオリジンになら応答を渡してよいかを、サーバーからブラウザへ伝える方法が必要になりました。そうして決められたのがCORSで、最初はW3C(Webの標準を定める団体)の仕様として、いまはWHATWG(主要なブラウザの開発元が運営する標準化団体)がまとめるFetch Standardの一部として定義されています。
CORSのエラーという名前なので、CORSが通信を止めているように見えますが、止めているのは同一オリジンポリシーのほうで、CORSはそこに例外を作る仕組みです。ここを逆に覚えると、エラーを消そうとしてブラウザの設定を変えにいく遠回りになります。
4. 検証に使うカフェの注文アプリ
ここからは、手元で動かしたアプリで確かめます。画面を配るフロント(フロントエンド)は http://app.cafe.localhost:5173 で動くVite 6.3.5(フロントエンドの開発用ツール)の開発サーバー、APIは http://api.cafe.localhost:8080 で動くPython 3.10の標準ライブラリだけのサーバーです。Chromeは *.localhost の名前を手元のマシン(127.0.0.1)に解決するので、hostsファイル(ホスト名とIPアドレスの対応を書く設定ファイル)を書き換えずに、サブドメインを2つ用意できます。ブラウザはChrome 157で、開発者ツールは日本語の表示です。
APIはCORSのヘッダーを環境変数で付けたり外したりできる作りにしていて、ヘッダーを付ける部分は次の十数行です。
import os
ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "")
CREDENTIALS = os.environ.get("CORS_CREDENTIALS") == "1"
def cors_headers(self):
# ブラウザに、このオリジンなら応答を渡してよいと伝えるヘッダー
if ALLOW_ORIGIN:
self.send_header("Access-Control-Allow-Origin", ALLOW_ORIGIN)
self.send_header("Vary", "Origin")
# Cookie付きのリクエストにも応答を渡してよいと伝える(8章)
if CREDENTIALS:
self.send_header("Access-Control-Allow-Credentials", "true")
最初はCORSのヘッダーを1つも付けない状態で2つのサーバーを起動します。
# CORSのヘッダーを付けない状態で、APIを api.cafe.localhost:8080 で起動します
python3 api.py
# 別の端末で、フロントを app.cafe.localhost:5173 で起動します(中身は vite --host 127.0.0.1 --port 5173)
npm run dev
http://app.cafe.localhost:5173 を開くと、左に5つのボタン、右に結果の欄が並ぶ画面になります。5章から10章では、このボタンを上から押していきます。
①はメニューのGET、②はJSONでの注文のPOST、③はCookie付きのGET、④は10章のプロキシ経由、⑤は mode: 'no-cors' を付けたGETです。ボタンの中身は fetch を1回呼んで、応答のステータス、応答の種類を表す type、本文を右の欄に出すだけです。
const API = 'http://api.cafe.localhost:8080'
// ① 別のオリジンにあるAPIから、メニューを取ってくる
document.getElementById('menu').onclick = () =>
show('GET ' + API + '/api/menu', () => fetch(`${API}/api/menu`))
// ② JSONで注文を送る(Content-Type が application/json になる)
document.getElementById('order').onclick = () =>
show('POST ' + API + '/api/orders', () => fetch(`${API}/api/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item: 'カフェラテ', qty: 1 }),
}))
// ③ Cookie を付けて、自分の注文を問い合わせる
document.getElementById('me').onclick = () =>
show('GET ' + API + '/api/me', () => fetch(`${API}/api/me`, { credentials: 'include' }))
5. CORSエラーは、届いた応答をブラウザが渡さない状態
まずはヘッダーなしのAPIに対して、①でメニューを取りにいきます。右の欄には TypeError: Failed to fetch とだけ出て、コンソールにはCORSのエラーが2行出ました。
エラーの理由は1行目の最後にある No 'Access-Control-Allow-Origin' header is present on the requested resource です。要求したリソースの応答に Access-Control-Allow-Origin のヘッダーがない、と言っています。2行目をよく確かめると、net::ERR_FAILED の後ろに 200 (OK) とあり、APIは正常に答えています。失敗の赤字の中に成功の200が混ざっているので、最初は混乱しますよね。
ネットワークのタブで、このリクエストを開いてみます。ステータスコードは 200 OK で、レスポンスヘッダーには Content-Length や Content-Type が並ぶ一方で、Access-Control-Allow-Origin の行が抜けています。
応答はブラウザまで届いているのに、レスポンスのタブに切り替えると、本文の代わりに取得に失敗したという表示が出ます。CORSの確認に通らなかった応答は、ページのJavaScriptだけでなく、この画面でも中身が空のままです。
では、ブラウザは何を手がかりに判定しているのでしょうか。同じリクエストのリクエストヘッダーを下へ進めると、fetch のコードでは書いていない Origin: http://app.cafe.localhost:5173 が付いています。
Origin はブラウザが自動で付けるヘッダーで、どのオリジンのページからのリクエストかをサーバーに伝えます。その下の Sec-Fetch-Mode: cors と合わせると、このリクエストはCORSの確認を受ける前提で送られたとわかります。もう1行の Sec-Fetch-Site: same-site が何を表すかは、9章の話です。
APIのログにも、リクエストはしっかり届いていました。
GET /api/menu Origin: http://app.cafe.localhost:5173 -> 200
ここまでをまとめると、APIは処理を終えて200で返していて、止めているのはその先のブラウザです。応答に Access-Control-Allow-Origin がないので、ブラウザがJavaScriptへの受け渡しを打ち切っています。
この動きからわかるとおり、CORSはブラウザの中だけで働く仕組みです。curl やサーバー同士の通信はCORSの確認を通らずにそのまま届きますし、ブラウザからのGETもAPIまで届いて処理されます。守りたいデータは、CORSとは別に、ログインの確認(認証)と権限の確認(認可)で守ります。
6. Access-Control-Allow-Originを1行足すと受け取れる
APIが応答に Access-Control-Allow-Origin を付ければ解決します。まずは、どのオリジンにも渡してよいという意味の * を付けて起動し直します。
# どのオリジンのページにも、応答を渡してよいと伝えます
CORS_ALLOW_ORIGIN='*' python3 api.py
同じく①を押すと、今度は status: 200、type: cors で、メニューの3品が結果の欄に出ました。type の cors はCORSの確認に通った別オリジンの応答という意味です。
ネットワークのタブでは、レスポンスヘッダーの先頭に Access-Control-Allow-Origin: * が増えています。変えたのはAPIの応答ヘッダー1行だけで、フロントのコードはそのままです。
公開しているメニューのように、誰に渡してもよいデータなら * で十分です。渡す先を絞るなら * の代わりに http://app.cafe.localhost:5173 のような具体的なオリジンを書きますが、書けるのは1つだけです。複数のオリジンを許すときは、サーバーがリクエストの Origin を許可リストと照らし合わせて、合っていたらその値をそのまま返します。返す値がリクエストごとに変わるので、Vary: Origin も付けて、ブラウザや配信サーバーが保存した応答のコピー(キャッシュ)を、別のオリジン向けに使い回さないようにします。
7. JSONのPOSTでは、先にプリフライトが飛ぶ
次は②でカフェラテを注文します。APIは Access-Control-Allow-Origin: http://app.cafe.localhost:5173 を返す設定に変えましたが、OPTIONS(GETやPOSTと同じHTTPのメソッドの1つで、サーバーに送り方を尋ねるときに使う)のリクエストにはまだ答えない状態です。
# 応答を渡してよいオリジンを、フロントの1つに絞ります(OPTIONSには405を返す)
CORS_ALLOW_ORIGIN='http://app.cafe.localhost:5173' python3 api.py
②を押すと、また TypeError: Failed to fetch です。ただし、エラーの文が5章と違っていて、Response to preflight request doesn't pass access control check、つまりプリフライトのリクエストへの応答が確認に通らなかった、と言っています。
ネットワークの一覧を確かめると、orders が2行あります。下の行はタイプが preflight でステータスが405、上の行はタイプが fetch でステータスが CORS エラーです。
APIのログには、OPTIONS が1行あるだけで、POST は届いていませんでした。5章のGETとは違い、今回は注文の本体がAPIへ届く前に止まっています。
OPTIONS /api/orders Origin: http://app.cafe.localhost:5173 -> 405
ここが、CORSの中でいちばんややこしいところですかね。この OPTIONS がプリフライト(preflight、事前の確認)です。ブラウザは別のオリジンへ送るリクエストのうち、決まった条件を満たさないものについて、本番の前に OPTIONS で、このメソッドとこのヘッダーで送ってもよいか、をサーバーに尋ねます。プリフライトなしで送れる条件は3つあり、条件を満たすリクエストは、古い仕様の言葉を借りて単純リクエストと呼ばれています。
②の注文は Content-Type: application/json なので、3つ目の条件から外れます。この3つは、HTMLのフォームで昔から送れたリクエストの範囲と重なる条件です。フォームで送れるリクエストはもともとどのオリジンへも送れたので、サーバー側はすでに対策しているはず、という前提で、確認を省いています。JSONのPOSTや、Authorization ヘッダーを付けたリクエストは、フォームでは送れなかった種類なので、先にサーバーの同意を取ります。
APIに、OPTIONS への答え方を足します。
def do_OPTIONS(self):
# プリフライトへの答え。どのメソッドとヘッダーを使ってよいかを返す
self.send_response(204)
self.cors_headers()
self.send_header("Access-Control-Allow-Methods", "GET, POST")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
# この答えを600秒(10分)キャッシュしてよい、と伝える
self.send_header("Access-Control-Max-Age", "600")
self.end_headers()
# OPTIONSに204で答える設定で起動し直します
CORS_ALLOW_ORIGIN='http://app.cafe.localhost:5173' CORS_PREFLIGHT=1 python3 api.py
もう一度②を押すと、status: 201、type: cors で注文が通りました。ネットワークの一覧では、preflight が204、fetch が201になっています。
ブラウザとAPIの間では、2往復のやりとりが起きています。1往復目がプリフライトで、許可が出たので2往復目で本番のPOSTを送り、201の応答を受け取っています。
プリフライトの行を開くと、リクエストメソッドは OPTIONS、ステータスコードは 204 No Content です。レスポンスヘッダーには使ってよいヘッダーの Content-Type、メソッドの GET, POST、オリジン、そして Access-Control-Max-Age: 600 が並んでいます。
Access-Control-Max-Age は、プリフライトの結果を何秒キャッシュしてよいかの指定です。付けないと既定値の5秒になり、Chromium系のブラウザ(ChromeやEdge)は、大きな値を指定しても最大2時間(7200秒)までしかキャッシュしません。キャッシュが残っている間は、同じ注文を何回送っても、本番のPOSTだけが飛びます。
本番のPOSTの行も開いておきます。リクエストメソッドは POST、ステータスコードは 201 Created で、この応答にも Access-Control-Allow-Origin が付いています。
プリフライトに許可を返しても、本番の応答に Access-Control-Allow-Origin がなければ、5章と同じエラーになります。プリフライトの応答と本番の応答の両方に付ける、と覚えておくと設定漏れを防げます。
8. Cookie付きのリクエストで増える2つの条件
ログインしているユーザーの情報を返すAPIでは、リクエストにCookieを付けたくなります。fetch は別のオリジンへのリクエストに既定でCookieを付けないので、credentials: 'include' を指定します。
まずはCookieを用意します。APIのドメインで http://api.cafe.localhost:8080/login を直接開くと、APIが会員番号のCookie(member=1024)を発行します。
このCookieはAPIのドメインに保存され、③の credentials: 'include' でAPIへ問い合わせたときに、ブラウザが自動で付けて送ります。
APIを Access-Control-Allow-Origin: http://app.cafe.localhost:5173 だけ返す設定にして③を押すと、エラーになりました。
エラーの文には、Access-Control-Allow-Credentials の値が '' で、credentials が 'include' のときは 'true' でなければならない、とあります。Cookie付きの応答を渡すには、サーバーが Access-Control-Allow-Credentials: true も返して、同意を示す必要があります。
それならと、Access-Control-Allow-Origin を * に戻して、Access-Control-Allow-Credentials: true を付けてみます。これも、別の理由でエラーです。
# * と Allow-Credentials: true の組み合わせ(ブラウザはこれを受け付けません)
CORS_ALLOW_ORIGIN='*' CORS_PREFLIGHT=1 CORS_CREDENTIALS=1 python3 api.py
今度は、credentials が 'include' のときは Access-Control-Allow-Origin をすべてを表す * にしてはいけない、と言われます。Cookie付きの応答を渡してもらうには、フロントのオリジンを名指しで許可したうえで、Allow-Credentials も返す必要があります。
オリジンを名指しして Access-Control-Allow-Credentials: true も返す設定で、起動し直します。
# オリジンを名指しして、Cookie付きの応答も渡してよいと伝えます
CORS_ALLOW_ORIGIN='http://app.cafe.localhost:5173' CORS_PREFLIGHT=1 CORS_CREDENTIALS=1 python3 api.py
③を押すと、status: 200 で、APIに届いたCookieの member=1024 が結果に返ってきました。
レスポンスヘッダーには Access-Control-Allow-Credentials: true と、名指しの Access-Control-Allow-Origin が並んでいます。この2つが両方あって、はじめてCookie付きの応答がJavaScriptに渡ります。
リクエストヘッダーのほうでは、Cookie: member=1024 と、Origin: http://app.cafe.localhost:5173 が送られています。
ちなみに、失敗した2回もAPIのログには200で残っていて、止めたのはここでもブラウザです。6章で触れた、Origin を許可リストと照らし合わせる処理を省いて、届いた Origin をそのまま返す実装にすると、Cookie付きの応答をどのサイトにも渡すことになります。3章の bank.example の場面がそのまま起きるので、Allow-Credentials を付けるAPIでは、許可リストとの照合を必ず入れます。
9. サブドメインが違うと、オリジンは別でもサイトは同じ
ドメインまわりは似た名前が続くので、1つずつ確かめていきます。api.example.co.jp というドメイン名なら、先頭の api. がサブドメイン、末尾の .co.jp が公開サフィックス(MDNの日本語版では公開接尾辞)です。公開サフィックスは誰でもその下に名前を登録できる部分のことで、co.jp や com のような値が Public Suffix List という一覧で管理されています。公開サフィックスの1つ手前までを含めた example.co.jp が、登録したドメインになります(eTLD+1 とも書きます)。
この登録したドメインにスキームを加えたものを、サイトと呼びます。オリジンより大きなまとまりで、サブドメインが違うだけの2つは、オリジンは別でも、サイトは同じです。
5章の画像で Sec-Fetch-Site: same-site が付いていたのは、app.cafe.localhost と api.cafe.localhost が、オリジンは別でも同じサイトと判定されたからです。CORSの判定はオリジンの単位なので、サブドメインが違えばCORSの設定が要ります。一方で、Cookieの SameSite 属性(そのCookieを、別のサイトから来たリクエストにも付けるかを決める設定)は、サイトの単位で判定されます。
8章のCookieは SameSite=Lax で発行していますが、同じサイトの中のリクエストなので、③の fetch にも付いて送られました。フロントとAPIを別々のドメインに置くと、オリジンもサイトも別になり、SameSite=Lax のCookieは fetch に付かなくなります。CORSのヘッダーが正しくてもCookieが届かない場合は、このサイトの判定を確かめてみてください。
10. CORSを回避する実例
CORSのエラーが出たときに取れる手は、大きく5つです。使えるのは上の3つで、下の2つはエラーが消えたように見えるだけです。
API側で許可のヘッダーを返す方法は、6章から8章で試したとおりです。自分たちで管理しているAPIなら、まずこれを選びます。
開発中だけなら、開発サーバーに取り次いでもらう手もあります。Viteの開発サーバーには、特定のパスへのリクエストを別のサーバーへ取り次ぐ server.proxy という設定があります。
import { defineConfig } from 'vite'
export default defineConfig({
server: {
allowedHosts: ['app.cafe.localhost'],
proxy: {
// ブラウザは同じオリジンの /api へ送り、開発サーバーがAPIへ取り次ぐ
'/api': { target: 'http://api.cafe.localhost:8080', changeOrigin: true },
},
},
})
APIはCORSのヘッダーを付けない最初の設定に戻し、④で、同じオリジンの /api/menu を fetch します。結果は status: 200、type: basic でメニューが取れました。type の basic は同じオリジンの応答という意味です。
ネットワークのタブを開くと、リクエストURLは http://app.cafe.localhost:5173/api/menu で、ブラウザから見た送り先はフロントと同じオリジンです。レスポンスヘッダーは Content-Type などの通常の行だけで、CORSのヘッダーは1つも付いていません。
APIのログには、Origin のないGETが残っていました。ブラウザから見れば、送り先はずっと同じオリジンなので、CORSの確認そのものが省かれます。開発サーバーからAPIへの通信はサーバー同士のやりとりで、同一オリジンポリシーの外側にあります。
GET /api/menu Origin: - -> 200
ただし、この取り次ぎは開発サーバーの機能なので、ビルドして配信する本番では消えます。本番で同じ形にするなら、リバースプロキシ(受けたリクエストを裏のサーバーへ取り次ぐサーバー)やCDN(コンテンツを配信するサーバーの網)で、ページと同じドメインの /api 以下をAPIのサーバーへ振り向けます。たとえばAWSのCloudFrontなら、パスのパターンが /api/* のリクエストだけをAPIのサーバーへ送るビヘイビア(パスごとに送り先を決める設定)を足す形です。ページとAPIが同じオリジンになるので、CORSの設定そのものが要らなくなります。
mode: 'no-cors' は、名前だけ見るとCORSを無効にできそうです。⑤を押すと、エラーは出ない代わりに、status: 0、type: opaque が返り、本文は空でした。
opaque は不透明という意味で、ステータスも本文も中身を見せない応答です。ところがネットワークのタブでレスポンスを開くと、メニューの3品がしっかり届いています。
応答は届いているのにJavaScriptには渡らない、という5章と同じ状態のまま、エラーの表示だけが消えています。このモードは画像の表示のように中身を取り出さない用途のためのものです。
ブラウザのセキュリティを切る方法は、起動オプションや拡張機能でブラウザの判定を止めるものです。自分のブラウザでエラーが消えても、利用者のブラウザでは同じエラーが出るので、問題はそのまま残ります。3章で確かめたとおり、同一オリジンポリシーは利用者を守るための決まりなので、サーバーから許可を伝えるか、同じオリジンにまとめる方向で考えます。
おわりに
ここまでお付き合いいただきありがとうございます。
CORSのエラーは、APIが答えた応答をブラウザがJavaScriptに渡していない状態で、直す場所はサーバーの応答ヘッダーでした。次に赤い英文を見かけたら、まず1行目の最後で、どのヘッダーが足りないと言われているかを確かめてみてください。
ではまた、お会いしましょう。
参考リンク
仕様と歴史
- Fetch Standard - WHATWG
- Cross-Origin Resource Sharing(2014年1月16日の勧告) - W3C
- Cross-Origin Resource Sharing(2020年6月2日、置き換え済み) - W3C
- Same-origin policy - Wikipedia
CORSと同一オリジンポリシー
- オリジン間リソース共有 (CORS) - MDN
- 同一オリジンポリシー - MDN
- オリジン - MDN
- プリフライトリクエスト - MDN
- Access-Control-Allow-Credentials - MDN
- Access-Control-Max-Age - MDN
- Vary - MDN
- Response: type プロパティ - MDN
ドメインとサイト
- サイト - MDN
- Sec-Fetch-Site - MDN
- Understanding "same-site" and "same-origin" - web.dev
- Public Suffix List - Mozilla

































