CORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答

CORS OPTIONS API HTTP フロントエンド
結論

プリフライトは本番リクエストの前にブラウザが自動送信する OPTIONS リクエスト だ。JSON API・Bearer 認証・PUT/DELETE を使う現代のフロントエンドでは避けられない。サーバーは OPTIONS に対し Access-Control-Allow-MethodsAllow-Headers・必要なら Allow-CredentialsMax-Age を返し、認証ミドルウェアで OPTIONS を 401 にしない ことが通過条件になる。

プリフライト(OPTIONS)とは何か

クロスオリジン(例: https://app.example.comhttps://api.example.com)で API を呼ぶとき、ブラウザは条件によって 本番リクエストの前に OPTIONS を1本差し込む。これが CORS プリフライトリクエスト(Preflight Request)だ。

プリフライトの目的は「このオリジンから、この HTTP メソッドで、このカスタムヘッダーを付けて送っても安全か」をサーバーに確認すること。OPTIONS が 200 または 204 で返り、必要な CORS レスポンスヘッダーが揃えば、ブラウザは続けて POST や PUT などの本番リクエストを送る。1つでも欠けると本番リクエストは送られず、コンソールに CORS エラーが出る。

DevTools の Network タブでは、同じ URL に OPTIONS(preflight)POST(actual request) が連続して並ぶ。OPTIONS だけ 401/404/405 になっているケースが、実務で最も多いプリフライト障害だ。

OPTIONS はブラウザが自動で送る

アプリケーションコードで fetch(url, { method: 'OPTIONS' }) を書く必要はない。Authorization 付きの POST や Content-Type: application/json を指定した瞬間、ブラウザが裏で OPTIONS を発行する。サーバーが OPTIONS を処理できていなければ、フロントの fetch コードが正しくても必ず失敗する。

単純リクエスト vs プリフライト — 判定の境界線

プリフライトが 発生しない のは「単純リクエスト(Simple Request)」だけ。次の すべて を満たす場合、ブラウザは OPTIONS を送らず直接リクエストする。

  • HTTP メソッドが GETHEADPOST のいずれか
  • ブラウザが許可したヘッダー(AcceptAccept-LanguageContent-LanguageContent-Type など)以外の カスタムヘッダーがない
  • Content-Type が次のいずれか: application/x-www-form-urlencodedmultipart/form-datatext/plain

1つでも外れるとプリフライトが走る。 現代の SPA + REST API では、ほぼすべての API 呼び出しがプリフライト対象になると考えてよい。

リクエストの種類 プリフライト
GET(カスタムヘッダーなし) なし(単純リクエスト)
GET + Authorization: Bearer ... あり(カスタムヘッダー)
POST + application/x-www-form-urlencoded なし(単純リクエスト)
POST + Content-Type: application/json あり(Content-Type が対象外)
POST + Authorization + application/json あり(両方の理由)
PUT / PATCH / DELETE あり(非単純メソッド)
fetch(url, { credentials: 'include' }) あり(資格情報モードは追加チェック)
POST + X-Request-Id など独自ヘッダー あり(カスタムヘッダー)

フロント側: Authorization 付き fetch の例

Bearer トークン付き JSON POST は、単純リクエストの条件をすべて外れる典型パターンだ。

const response = await fetch('https://api.example.com/v1/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${accessToken}`,
  },
  credentials: 'include', // Cookie も送る場合
  body: JSON.stringify({ name: 'Alice' }),
});

このコード1行を実行するだけで、ブラウザは先に OPTIONS を https://api.example.com/v1/users へ送る。OPTIONS が通らなければ、上記 fetch は TypeError: Failed to fetch 相当の CORS エラーで reject される。

完全な OPTIONS リクエスト/レスポンスの読み方

実際のプリフライトでは、ブラウザが 本番リクエストの予告 を OPTIONS のリクエストヘッダーに載せる。サーバー実装者が必ず読むべき3つは次のとおり。

