はじめに — curlは通るのにブラウザだけがブロックされる
コンソールにこの文が出たとしましょう。
Access to fetch at 'https://api.example.com/v1/orders' from origin
'https://app.example.com' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
最初にやることは、たいてい同じリクエストをターミナルで再現してみることです。
curl -i -X GET 'https://api.example.com/v1/orders' \
-H 'Origin: https://app.example.com' \
-H 'Authorization: Bearer eyJhbGciOi...'
HTTP/2 200
content-type: application/json; charset=utf-8
content-length: 1842
date: Sun, 26 Jul 2026 04:11:32 GMT
x-request-id: 9f1c2a44-3b8e-4d1a-9c77-2f0a1b6d4e55
{"items":[{"id":"ord_8812","total":49000}, ...]}
200です。サーバーはリクエストを受け取り、認証を通し、DBを照会し、JSONを返しました。データがなくて失敗したわけではありません。レスポンスにたった一行、access-control-allow-originがないだけです。
この一行の差がCORSのすべてを説明します。そしてここで、ほとんどの人が最初の誤った結論にたどり着きます。「フロントエンドの問題だからフロントエンドで直そう」という結論です。フロントエンドで直せるものはありません。サーバーが許可を表明しなかったからブラウザがブロックしたのであり、許可を表明できる主体はサーバーだけです。
CORSはサーバーのセキュリティではなくブラウザが強制する緩和ポリシーだ
同一オリジンポリシーはブラウザの既定値です。スキーム、ホスト、ポートがすべて同じであってはじめて同じオリジンであり、別オリジンのレスポンスはスクリプトが読めません。https://app.example.comとhttps://api.example.comは別オリジンであり、https://app.example.comとhttp://app.example.comも、https://app.example.comとhttps://app.example.com:8443も別オリジンです。
このポリシーがなければ何が起きるかを考えると、存在理由がはっきりします。悪意のあるサイトが、開いているタブからhttps://mail.example.com/inboxをfetchで取得して読めるとしたら、ブラウザに残っているクッキーが自動的に載って、ログイン状態のままメールボックス全体をさらっていけます。同一オリジンポリシーはこれを防ぎます。
CORSはこのポリシーを緩める装置です。締める装置ではありません。サーバーが「このオリジンから来たスクリプトが私のレスポンスを読んでよい」とレスポンスヘッダーで宣言すると、ブラウザが例外を許可します。ここから三つの事実が導かれます。
第一に、強制する主体はブラウザです。curl、Postman、サーバーサイドのfetch、モバイルアプリのHTTPクライアントは同一オリジンポリシーを実装していないので、CORSとは無関係です。「CORSでAPIを保護する」という言い方は成立しません。本物の攻撃者はブラウザを使いません。
第二に、判断の対象はレスポンスを読む行為であって、リクエストを送る行為ではありません。プリフライトが付かないリクエストは実際にサーバーへ届いて実行されます。ブラウザはそのレスポンスをスクリプトに渡さないだけです。この事実は、後でCSRFを語るときに決定的になります。
第三に、直す場所はつねにレスポンスヘッダーを作る側です。その側が自分たちのサーバーなら自分たちで直し、他社のAPIならそちらに依頼するか、自分たちのサーバーを経由させます。ブラウザ設定を変えるのは、自分のブラウザでしか通用しない自己欺瞞です。
プリフライトが発生する正確な条件
ブラウザはすべての別オリジンリクエストにOPTIONSを先に送るわけではありません。HTMLフォームでずっと以前から送れた形のリクエストはそのまま送ります。これを単純リクエストと呼び、条件は次の三つをすべて満たす場合です。
メソッドがGET、HEAD、POSTのいずれかであることです。PUT、PATCH、DELETEは無条件でプリフライトを引き起こします。
手動で設定したヘッダーが許可リストの中だけに収まっていることです。リストはAccept、Accept-Language、Content-Language、Content-Type、Range程度です。Authorizationを付けた瞬間にプリフライトが生じます。X-Requested-WithやX-Trace-Idのようなカスタムヘッダーも同じです。これが実務で最も多い発生原因です。
Content-Typeの値がapplication/x-www-form-urlencoded、multipart/form-data、text/plainのいずれかであることです。application/jsonはこのリストにありません。だからJSONをPOSTするほぼすべての現代的なAPI呼び出しはプリフライトを経験します。
付加的に、XMLHttpRequestUploadにイベントリスナーが付いている場合や、リクエストボディにReadableStreamを使う場合もやはりプリフライトが発生します。
実際のプリフライトはこういう形です。
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization,content-type
プリフライトにはボディがなく、クッキーも載らず、サーバーの認証を通る必要もありません。サーバーが返すべきなのはこういうレスポンスです。
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 7200
Vary: Origin
ここでよく壊れる箇所が二つあります。ひとつは認証ミドルウェアがOPTIONSリクエストまで捕まえて401を返してしまう場合です。プリフライトは2xxでなければ失敗として扱われるので、CORS処理は認証ミドルウェアよりも前に置かなければなりません。もうひとつはリダイレクトです。プリフライトのレスポンスに301や308が来ると、ブラウザは追わずにそのまま失敗させます。HTTPからHTTPSへのリダイレクト、末尾スラッシュを付けるリダイレクトがここに引っかかります。
Access-Control-Max-Ageはプリフライトの結果をブラウザがキャッシュする時間です。ただし上限があります。Chromeは7200秒で切り、Safariはそれよりずっと短いです。86400と書いて一日じゅうOPTIONSが飛ばないと期待すると外れます。
レスポンスヘッダーの役割とよく間違える組み合わせ
Access-Control-Allow-Originは値をひとつしか持てません。カンマで複数のオリジンを並べるのは有効ではありません。複数のオリジンを許可したいなら、サーバーがリクエストのOriginヘッダーを見て、許可リストにあるときだけその値をそのまま返す必要があります。
Access-Control-Expose-Headersはスクリプトが読めるレスポンスヘッダーを増やします。既定で読めるのはCache-Control、Content-Language、Content-Length、Content-Type、Expires、Last-Modified、Pragmaの七つだけです。ページネーションの総件数をX-Total-Countで返しているのにフロントエンドでnullが出るなら、ほぼ必ずこのヘッダーを漏らしています。サーバーログにはヘッダーがきちんと出るのにクライアントでだけ見えないという形で現れるので、原因を見つけるのがことのほか難しい種類です。
最もよく間違える組み合わせは資格情報とワイルドカードです。credentials: 'include'でクッキーを載せて送るリクエストには次のルールが適用されます。Access-Control-Allow-Originはアスタリスクではいけません。Access-Control-Allow-HeadersとAccess-Control-Allow-Methodsのアスタリスクも無効になり、Access-Control-Expose-Headersのアスタリスクも無効です。すべて明示的に列挙する必要があります。このルールには理由があります。アスタリスクを許すと、どのサイトでもユーザーのクッキーで認証されたレスポンスを読めるようになり、同一オリジンポリシーが消えるのと同じことになります。
だから次のコードは危険です。
// やらないでください — すべてのオリジンに認証済みレスポンスを読ませます
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin ?? '*')
res.setHeader('Access-Control-Allow-Credentials', 'true')
next()
})
Originをそのまま反射すると、ワイルドカード禁止のルールを形式的に回避しているだけで、実質的にはすべてのオリジンを許可したことになります。攻撃者のサイトからfetchを飛ばせばユーザーのセッションクッキーが載り、レスポンスがそのまま読まれます。必ず許可リストで検査してください。
const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com'])
app.use((req, res, next) => {
const origin = req.headers.origin
// 許可されないオリジンであってもVaryはつねに付けます
res.setHeader('Vary', 'Origin')
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Access-Control-Allow-Credentials', 'true')
res.setHeader('Access-Control-Expose-Headers', 'X-Total-Count, RateLimit-Remaining')
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE')
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type')
res.setHeader('Access-Control-Max-Age', '7200')
return res.status(204).end() // 認証ミドルウェアの前で終わらせます
}
next()
})
許可リストを正規表現で作るときは、ドットをエスケープして文字列の末尾を固定してください。サブドメインをまとめて許可しようとして雑に書いたパターンは、https://example.com.attacker.ioのような値を通してしまいます。実際にこの間違いによるアカウント乗っ取りの事例が何度も公開されています。可能なら正規表現ではなく文字列の集合で比較するほうが安全です。
Origin: nullも許可リストに入れてはいけません。sandbox属性の付いたiframe、ローカルファイル、一部のリダイレクト状況で付く値ですが、攻撃者が自分のページにsandbox iframeをひとつ出すだけでいつでも作り出せます。
Varyを漏らすとキャッシュが汚染される
Originによってレスポンスヘッダーが変わるのにVary: Originがないと、途中のCDNやリバースプロキシはURLだけを見てレスポンスを再利用します。するとこういう事故が起きます。adminオリジンから来たリクエストが作ったレスポンスがキャッシュに入り、次にappオリジンから同じURLをリクエストするとAccess-Control-Allow-Origin: https://admin.example.comが入ったレスポンスが出ていきます。appではCORSエラーになり、キャッシュが期限切れになるまで続きます。
症状がとりわけ厄介です。再現せず、リロードすると直ることもあり、特定リージョンのユーザーにだけ発生します。逆方向の事故もあります。サーバーサイドでOriginなしにリクエストしたレスポンスがキャッシュされると、CORSヘッダーがまったくないレスポンスが保存され、その後のすべてのブラウザリクエストがブロックされます。
だからOriginを反射するサーバーは、条件と無関係につねにVary: Originを付けなければなりません。許可していないオリジンに対しても付けてください。
エラーメッセージ別の原因解読
ブラウザのコンソールメッセージは思ったより正確に原因を指し示します。よく見るものを挙げます。
| コンソールメッセージの核心フレーズ | 実際の原因 | 直す場所 |
|---|---|---|
| No Access-Control-Allow-Origin header is present | サーバーがヘッダーを送っていない。5xxで落ちてCORSミドルウェアに到達しなかった場合を含む | サーバーのレスポンスヘッダー。まずcurlでステータスコードから確認 |
| Response to preflight request does not have HTTP ok status | OPTIONSが401、404、405、500を返した | CORS処理を認証ミドルウェアより前へ。OPTIONSルートの登録 |
| Redirect is not allowed for a preflight request | OPTIONSのレスポンスが301または308 | HTTPS強制リダイレクトや末尾スラッシュ正規化がOPTIONSに掛からないように |
| Request header field authorization is not allowed | Access-Control-Allow-Headersに該当ヘッダーが抜けている | プリフライトレスポンスの許可ヘッダー一覧 |
| Method PATCH is not allowed by Access-Control-Allow-Methods | 許可メソッド一覧の抜け | プリフライトレスポンスの許可メソッド一覧 |
| must not be the wildcard when credentials mode is include | アスタリスクを使いながらクッキーを送っている | オリジンを明示値に。許可リスト検査の追加 |
| contains multiple values, but only one is allowed | プロキシとアプリケーションがそれぞれヘッダーを付けている | 一箇所だけで付けるよう整理。たいていnginxとアプリの両方に設定されている |
| Origin null is not allowed | fileプロトコル、sandbox iframe、リダイレクト後のリクエスト | ローカル開発サーバーの利用。nullを許可リストに入れないこと |
一覧のいちばん上のものが最も多く、最も誤解されます。このメッセージは「サーバーがCORSを設定していない」という意味のこともありますが、「サーバーが500で落ちてヘッダーを付けるミドルウェアまで到達できなかった」という意味である場合が非常に多いです。CORSエラーが出たら必ずネットワークタブで実際のステータスコードを先に見るか、curlで確認してください。500をCORSの問題だと勘違いして何時間もさまようことはよくあります。
プロキシ回避 — 正当な場合とそうでない場合
同一オリジンにしてしまえばCORSはそもそも発生しません。だからプロキシはいつでも効きます。問題は、いつそれが設計で、いつ回避なのかです。
正当な場合があります。第一に、サードパーティAPIがCORSヘッダーを送らず、私たちがそのサーバーを直せないときです。第二に、APIキーを隠さなければならないときです。ブラウザに降りた鍵は公開された鍵なので、この場合サーバー経由は回避ではなく唯一正しい構造です。第三に、複数のバックエンドをひとつのオリジンの下にまとめるゲートウェイやBFFをすでに運用しているときです。第四に、開発環境でdevサーバーがAPIをプロキシする場合です。
// vite.config.js — 開発中は同一オリジンにしてしまいます
export default {
server: {
proxy: {
'/api': {
target: 'https://api-dev.example.com',
changeOrigin: true,
},
},
},
}
正当でない場合もはっきりしています。自分たちが統制しているバックエンドなのに、ヘッダー三行を入れるのが面倒でプロキシを立てるのは、インフラを一枚と遅延を永久に追加する選択です。公開されたCORSプロキシサービスを本番で使うのは、ユーザーのトークンとデータを第三者のサーバーへ流すことであり、そのサーバーが落ちれば自分たちのサービスも一緒に落ちます。
mode: 'no-cors'も解法ではありません。エラーは消えますが、opaqueなレスポンスが返ってきてステータスコードもボディも読めません。画像やスクリプトを副作用として読み込む場合でなければ使い道がないのに、エラーがなくなったという理由で直したと錯覚しやすいです。
ブラウザのセキュリティを切れという助言
検索すれば必ず上位に出てくる助言があります。--disable-web-securityフラグでChromeを起動しろというものです。三つの理由で悪いです。
そのプロファイルのすべてのタブで同一オリジンポリシーが消えます。開発中に開いておいた他のタブが全部無防備になります。習慣になると、ふだん使うブラウザでもそのフラグを立てるようになります。
本番に存在しない環境で開発することになります。資格情報とワイルドカードの組み合わせ、Varyの欠落、公開ヘッダーの欠落といった問題が全部隠れたまま、ステージングや本番で一度に噴き出します。
問題を先送りするだけで何も解決しません。どうせリリース前にサーバーのヘッダーを直さなければならず、そのときには積み上がった誤解まで一緒にほどく必要があります。
代案は簡単です。devサーバーのプロキシを使うか、開発用オリジンをサーバーの許可リストに追加すればよいのです。後者のほうが優れています。本番と同じ経路で検証されるからです。
CORSはCSRFを防いでくれない
最も危険な誤解です。「CORSを設定したから他のサイトからうちのAPIを呼べない」という言い方は間違いです。CORSはレスポンスを読むことを防ぐのであって、リクエストが実行されることを防ぎません。
攻撃者のページにこういうフォームがあるとしましょう。
<!-- evil.example.com — CORSはこのリクエストをまったく防ぎません -->
<form action="https://bank.example.com/transfer" method="POST">
<input name="to" value="attacker" />
<input name="amount" value="1000000" />
</form>
<script>
document.forms[0].submit()
</script>
フォーム送信はContent-Typeがapplication/x-www-form-urlencodedのPOSTなので単純リクエストです。プリフライトがありません。ブラウザはbank.example.comのクッキーを載せてリクエストを送り、サーバーはログイン済みユーザーのリクエストとして処理して送金を実行します。攻撃者はレスポンスを読めませんが、読む必要がありません。すでにお金が移っています。
CSRFを防ぐのは別の装置たちです。
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/
SameSite=Laxはクロスサイトから来たPOSTにクッキーを載せません。最新ブラウザの既定値でもあるので、かなりの数の古典的CSRFがすでに防がれています。ただしサイト単位だという点は知っておく必要があります。app.example.comとapi.example.comは別オリジンですが同じサイトなので、SameSiteはこの二つを区別しません。サブドメインがひとつ破られると防御が消えます。
だから状態を変えるリクエストにはCSRFトークンを併用します。サーバーが発行した値をリクエストボディやカスタムヘッダーに載せて送らせ、サーバーが照合する方式です。カスタムヘッダーを要求すること自体がプリフライトを強制するので副次的な防御にもなりますが、これだけを信じるには根拠が弱いです。JSONだけを受け付けると宣言してフォーム系のContent-Typeを拒否するのも同じ性格の補助手段です。
整理すると、CORSとCSRFは向きが逆の問題です。CORSは他人のデータを読み取っていくことを、CSRF対策は他人の名前で書き込むことを防ぎます。片方を設定したからといってもう片方が解決するわけではありません。
おわりに — ブラウザが教えてくれたのはサーバーの問題だ
CORSエラーに出会ったときの順序はこうです。curlで同じリクエストを送り、実際のステータスコードとレスポンスヘッダーを確認します。500ならCORSの問題ではなくサーバーエラーです。200なのにヘッダーがなければサーバーのCORS設定の問題です。プリフライトが回るリクエストならOPTIONSのレスポンスを別途確認します。そのあとコンソールメッセージの核心フレーズを先ほどの表で探します。
CORSエラーはブラウザがサーバー設定の欠陥を教えてくれる信号であり、信号を消すことは欠陥を直すことと違います。フロントエンドのコード、ブラウザのフラグ、拡張機能に答えを求める時間が長くなるほど、正解から遠ざかります。直す場所はレスポンスヘッダーを作るサーバー一箇所だけです。
현재 단락 (1/121)
コンソールにこの文が出たとしましょう。