TL

CORSプリフライトの判定アルゴリズムと単純リクエスト

なぜ GET は通るのに JSON の POST だけ OPTIONS が飛ぶのかが分かる。単純リクエストの厳密な条件、プリフライト発火の境界、Access-Control 系ヘッダの照合手続きとキャッシュ秒数の効き方を原理から解きます。

応用CORSWebセキュリティブラウザHTTP最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.プリフライトは「単純リクエストか」の判定で発火が決まる。メソッドが GET/HEAD/POST、ヘッダが CORS-safelisted のみ、Content-Type が3種のいずれか、を全て満たせば単純で、OPTIONS は飛ばない。1つでも外れると OPTIONS が先行する。
  • 2.OPTIONS でブラウザは Access-Control-Request-Method と Access-Control-Request-Headers を申告し、サーバーの Allow-Methods/Allow-Headers と照合する。資格情報付きなら Allow-Origin は具体名必須で * は不可。
  • 3.Access-Control-Max-Age はプリフライト結果のキャッシュ秒数。同一オリジン・URL・メソッド・ヘッダの組に対し OPTIONS を省略させるが、上限はブラウザ実装で頭打ちされる。

CORS でいちばん混乱を生むのは「いつ OPTIONS が飛ぶのか」です。GET は素通りするのに JSON の POST だけ事前確認が走る、独自ヘッダを1つ足した途端に往復が増える――これらは気分やブラウザの機嫌ではなく、Fetch 標準が定める単純リクエスト(CORS-safelisted request)かどうかの厳密な判定で決まっています。ここではその判定アルゴリズムと、プリフライト発火後に走る Access-Control-* ヘッダの照合手続き、そして Access-Control-Max-Age によるキャッシュの効き方を、原理から正確に追います。前提となる仕組みは CORS(オリジン間リソース共有)同一オリジンポリシーとサイト分離の信頼境界 を押さえておくとつながります。

単純リクエストの厳密な条件

「単純リクエスト」は俗称で、標準では各条件の論理積で定義されます。次のすべてを満たすときだけ、リクエストは単純とみなされ、プリフライトを伴いません。

条件満たす値外れると
メソッドGET / HEAD / POST のいずれかPUT/DELETE/PATCH 等は即プリフライト
ヘッダCORS-safelisted ヘッダのみ独自ヘッダを1つでも足すとプリフライト
Content-Type下記3種のいずれかapplication/json 等はプリフライト
読み取りモードReadableStream ボディでない 等ストリーム送信などは対象外

ここで言う CORS-safelisted ヘッダは、AcceptAccept-LanguageContent-LanguageContent-TypeRange(一部制約付き)など、ブラウザが昔から <form> 送信で付けてきた安全なヘッダの集合です。AuthorizationX-Requested-With のような追加ヘッダは、たとえ1つでもこの集合の外なのでプリフライトを誘発します。

Content-Type には特に注意が要ります。safelisted と認められるのは値が application/x-www-form-urlencodedmultipart/form-datatext/plain のいずれかのときだけです。application/json はこの3種に含まれないため、JSON ボディの POST は他がすべて単純でも必ずプリフライトされます。「GET は通るのに POST だけ落ちる」の正体はほぼこれです。

POST は単純になり得る

よくある誤解は「POST は常にプリフライトされる」。違います。メソッド POST 自体は safelisted です。プリフライトを呼ぶのは Content-Type が3種の外(典型は application/json)だったり、独自ヘッダを足したりした場合。text/plain で投げる POST は単純リクエストになり OPTIONS は飛びません。

プリフライト発火の境界

ブラウザは実際のリクエストを送る前に、内部で「このリクエストは単純か」を評価します。判定の擬似コードはおおむね次の形です。波括弧の集合は安全のためインラインコードで示します。