リクエストヘッダー意味
Originページのオリジン(例: https://app.example.com
Access-Control-Request-Method本番で使う HTTP メソッド(例: POST
Access-Control-Request-Headers本番で付ける予定のヘッダー名(小文字・カンマ区切り)

リクエストダンプ(ブラウザ → サーバー)

OPTIONS /v1/users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
Connection: keep-alive
User-Agent: Mozilla/5.0 ...
Accept: */*
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Referer: https://app.example.com/dashboard
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
Sec-Fetch-Dest: empty

正しいレスポンスダンプ(サーバー → ブラウザ)

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400
Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers
Content-Length: 0

ポイントは Access-Control-Request-Headers に列挙された名前を、Access-Control-Allow-Headers がすべてカバーしていることauthorization, content-type が来ているのに Allow-Headers が Content-Type だけ、という不一致が頻出バグだ。大文字小文字はブラウザ側で正規化されるが、サーバーは要求されたヘッダー名を許可リストに含める必要がある。

本番リクエスト用に Access-Control-Allow-Origin だけ設定していても、OPTIONS 応答に Allow-Methods / Allow-Headers が欠けていればプリフライト段階で弾かれる

Access-Control-Max-Age とプリフライトキャッシュ

Access-Control-Max-Age は、同一条件のプリフライト結果をブラウザがキャッシュする秒数 を指定するレスポンスヘッダーだ。キャッシュが有効な間、同じ URL・同じオリジン・同じメソッド・同じヘッダー構成であれば OPTIONS を再送しない。

キャッシュの挙動

  • Max-Age 未設定または 0: 毎回 OPTIONS が飛ぶ。DevTools で OPTIONS が大量に並ぶ原因の一つ。
  • Max-Age: 86400(24時間): 本番環境でよく使う値。API 呼び出しが多い SPA ではレイテンシ削減に効く。
  • ブラウザ上限: Chromium 系は最大 7200 秒(2時間)にクリップされる等、ブラウザ実装ごとに上限がある。Firefox は仕様通りの値を尊重する傾向がある。
  • 開発時の注意: DevTools の Disable cache を ON にしていると Max-Age が無視され、毎回 OPTIONS が走る。本番挙動と混同しないこと。

キャッシュキーに含まれる要素

ブラウザはおおよそ次の組み合わせでキャッシュを判定する。

  • リクエスト URL(パス + クエリ)
  • Origin
  • Access-Control-Request-Method
  • Access-Control-Request-Headers の内容

URL が1文字でも変われば別キャッシュ になる。/api/users/api/users?page=2 は別扱いだ。マイクロサービス化でエンドポイントが細分化されるほど、プリフライトキャッシュの効率は落ちる。

Vary ヘッダーを忘れない

CDN や共有キャッシュの前段に Nginx 等がある場合、Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers を付けないと、別オリジンのプリフライト応答が混ざるリスクがある。Express cors パッケージは適切に Vary を付与する。

fetch の credentials オプション(XHR では withCredentials)は、クロスオリジンでも Cookie や Client Certificate を送るか を制御する。

挙動
omitCookie を送らない(デフォルトの same-origin 以外)
same-origin同一オリジンのみ Cookie 送信
includeクロスオリジンでも Cookie 送信

credentials: 'include' を使う場合、サーバーは次 両方 を満たす必要がある。

  1. レスポンスに Access-Control-Allow-Credentials: true
  2. Access-Control-Allow-Origin* ではなく 具体的なオリジン(例: https://app.example.com)を返す

Authorization: Bearer ... ヘッダーは開発者が明示的に付けるため Cookie とは別だが、Allow-Credentials: true と具体的 Allow-Origin の組み合わせ は同じルールが適用される。複数フロントオリジンを許可する場合、リクエストの Origin を見て動的に Allow-Origin を返す実装が必要で、* 一発設定は使えない。

// Cookie ベースセッション + クロスオリジン
await fetch('https://api.example.com/v1/me', {
  method: 'GET',
  credentials: 'include',
});

サーバー側 Express 例(後述)では credentials: true を cors オプションに渡し、許可オリジンを配列またはコールバックで絞る。

認証ミドルウェアが OPTIONS を 401 にする落とし穴

JWT やセッション検証を 全ルートに一括適用 していると、OPTIONS が認証なしで届き 401 Unauthorized になり、プリフライトが失敗する。フロントから見ると「トークンは付けているのに CORS エラー」に見えるが、本番 POST 以前の OPTIONS が死んでいるのが原因だ。

なぜ OPTIONS に Authorization が来ないのか

プリフライト OPTIONS は 「これから送る予定のリクエストの許可確認」 であり、本番と同じヘッダーをすべて載せるわけではない。Access-Control-Request-Headers: authorization は「本番で Authorization を付けたい」という 宣言 であって、OPTIONS 自体に Authorization: Bearer ... が付くとは限らない。認証ミドルウェアが「Authorization ヘッダー必須」としていると OPTIONS が弾かれる。

Express での定石 — OPTIONS だけスキップ

import express from 'express';
import cors from 'cors';

const app = express();

const corsOptions = {
  origin: ['https://app.example.com', 'https://staging.example.com'],
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
  credentials: true,
  maxAge: 86400,
};

// CORS は認証より先に適用する
app.use(cors(corsOptions));
app.options('*', cors(corsOptions)); // 明示的 OPTIONS ハンドル

// 認証ミドルウェア — OPTIONS を除外
function authMiddleware(req, res, next) {
  if (req.method === 'OPTIONS') return next();
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) return res.status(401).json({ error: 'Unauthorized' });
  // JWT 検証...
  next();
}

app.use('/v1', authMiddleware);
app.post('/v1/users', (req, res) => { /* ... */ });

