CORSプリフライトリクエストの正体:なぜブラウザは本番通信の前にOPTIONSを叫ぶのか?
こんにちは。ネットワークのパケットキャプチャを開き、TCP 3-way handshakeのその先にあるHTTPのやり取りを眺めるのが何よりの趣味であるインフラアーキテクトです。
現場でWeb APIを設計・運用していると、避けて通れないのがCORS(Cross-Origin Resource Sharing)の壁です。
「フロントエンドのモダンなSPAからAPIを叩いたら、ブラウザのコンソールに何やら見慣れないエラーが出た」「とりあえずネットで見つけた Access-Control-Allow-Origin: * を貼ったら動いたけれど、セキュリティ的に本当にこれでいいのか?」――そんな泥臭いトラブルシューティングに直面したエンジニアは数知れないでしょう。
今回は、そのCORSの仕組みの中でも、特にバックエンドエンジニアやインフラエンジニアを悩ませる「プリフライトリクエスト(OPTIONSメソッド)」にスポットを当てます。ブラウザが裏側で何をやっているのか、RFCや実務のパケットの動きベースで徹底的に紐解いていきましょう。
—
1. そもそもなぜCORSとプリフライトが必要なのか?
現代のWebアプリケーションは、フロントエンド(例: https://app.example.com)とAPIサーバー(例: https://api.example.com)が別々のオリジン(スキーム・ドメイン・ポート番号の組み合わせ)で稼働していることが当たり前です。
もし、ブラウザに「同一同一オリジンポリシー(Same-Origin Policy)」という安全装置がなかったらどうなるでしょうか?
あなたが悪意のあるサイトを訪れた瞬間、そのサイトのJavaScriptが裏側であなたのネットバンキングのドメインへ勝手にリクエストを送り、Cookieを付与した上で不正な送金APIを叩く――そんなクロスサイトリクエストフォージェリ(CSRF)や情報搾取がやりたい放題になってしまいます。
ブラウザはこの脅威を防ぐため、デフォルトでは異なるオリジンへのリクエストを厳しく制限します。しかし、API全盛の現代において、完全に他オリジンとの通信を遮断してしまうとWebアプリが成り立ちません。そこで登場するのが、安全にクロスオリジン通信を許可するための規格であるCORSです。
そして、そのCORSの安全弁として機能するのが、本命のリクエストを送信する直前にブラウザがこっそり投げる「プリフライトリクエスト(事前確認リクエスト)」なのです。
—
2. プリフライトリクエストの通信フロー(シーケンス)
ブラウザが「複雑なリクエスト(後述)」をクロスオリジンに対して送信する場合、いきなり POST や PUT を投げることはしません。まずは次のようなステップで、サーバーの意思を確認します。
[Browser (Client)] [API Server]
| |
|--- 1. OPTIONS /api/v1/users --------------->| (プリフライトリクエスト)
| Access-Control-Request-Method: POST |
| Access-Control-Request-Headers: ... |
| |
|<-- 2. 200 OK / 204 No Content ------------| (プリフライトレスポンス)
| Access-Control-Allow-Origin: * |
| Access-Control-Allow-Methods: POST |
| Access-Control-Max-Age: 86400 |
| |
|--- 3. POST /api/v1/users (本命) ----------->| (本リクエスト)
| |
|<-- 4. 200 OK (データ返却) ------------------| (本レスポンス)
現場のエンジニアとして声を大にして言いたいのは、「ブラウザは裏側で2往復のHTTP通信を行っている」という事実です。APIサーバーの設計やインフラのルーティング(API GatewayやWAF、リバースプロキシ)において、この OPTIONS メソッドのハンドリングをサボっていると、本命の POST や PUT が永遠に届かないという悲劇が起きます。
—
3. どんなときにプリフライトは発生するのか?
すべてのクロスオリジンリクエストで OPTIONS が飛ぶわけではありません。RFC 6454およびFetch仕様に基づき、ブラウザは「単純リクエスト(Simple Request)」に該当しない場合にのみプリフライトを発動します。
以下の条件のいずれか一つでも満たさない場合、プリフライトが実行されます。
1. HTTPメソッドが以下のいずれかであること
GETHEADPOST
2. 使用できるヘッダーが以下の安全なもの(CORS-safelisted request-headers)に限定されていること
AcceptAccept-LanguageLanguageContent-LanguageContent-Type(ただし、後述の制限あり)
3. Content-Type ヘッダーの値が以下のいずれかであること
application/x-www-form-urlencodedmultipart/form-datatext/plain
現場でよくあるハマりどころ
近年のWeb APIは、データフォーマットに application/json を使ったり、認証のために Authorization: Bearer <token> ヘッダーを付与したりすることが一般的です。
これらは上記の「単純リクエスト」の条件から完全に外れるため、モダンなAPI通信のほぼ100%でプリフライトリクエスト(OPTIONS)が発生することになります。
—
4. 登場人物(HTTPヘッダー)の全貌
プリフライトのやり取りで飛び交う主要なヘッダーの意味を、実務的な視点で整理しておきましょう。
ブラウザが送信するリクエストヘッダー
Origin: 請求元のオリジン(例:https://app.example.com)。サーバーはこの値を見て許可・不許可を判断します。Access-Control-Request-Method: この後、実際に送りたい本命のメソッド(例:PUT,DELETEなど)。Access-Control-Request-Headers: この後、実際に送りたいカスタムヘッダーの一覧(例:authorization, content-type)。
サーバーが返すべきレスポンスヘッダー
Access-Control-Allow-Origin: 許可するオリジン(例:https://app.example.comまたはワイルドカード*)。Access-Control-Allow-Methods: 許可するHTTPメソッドのカンマ区切りリスト(例:GET, POST, PUT, OPTIONS)。Access-Control-Allow-Headers: 許可するリクエストヘッダーのカンマ区切りリスト(例:Content-Type, Authorization)。Access-Control-Max-Age: プリフライト結果をブラウザがキャッシュする秒数(例:86400で24時間)。毎回OPTIONSを飛ばさせないためのパフォーマンスチューニングに必須です。
—
5. 実装・設定サンプルコード
では、実際の開発やインフラ構築において、どのようにこのCORSとプリフライトをハンドリングすべきか、具体例を見ていきましょう。
① バックエンドの実装例(Python / FastAPI)
モダンなフレームワークの多くはCORSミドルウェアを標準装備しています。FastAPIの場合、以下のように明示的に許可設定を行います。
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
# 許可するオリジンのリスト(本番環境ではワイルドカードを避け、ドメインを厳格に指定すべきです)
origins = [
"https://app.example.com",
"http://localhost:3000", # ローカル開発用
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True, # Cookieや認証情報の送信を許可する場合
allow_methods=["GET", "POST", "PUT", "DELETE"], # 許可するメソッド
allow_headers=["Authorization", "Content-Type"], # 許可するカスタムヘッダー
max_age=3600, # プリフライト結果のキャッシュ時間(秒)
)
@app.post("/api/v1/items")
async def create_item():
return {"message": "リクエスト成功!"}
② インフラ層での設定例(Nginx)
APIサーバーの手前にNginxなどのリバースプロキシを置いている場合、アプリケーションに到達する前にNginx側で OPTIONS リクエストを即座に処理(ショートサーキット)させると、バックエンドの負荷軽減になります。
server {
listen 80;
server_name api.example.com;
location /api/ {
# プリフライトリクエストに対する明示的なハンドリング
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204; # ボディなしで正常終了を返す
}
# 通常のリクエストのプロキシ設定
proxy_pass http://backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 本リクエストに対するCORSヘッダーの付与
add_header 'Access-Control-Allow-Origin' 'https://app.example.com' always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
}
}
—
6. 現場で役立つデバッグ・トラブルシューティングの極意
「CORSエラーが出た!」という報告を受けたとき、未経験のエンジニアは慌ててコードのあちこちに手を加えがちですが、ネットワークエンジニア流の冷静な切り分け手順を知っていれば、原因は一発で特定できます。
ステップ1: ブラウザの開発者ツール(Networkタブ)を見る
まずはChrome等のDevToolsを開き、問題のリクエストを確認します。
OPTIONSリクエストのステータスコードは何か? (200 OKや204 No Contentなら合格。403や404、500ならサーバー側のルーティングやミドルウェア設定ミスです)- レスポンスヘッダーに
Access-Control-Allow-Originが正しく含まれているか?
ステップ2: curl で直接 OPTIONS を投げてみる
ブラウザの挙動が怪しい、あるいはプロキシやWAF(Web Application Firewall)が邪魔をしている疑いがあるときは、手元から直接 curl コマンドでプリフライトをシミュレートします。
curl -i -X OPTIONS https://api.example.com/api/v1/items \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Authorization, Content-Type"
このコマンドを実行し、期待通りの Access-Control-Allow-* ヘルパーが返ってくるかを自分の目で確認してください。もしここでヘッダーが返ってこなければ、CloudflareやAWS API Gateway、Nginxなどの前段のインフラストラクチャが OPTIONS メソッドをブロックしているか、ルーティングの定義から漏れています。
—
まとめ
CORSのプリフライトリクエスト(OPTIONS)は、モダンなWebアーキテクチャのセキュリティと柔軟性を両立させるための不可欠な守り神です。
「ただ動くからいいや」と適当に * を乱用するのではなく、
1. どのオリジンから
2. どのメソッドとヘッダーで
3. どれくらいの頻度で(キャッシュを活用して)
通信が行われるべきかを正しく設計・コントロールすること。それこそが、トラブルに強く、セキュアで美しいWeb APIインフラを作り上げるプロフェッショナルの条件です。
日々のパケット解析やAPI設計の現場で、この記事が少しでもあなたの助けになれば幸いです。
コメント