CORS プリフライトで 400 / 403 が返る原因と対処
「本番だけ OPTIONS が 400 / 403 で落ちて API が叩けない」は CORS で最も頻出する 事故です。原因は OPTIONS ルーティング欠落、認証ミドルウェアが OPTIONS をブロック、必要ヘッダ不足の三択に絞り込めます。
Preflight OPTIONS requests failing with 400/403 almost always come down to one of three causes: missing OPTIONS handler, auth middleware blocking preflight, or missingAccess-Control-Allow-* headers.
TL;DR
OPTIONSは認証・CSRFトークンを要求せず 2xx を返すのが原則Access-Control-Allow-Origin/-Methods/-Headersの3点セットcredentials: "include"を使うなら Origin は*不可
1. プリフライトが飛ぶ条件 / When preflight fires
- メソッドが
GET/HEAD/POST以外 Content-Typeがapplication/jsonなど非simpleAuthorization,X-*などカスタムヘッダ付き
2. 原因別の直し方 / Cause table
| 症状 / Symptom | 原因 / Cause | 対処 / Fix |
|---|---|---|
| OPTIONS 400 | ルーティング未定義 | Express: app.options('*', cors()) 等で明示 |
| OPTIONS 401/403 | 認証ミドルウェアが先に走る | OPTIONS を認証より前でバイパス |
| Missing Allow-Headers | Authorization をサーバが知らない | Access-Control-Allow-Headers: Authorization, Content-Type |
| credentials エラー | Origin: * と credentials を併用 | 具体オリジン + Allow-Credentials: true |
| Chrome: blocked by CORB | JSONに Content-Type が無い | 必ず application/json を返す |
3. サーバ別の最小設定 / Minimum server config
Express (Node.js)
import cors from "cors";
app.use(cors({
origin: "https://app.example.com",
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Authorization", "Content-Type"],
}));Nginx
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;
return 204;
}
...
}4. DevTools での切り分け / DevTools triage
- Network タブで失敗中の OPTIONS を選択
- Request Headers に
Access-Control-Request-Method/-Headersがあるか確認 - Response Headers にそれぞれに対応する
Access-Control-Allow-*が返っているか - 200/204 で返っているのに後続 POST が落ちるなら、POST レスポンスの Allow-Origin 不足
5. 同一オリジンの誤解:ポート・サブドメインも別オリジン / Same-origin misconceptions
「同じドメインなのにCORSエラーが出る」場合、オリジンはスキーム+ホスト+ポートの組で 決まることを見落としているケースがあります。ポートやサブドメイン、httpとhttpsの違いも すべて別オリジン扱いです。
| 比較対象 / Comparison | 同一オリジン? |
|---|---|
| http://example.com:3000 と http://example.com:8080 | ❌ ポートが違う |
| http://example.com と https://example.com | ❌ スキームが違う |
| http://app.example.com と http://example.com | ❌ サブドメインが違う |
| http://example.com/a と http://example.com/b | ✅ パスは無関係、同一オリジン |
フロントエンドとバックエンドを別ポートで開発しているとき(例: フロント3000番、API 8080番)は、 本番では同一オリジンに見えても開発環境ではCORSが必要になる、という状況もよく起こります。
6. プリフライトのキャッシュ(Access-Control-Max-Age)の落とし穴 / Preflight caching gotcha
ブラウザはAccess-Control-Max-Ageで指定された秒数だけ、プリフライトの結果を キャッシュします。この間は同じメソッド・ヘッダーの組み合わせに対してOPTIONSを 再送しません。CORS設定を変更した直後にブラウザ側が古いキャッシュを参照し続けて 「直したのに直っていないように見える」ことがあります。
Access-Control-Max-Age: 86400 // 24時間キャッシュされる
// 許可ヘッダーを追加するなど設定変更した直後は、
// ブラウザが古いプリフライト結果をキャッシュしたままになっている可能性がある検証中はAccess-Control-Max-Ageを短く(あるいは0に)設定するか、DevToolsの Networkタブで「キャッシュを無効化」を有効にして確認すると、設定変更の反映を早く確認できます。
7. Next.js Route HandlersでのCORS設定例 / CORS in Next.js Route Handlers
Next.js App Routerでは、Route HandlerにOPTIONSメソッドを明示的にエクスポートし、 各レスポンスにAccess-Control-Allow-*ヘッダーを付与します。
// app/api/example/route.ts
export async function 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",
},
});
}
export async function GET() {
return Response.json(
{ data: "..." },
{ headers: { "Access-Control-Allow-Origin": "https://app.example.com" } }
);
}OPTIONSハンドラーを定義し忘れると、Next.jsのデフォルトルーティングでは 404が返り、本記事冒頭の「ルーティング未定義」と同じ状態になります。
8. English summary
A failing CORS preflight (400/403) is usually a server-side issue, not a browser one. Check three things in order: (1) the server has an OPTIONS route, (2) auth middleware does not reject unauthenticated preflights, and (3) response headers include all requiredAccess-Control-Allow-* values. When using credentials: include, Origin cannot be *; return the specific origin and addAccess-Control-Allow-Credentials: true.
よくある質問 / FAQ
- なぜ OPTIONS だけ 400 が返る?
- サーバ側で OPTIONS を受けるルーティングが無い、または認証ミドルウェアが OPTIONS もブロックしているケースが大半です。OPTIONS は認証不要で 2xx を返す必要があります。
- Why does OPTIONS return 400?
- The server typically has no OPTIONS handler or auth middleware is rejecting the unauthenticated preflight. Preflights must return 2xx without requiring credentials.
- credentials: include を使うと失敗する
- Access-Control-Allow-Origin に * は使えません。具体的なオリジンを返し、Access-Control-Allow-Credentials: true を併記する必要があります。