ミドルウェアの適用順序 が重要だ。app.use(authMiddleware)app.use(cors()) より上に置くと、CORS ヘッダーが 401 レスポンスに付かず、ブラウザは「CORS エラー」と「401」の両方の情報を混乱して表示することがある。

Express・Nginx・Spring での実装例

Express — cors パッケージの本番向け設定

import cors from 'cors';

app.use(cors({
  origin: (origin, callback) => {
    const allowed = [
      'https://app.example.com',
      'https://admin.example.com',
    ];
    // curl 等 Origin なしは許可(サーバー間通信)
    if (!origin || allowed.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
  exposedHeaders: ['X-Total-Count'], // フロントが読めるレスポンスヘッダー
  credentials: true,
  maxAge: 86400,
}));

exposedHeaders は別論点だが、ページネーションの X-Total-Count 等を JavaScript から読む場合に必要になる。

curl — OPTIONS を手動再現(全ヘッダー明示)

ブラウザを介さずサーバーを直接テストする定番コマンド。プロキシ層とアプリ層の切り分けに使う。

curl -i -X OPTIONS 'https://api.example.com/v1/users' \
  -H 'Origin: https://app.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization, content-type' \
  -H 'Accept: */*'

期待する出力の要点:

  • ステータス 204 または 200(405/401/404 は NG)
  • Access-Control-Allow-Origin: https://app.example.com
  • Access-Control-Allow-MethodsPOST が含まれる
  • Access-Control-Allow-Headersauthorizationcontent-type が含まれる

curl で成功しブラウザだけ失敗する場合、中間の CDN・WAF・ALB が OPTIONS をブロック している可能性が高い。

Nginx — add_header で CORS を付与

アプリではなくリバースプロキシで CORS を返す構成。location 内で OPTIONS を早期 return するパターンが多い。

map $http_origin $cors_origin {
    default "";
    "https://app.example.com"  $http_origin;
    "https://staging.example.com" $http_origin;
}

server {
    listen 443 ssl;
    server_name api.example.com;

    location /v1/ {
        if ($request_method = OPTIONS) {
            add_header Access-Control-Allow-Origin $cors_origin always;
            add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Request-Id" always;
            add_header Access-Control-Allow-Credentials "true" always;
            add_header Access-Control-Max-Age 86400 always;
            add_header Content-Length 0;
            add_header Content-Type text/plain;
            return 204;
        }

        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;

        proxy_pass http://backend_upstream;
    }
}

always を付けないと 4xx/5xx レスポンスに CORS ヘッダーが載らず、エラー時だけブラウザが CORS エラー表示になる罠がある。

Spring Boot — @CrossOrigin

@RestController
@RequestMapping("/v1/users")
@CrossOrigin(
    origins = {"https://app.example.com", "https://staging.example.com"},
    methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT,
               RequestMethod.PATCH, RequestMethod.DELETE, RequestMethod.OPTIONS},
    allowedHeaders = {"Authorization", "Content-Type", "X-Request-Id"},
    allowCredentials = "true",
    maxAge = 86400
)
public class UserController {

    @PostMapping
    public ResponseEntity<User> create(@RequestBody CreateUserRequest req) {
        // ...
    }
}

グローバル設定する場合は WebMvcConfigurer#addCorsMappings を使う。Spring Security を併用している場合、Security フィルタチェーンで OPTIONS を permitAll() にしないと再び 401 になる。

http.authorizeHttpRequests(auth -> auth
    .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
    .anyRequest().authenticated()
);

API Gateway・CDN で OPTIONS を処理する

AWS API Gateway、Cloudflare、Azure API Management などでは、OPTIONS がバックエンドに到達する前にゲートウェイ層で処理 されることがある。

よくある構成パターン

構成OPTIONS の処理場所注意点
Gateway CORS 設定 ONゲートウェイが直接 204 + CORS ヘッダーバックエンドに OPTIONS が届かない。Allow-Headers の設定漏れに注意
Gateway CORS OFF + バックエンドLambda/ECS 等が OPTIONS を処理ゲートウェイが OPTIONS を 403/Missing Authentication にしないか確認
Cloudflare Transform Rulesエッジでヘッダー追加オリジンとエッジの二重 CORS ヘッダーで値が重複しないか

API Gateway(REST API)で CORS を有効にすると、コンソールが OPTIONS メソッドと MOCK 統合を自動追加する。しかし Allow-Headers に Authorization が含まれていない ままデプロイし、後からフロントが Bearer 認証を足した——という順序ミスが非常に多い。Gateway の CORS 設定とバックエンドの cors ミドルウェアが 二重に異なる値 を返すと、ブラウザは不正なレスポンスとして拒否する。

Cloudflare Workers で API を組む場合も同様だ。

// Workers — OPTIONS を先に処理
export default {
  async fetch(request) {
    if (request.method === 'OPTIONS') {
      return new Response(null, {
        status: 204,
        headers: {
          'Access-Control-Allow-Origin': 'https://app.example.com',
          'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
          'Access-Control-Allow-Headers': 'Authorization, Content-Type',
          'Access-Control-Allow-Credentials': 'true',
          'Access-Control-Max-Age': '86400',
        },
      });
    }
    // 本番リクエスト処理...
  },
};

インフラとアプリのどちらで OPTIONS を終端するかを決め、一方に統一 するのが運用上ラクだ。

デバッグ手順 — 原因切り分けの流れ

プリフライト障害は層が多い。上から順に潰す と最短で原因に辿り着く。

ステップ1 — ブラウザ Network で OPTIONS を特定する。 失敗している fetch の直前に OPTIONS があるか確認する。なければ単純リクエストか、同一オリジン呼び出し。OPTIONS があるならその行を開き、ステータスコードと Response Headers を読む。

ステップ2 — ステータスコードで分岐する。 200/204 なら CORS ヘッダー不足が疑わしい。401 なら認証ミドルウェア。404 ならルーティング未登録。405 なら OPTIONS メソッド未許可。502/503 なら上流障害。

ステップ3 — ヘッダー照合する。 Request Headers の Access-Control-Request-Method と Response の Access-Control-Allow-Methods を比較。Access-Control-Request-Headers の各名前が Access-Control-Allow-Headers に含まれるか確認。credentials: 'include' なら Allow-Credentials: true と具体的 Allow-Origin を確認。

ステップ4 — curl で直叩きする。 前述の curl -X OPTIONS を本番 URL に対して実行。成功すれば ブラウザとサーバーの間(CDN・WAF・ALB・Nginx) が怪しい。失敗すれば アプリまたはその手前の Nginx が怪しい。

ステップ5 — ミドルウェア順序と Gateway 設定を見直す。 Express なら cors → auth の順。Spring Security なら OPTIONS permitAll。API Gateway なら CORS 設定の Allow-Headers に Authorization があるか。

ステップ6 — Max-Age とキャッシュを確認する。 1 回目は失敗・2 回目以降成功、という intermittent な挙動はキャッシュと設定変更のタイミングズレを疑う。Disable cache ON/OFF で挙動が変わるか試す。

1

Network タブで OPTIONS のステータスと CORS レスポンスヘッダーを確認

2

Request-Method / Request-Headers と Allow-Methods / Allow-Headers を突合

3

curl -X OPTIONS で Origin・Request-Method・Request-Headers を再現

4

401 なら認証ミドルウェア、404/405 ならルーティングを修正

5

Gateway/CDN とアプリの CORS 二重設定がないか確認

6

Max-Age 設定後、2 回目以降 OPTIONS が省略されるか検証

CORS 全般(Allow-Origin の基本、プロキシ経由の回避、開発時の注意)は CORSエラーの対処法 を参照。本記事は OPTIONS プリフライトに特化 した深掘りだ。

関連記事

この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
XSS攻撃と防御の完全ガイドXSS攻撃と防御の完全ガイド|反射型・蓄積型・DOM型の対策
CSRF対策の実装ガイドCSRF対策の実装ガイド|SameSite・Synchronizer Token・Laravel/Expressコード
Double Submit Cookie完全実装Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃
Content Security Policy (CSP) 設定ガイドContent Security Policy (CSP) 設定ガイド|XSS・クリックジャッキング対策
CSP Report-Only 段階導入ガイドCSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する
セキュリティヘッダー一覧と設定ガイドセキュリティヘッダー一覧と設定ガイド|CSP・HSTS・Referrer-Policy・Permissions-Policy
セキュアなCookie設定ガイドセキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策
ローカル開発でCORSエラーが出る原因と3つの回避策ローカル開発でCORSエラーが出る原因と3つの回避策
OAuth2 PKCE SPA実装ガイドOAuth2 PKCE SPA実装ガイド|Authorization Code + S256 + BFFパターン
Passkeys・WebAuthn実装の基礎Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server
JWTのデバッグ方法JWTのデバッグ方法|トークンの中身を安全に確認する
JWTの中身を確認する方法JWTの中身を確認する方法|改ざんが検知される仕組み

まとめ

  • プリフライトはブラウザが自動送信する OPTIONS で、JSON API・Bearer 認証・REST メソッドでは ほぼ必ず発生 する
  • OPTIONS 応答には Allow-Origin に加え Allow-MethodsAllow-Headers が必須。Max-Age でキャッシュすれば往復を減らせる
  • credentials: 'include' 時は Allow-Credentials: true具体的 Allow-Origin* 不可)
  • 認証ミドルウェアが OPTIONS を 401 にしない こと、API Gateway の Allow-Headers 漏れに注意
  • curl -X OPTIONS でプロキシ層とアプリ層を切り分け、Network タブで OPTIONS の1本を見逃さない

プリフライトを「本番リクエストの前触れ」として理解し、OPTIONS 専用の成功条件を満たせば、CORS 問題の大半は Network タブの1行で解決できる。