needsPreflight(req):
  if req.method not in {GET, HEAD, POST}: return true
  for h in req.headers:
    if h not in CORS_SAFELISTED: return true
  ct = req.headers["Content-Type"]
  if ct and parse(ct) not in
     {application/x-www-form-urlencoded, multipart/form-data, text/plain}:
    return true
  if req.usesReadableStreamBody or req.hasNonSafelistedSemantics: return true
  return false

この関数が true を返すと、ブラウザは本番リクエストの前に OPTIONS を1本送ります。重要なのは、この判定はクロスオリジンのときだけ意味を持つ点です。同一オリジンへの fetch では同一オリジンポリシーの制約自体がかからないため、needsPreflight の結果に関わらず OPTIONS は発生しません。プリフライトはあくまで「別オリジンに、単純でないリクエストを送ってよいか」をサーバーに事前確認する手続きです。

プリフライト前は本番が一切届かない

単純リクエストの場合、本番リクエスト自体はサーバーに到達します(読めないだけ)。一方プリフライトを伴う場合、OPTIONS が許可されない限り本番は一度も送られません。つまり PUT による更新などの副作用は、プリフライトの段階で確実に止まります。サーバー側で OPTIONS をルーティングし損ねて 404/405 を返すと、本番が届く前にここで詰みます。

OPTIONS の中身と照合手続き

プリフライトの OPTIONS には本文がなく、代わりに「これから何を送りたいか」を申告するリクエストヘッダが乗ります。サーバーはこれを見て Access-Control-Allow-* で可否を返し、ブラウザがその応答を照合します。

OPTIONS /items/1 HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600

ブラウザ側の照合アルゴリズムは次のように進みます。1つでも不成立なら本番には進まず、CORS エラーになります。

照合ステップブラウザが確認すること失敗時
Origin 一致Allow-Origin が呼び出し元と一致(または *)Origin が許可されていない
メソッド許可Request-Method が Allow-Methods に含まれるメソッドが許可されない
ヘッダ許可Request-Headers の各要素が Allow-Headers に含まれるそのヘッダが許可されない
資格情報整合credentials 付きなら Allow-Credentials: true かつ Allow-Origin が具体名wildcard と Cookie の併用エラー

照合の細部にいくつか落とし穴があります。Access-Control-Request-Headers の値は小文字に正規化され、ソートされて送られます。サーバーが Allow-Headers で返す名前は大文字小文字を区別せず突き合わされますが、値そのものは省略できません。クライアントが申告したヘッダのうち1つでも Allow-Headers に欠けると照合は失敗します。また Allow-Methods / Allow-Headers には *(ワイルドカード)も使えますが、資格情報付きリクエストでは * は文字どおりの記号として扱われ、ワイルドカードの意味を失います。Cookie を送るなら具体名で列挙する必要があります。

資格情報付きでの * は全面的に無効

credentials を含むリクエストでは、Allow-Origin も Allow-Methods も Allow-Headers も * をワイルドカードとして解釈しません。Allow-Origin は呼び出し元オリジンの具体名、Allow-Headers は実際に送るヘッダ名の列挙が必須で、加えて Allow-Credentials: true が要ります。さらに Cookie を反映させる Set-Cookie 系では、レスポンスに Vary: Origin を付けてキャッシュ汚染を防ぐのが定石です。

Access-Control-Max-Age とキャッシュ

毎リクエストごとに OPTIONS を往復させると、レイテンシが単純に2倍近くになります。これを抑えるのが Access-Control-Max-Age で、プリフライト結果をプリフライトキャッシュに保持する秒数を指定します。キャッシュが有効な間、ブラウザは同じ条件のプリフライトを省略し、本番だけを送ります。

キャッシュのキーは「オリジン」「リクエスト URL」「メソッド」「申告ヘッダの集合」の組です。したがって URL が違えば別エントリですし、同じ URL でもメソッドやヘッダの組が変われば再びプリフライトが走ります。実務では「GET には OPTIONS が出ないのに、新しい独自ヘッダを足した瞬間に1回だけ OPTIONS が復活した」という挙動として観測されます。

