CORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答
プリフライトは本番リクエストの前にブラウザが自動送信する OPTIONS リクエスト だ。JSON API・Bearer 認証・PUT/DELETE を使う現代のフロントエンドでは避けられない。サーバーは OPTIONS に対し Access-Control-Allow-Methods・Allow-Headers・必要なら Allow-Credentials と Max-Age を返し、認証ミドルウェアで OPTIONS を 401 にしない ことが通過条件になる。
プリフライト(OPTIONS)とは何か
クロスオリジン(例: https://app.example.com → https://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 になっているケースが、実務で最も多いプリフライト障害だ。
アプリケーションコードで fetch(url, { method: 'OPTIONS' }) を書く必要はない。Authorization 付きの POST や Content-Type: application/json を指定した瞬間、ブラウザが裏で OPTIONS を発行する。サーバーが OPTIONS を処理できていなければ、フロントの fetch コードが正しくても必ず失敗する。
単純リクエスト vs プリフライト — 判定の境界線
プリフライトが 発生しない のは「単純リクエスト(Simple Request)」だけ。次の すべて を満たす場合、ブラウザは OPTIONS を送らず直接リクエストする。
- HTTP メソッドが
GET・HEAD・POSTのいずれか - ブラウザが許可したヘッダー(
Accept・Accept-Language・Content-Language・Content-Typeなど)以外の カスタムヘッダーがない Content-Typeが次のいずれか:application/x-www-form-urlencoded・multipart/form-data・text/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(パス + クエリ)
OriginAccess-Control-Request-MethodAccess-Control-Request-Headersの内容
URL が1文字でも変われば別キャッシュ になる。/api/users と /api/users?page=2 は別扱いだ。マイクロサービス化でエンドポイントが細分化されるほど、プリフライトキャッシュの効率は落ちる。
CDN や共有キャッシュの前段に Nginx 等がある場合、Vary: Origin, Access-Control-Request-Method, Access-Control-Request-Headers を付けないと、別オリジンのプリフライト応答が混ざるリスクがある。Express cors パッケージは適切に Vary を付与する。
credentials モード — Cookie と Authorization の扱い
fetch の credentials オプション(XHR では withCredentials)は、クロスオリジンでも Cookie や Client Certificate を送るか を制御する。
| 値 | 挙動 |
|---|---|
omit | Cookie を送らない(デフォルトの same-origin 以外) |
same-origin | 同一オリジンのみ Cookie 送信 |
include | クロスオリジンでも Cookie 送信 |
credentials: 'include' を使う場合、サーバーは次 両方 を満たす必要がある。
- レスポンスに
Access-Control-Allow-Credentials: true 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.comAccess-Control-Allow-MethodsにPOSTが含まれるAccess-Control-Allow-Headersにauthorizationとcontent-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 で挙動が変わるか試す。
Network タブで OPTIONS のステータスと CORS レスポンスヘッダーを確認
Request-Method / Request-Headers と Allow-Methods / Allow-Headers を突合
curl -X OPTIONS で Origin・Request-Method・Request-Headers を再現
401 なら認証ミドルウェア、404/405 ならルーティングを修正
Gateway/CDN とアプリの CORS 二重設定がないか確認
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-Methods・Allow-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行で解決できる。