max-age には実装上の上限がある

Access-Control-Max-Age に巨大な値を指定しても、ブラウザ側の上限で頭打ちされます。たとえば Chromium 系は2時間(7200秒)、Firefox は24時間(86400秒)を上限とする実装で、これを超える秒数は切り詰められます。0 を指定するとキャッシュ無効(毎回プリフライト)、ヘッダ未指定時は既定の5秒として扱われます。サーバーで大きな値を返しても「効いていない」場合、この上限を疑います。

プリフライトキャッシュは HTTP キャッシュ とは別物で、Cache-Control や中間プロキシのキャッシュとは独立にブラウザ内部で管理されます。エントリは資格情報モードごとにも分かれるため、credentials: 'include' の有無が違えば別キャッシュです。

実装側の最短経路

呼び出し側のコードからは、プリフライトの有無はほぼ自動で決まります。明示的に OPTIONS を書くことはなく、ブラウザが needsPreflight を評価して必要なら挿入します。

// JSON の PUT → Content-Type が application/json かつ PUT なので必ずプリフライト
const res = await fetch("https://api.example.com/items/1", {
  method: "PUT",
  headers: {
    "Content-Type": "application/json", // 3種の外 → 単純でない
    "Authorization": "Bearer ...",       // safelisted 外 → 単純でない
  },
  credentials: "include",                // Cookie を送るなら指定
  body: JSON.stringify({ name: "demo" }),
});

逆に、どうしてもプリフライトを避けたい局面(計測ビーコン等で往復を1回に抑えたい等)では、text/plain で送り独自ヘッダを付けないことで単純リクエストに収める設計が取れます。ただしサーバー側で本文を JSON として解釈する手当てが要り、可読性とのトレードオフになります。modecredentials の独立した軸については Fetch API の内部 で詳しく扱っています。

まとめ

まとめ

プリフライトの発火は気分ではなく単純リクエスト判定の論理積で決まります。メソッドが GET/HEAD/POST、ヘッダが CORS-safelisted のみ、Content-Type が application/x-www-form-urlencoded / multipart/form-data / text/plain のいずれか――この全部を満たせば単純で OPTIONS は飛ばず、1つでも外れると本番の前に OPTIONS が先行します。application/jsonAuthorization はこの境界を越える典型例です。プリフライトでは Access-Control-Request-Method / Request-Headers を申告し、サーバーの Allow-Methods / Allow-Headers / Allow-Origin と照合します。資格情報付きでは * がワイルドカードとして無効になり、具体名と Allow-Credentials: true が必須です。Access-Control-Max-Age は「オリジン・URL・メソッド・ヘッダ」の組ごとにプリフライト結果をキャッシュして OPTIONS を省略させますが、ブラウザ実装の上限で頭打ちされます。背景は CORS同一オリジンポリシー、呼び出し側の作法は Fetch API の内部 と合わせて把握すると、OPTIONS の発火がすべて一本の線でつながります。

Web/フロントエンド Article

CORSプリフライトの判定アルゴリズムと単純リクエストを実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

CORS

比較で見る軸

難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5

導入後に効く点

OPTIONS でブラウザは Access-Control-Request-Method と Access-Control-Request-Headers を申告し、サーバーの Allow-Methods/Allow-Headers と照合する。資格情報付きなら Allow-Origin は具体名必須で * は不可。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
Web/フロントエンド
タグ数
5

判断チェックリスト

  • 自社の用途が「CORS / Web」に近いか確認する。
  • 強みである「プリフライトは「単純リクエストか」の判定で発火が決まる。メソッドが GET/HEAD/POST、ヘッダが CORS-safelisted のみ、Content-Type が3種のいずれか、を全て満たせば単純で、OPTIONS は飛ばない。1つでも外れると OPTIONS が先行する。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

CORSWebセキュリティブラウザHTTPCORSWebセキュリティ
参考: 公式情報