新しいフロントエンドをリリースしてコンソールを開くと、赤いCORSエラーが表示され、リクエストが「CORSポリシーによってブロックされた」と告げられることがあります。ApidogやcurlではAPIが正常に動作するのに、ブラウザはJavaScriptへ応答を渡してくれません。しかし、原因と修正箇所を理解すれば、CORSは謎ではありません。
CORSエラーはブラウザによって強制されますが、原因の多くはサーバー設定にあります。サーバーが正しいAccess-Control-Allow-Originヘッダーを返さないため、ブラウザが応答をJavaScriptから隠します。したがって、修正箇所は通常フロントエンドではなくサーバーです。
この記事では、CORSの仕組み、プリフライトリクエスト、代表的な6つのエラーと修正方法、Express・Spring Boot・Nginxの設定例、ブラウザ外でのデバッグ方法を紹介します。
CORSエラーとは何か
CORSはCross-Origin Resource Sharing(クロスオリジンリソース共有)の略です。ブラウザはデフォルトで同一オリジンポリシーを適用します。
たとえば、https://app.example.comで動作するJavaScriptは、スキーム、ホスト、またはポートが異なるhttps://api.example.comの応答を読み取れません。CORSは、サーバーが明示的にこの制限を緩和するための仕組みです。詳しくは、MDNのCORSドキュメントとFetch仕様を参照してください。
押さえるべきポイントは3つです。
- 強制するのはブラウザ: サーバー間通信、curl、デスクトップAPIクライアントはCORSチェックを行いません。
- 設定するのはサーバー: ブラウザはサーバーのレスポンスヘッダーを見て、応答を公開してよいか判断します。
- リクエスト自体は到達することがある: 単純なリクエストでは、サーバーが処理して応答を返した後、ブラウザがその応答をJavaScriptから隠します。
CORSはAPIを外部から遮断するファイアウォールではありません。Cookieを使ってクロスオリジンデータを読み取ろうとする悪意のあるページからユーザーを守るためのブラウザ側の制御です。
CORSエラーが出たら、フロントエンドで回避するのではなく、レスポンスに欠けている、または誤っているヘッダーをサーバーで修正しましょう。
プリフライトリクエストの構造
特定のクロスオリジンリクエストでは、ブラウザが本リクエストの前にOPTIONSリクエストを送信します。これは、次のような場合に発生します。
- GET、HEAD、POST以外のメソッドを使う
-
Authorizationなどのカスタムヘッダーを送る -
application/jsonなどのContent-Typeを使う
プリフライトの例です。
OPTIONS /v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
ブラウザは「https://app.example.comのページが、指定したヘッダーでPOSTしてよいか」と確認しています。サーバーは次のように応答します。
HTTP/1.1 204 No Content
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-Max-Age: 86400
Vary: Origin
必要な情報が欠けていると、ブラウザは本リクエストを送信する前に処理を中止します。APIエンドポイントには到達せず、ログにはOPTIONSだけが残り、コンソールにはCORSエラーが表示されます。
Access-Control-Max-Ageはプリフライトの判定をキャッシュする時間を秒で指定します。上記の例では86400秒です。
CORSをデバッグするときは、まず「失敗したのはプリフライトか、本リクエストか」を切り分けてください。
よくある6つのCORSエラーと修正方法
1. Access-Control-Allow-Originヘッダーがありません
サーバーがCORSヘッダーをまったく返していないため、ブラウザが応答をブロックしています。
修正方法: 特定のオリジン、または認証情報を使わない公開APIの場合に限り*を返します。
Access-Control-Allow-Origin: https://app.example.com
エラー応答にもCORSヘッダーを付ける必要があります。たとえば、ミドルウェアが2xxレスポンスだけを処理していると、APIが500を返したときに本来のサーバーエラーではなくCORSエラーが表示されます。403 Forbiddenや500を含むすべてのレスポンスでヘッダーが付くことを確認してください。
2. ワイルドカード*と認証情報を併用しています
フロントエンドがcredentials: 'include'でCookieや認証情報を送っているのに、サーバーが次のように応答しているケースです。
Access-Control-Allow-Origin: *
認証情報付きリクエストで*を使うことはできません。この組み合わせを許可すると、任意のWebサイトが認証済みレスポンスを読み取れるためです。
修正方法: 正確なオリジンと認証情報の許可を返します。
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
受信したOriginを許可リストと照合してから返してください。任意のオリジンをそのまま反射すると、CORSの保護が無効になります。
3. プリフライトがアクセス制御チェックに失敗します
サーバーがOPTIONSを処理していない可能性があります。POSTだけを定義しているため404または405になる場合や、プリフライトには認証情報が付かないため、認証ミドルウェアが401を返す場合があります。
修正方法: 認証より前にOPTIONSを処理し、CORSヘッダーを含む2xxレスポンスを返します。手動で設定する場合は次のようになります。
app.options('/v1/orders', (req, res) => {
res.set({
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type'
});
res.sendStatus(204);
});
多くのフレームワークでは、CORSミドルウェアを最初にマウントするだけで解決できます。
4. ヘッダーのオリジンが一致しません
サーバーはAccess-Control-Allow-Originを返していますが、リクエストのOriginと異なっています。
よくある原因は次のとおりです。
-
http://localhost:5173からアクセスしているのに、本番オリジンを返している -
httpとhttpsが一致していない -
https://app.example.com/のように末尾スラッシュを付けている
修正方法: リクエストのOriginを許可リストと正確に比較し、一致した値を返します。また、キャッシュ対策としてVary: Originを追加します。
const allowed = ['https://app.example.com', 'http://localhost:5173'];
if (allowed.includes(req.headers.origin)) {
res.set('Access-Control-Allow-Origin', req.headers.origin);
res.set('Vary', 'Origin');
}
5. リクエストヘッダーまたはメソッドが許可されていません
代表的なメッセージは次の2つです。
-
authorizationがAccess-Control-Allow-Headersで許可されていない -
PUTがAccess-Control-Allow-Methodsで許可されていない
プリフライト自体は成功していますが、レスポンスが本リクエストに必要なヘッダーやメソッドを許可していません。
修正方法: フロントエンドが実際に送るすべてのヘッダーとメソッドを追加します。
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type, X-Request-Id
ヘッダー名は大文字と小文字を区別しません。メソッドは大文字で指定します。
6. プリフライト中のリダイレクトは許可されません
プリフライトが301や302を返すURLに到達すると、ブラウザはリダイレクトに従わず処理を中止することがあります。
よくある原因は次のとおりです。
-
httpからhttpsへのリダイレクト - 末尾スラッシュを補うルーターのリダイレクト
-
/v1/ordersから/v1/orders/へのゲートウェイリダイレクト
修正方法: フロントエンドを最終URLへ直接向けてください。最初からhttpsを使い、ルーターの末尾スラッシュ規則に合わせます。手動のOPTIONSリクエストで、エンドポイントが3xxではなく2xxを返すことも確認してください。
サーバー設定例
Express
ヘッダーを手動で設定する代わりに、公式のcorsミドルウェアを使用します。
const express = require('express');
const cors = require('cors');
const app = express();
app.use(cors({
origin: ['https://app.example.com', 'http://localhost:5173'],
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Authorization', 'Content-Type'],
credentials: true,
maxAge: 86400
}));
認証ミドルウェアより前にマウントしてください。これにより、プリフライトが認証トークン不足で拒否されるのを防げます。
Pythonの場合は、Flask-CORS拡張機能で同じヘッダーロジックを適用できます。
Spring Boot
WebMvcConfigurerでグローバル設定を行います。
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/v1/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.allowCredentials(true)
.maxAge(86400);
}
}
Spring Securityを使う場合は、セキュリティフィルターチェーンでも.cors(Customizer.withDefaults())を呼び出してください。そうしないと、MVC設定に到達する前にセキュリティ層がプリフライトをブロックします。詳細はSpringのCORSドキュメントを参照してください。
Nginx
Nginxでアプリの前段処理を行う場合は、エッジでプリフライトに応答できます。
location /v1/ {
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;
}
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Vary "Origin" always;
proxy_pass http://backend;
}
alwaysは重要です。これがないと、Nginxは4xxや5xxレスポンスでadd_headerを適用しません。その結果、失敗したリクエストでCORSエラーが元のサーバーエラーを隠します。
また、CORSを担当するレイヤーは1つに決めてください。Nginxとアプリケーションの両方がヘッダーを追加すると、Access-Control-Allow-Origin: *, *のような重複が発生し、ブラウザに拒否されます。
Apidogでブラウザ外からCORSをデバッグする
コンソールエラーはブラウザがブロックした事実を示しますが、サーバーが実際に返した内容までは分かりません。ブラウザを経由せずに同じリクエストを送ると、問題をすばやく切り分けられます。
ApidogはデスクトップAPIクライアントのため、ブラウザのCORSチェックを受けません。フロントエンドと同じリクエストを送信し、次のように判断できます。
- Apidogでは成功する: APIロジックは正常で、CORSヘッダーの問題である可能性が高い
- Apidogでも失敗する: CORSではなく通常のAPIバグ。一般的なAPIテスト手法で調査する
1. 本リクエストを再現する
ブラウザのネットワークタブから失敗したリクエストをコピーし、同じメソッド、ヘッダー、ボディでApidogから送信します。ステータスとレスポンスボディを確認してください。ここで500が返るなら、CORSは根本原因ではありません。
2. プリフライトを手動で送信する
新しいリクエストを作成し、メソッドをOPTIONSに設定します。次のヘッダーを追加してください。
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
3. レスポンスヘッダーを確認する
次のヘッダーを探し、フロントエンドが必要とする値と比較します。
Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-Headers
ヘッダーの欠落、誤ったオリジン、3xxステータスは、コンソールで推測しなくても特定できます。
4. 修正を検証する
サーバー設定を変更したら、保存済みのOPTIONSリクエストを再送信し、ヘッダーが更新されたことを確認します。フロントエンドの再デプロイやキャッシュクリアは不要です。
この手順なら、「APIクライアントでは動くのにブラウザでは失敗する」という問題を数秒で切り分けられます。PostmanのCORSテストも本質は同じです。デスクトップクライアントはCORSをスキップし、ブラウザはサーバーのCORSヘッダーを検証します。
Apidogを無料でダウンロードし、通常のエンドポイントテストと一緒にOPTIONSリクエストを保存しておけば、次回のCORS問題もすぐに確認できます。
30秒で確認するCORSチェックリスト
バグを報告する前に、次を確認してください。
- 失敗したレスポンスに
Access-Control-Allow-Originが含まれているか - 値がページのオリジン(スキーム、ホスト、ポート、末尾スラッシュなし)と完全に一致しているか
- Cookieや認証情報を使う場合、特定のオリジンと
Access-Control-Allow-Credentials: trueを返しているか -
OPTIONSが、必要なメソッドとヘッダーを含む2xxを返しているか - プリフライトURLがリダイレクトしていないか
- 401、403、500などのエラー応答にも成功応答と同じCORSヘッダーがあるか
10回中9回は、この6項目のいずれかに原因があります。Apidogで手動のOPTIONSリクエストを送り、サーバー設定を修正しましょう。
よくある質問
なぜブラウザでのみCORSエラーが発生するのですか?
CORSを強制するのはブラウザだけだからです。同一オリジンポリシーにより、ブラウザはクロスオリジンレスポンスのAccess-Control-Allow-Originを検証します。
curl、バックエンドサービス、デスクトップクライアントにはこの制約がありません。ブラウザ以外では成功する場合、API自体は正常で、サーバーのCORSヘッダーが欠落または誤設定されている可能性が高いです。
CORSはPostmanやApidogにも適用されますか?
いいえ。PostmanやApidogはブラウザのサンドボックス内で動作しないデスクトップアプリケーションなので、CORSをバイパスします。これにより、ブラウザのフィルタリングなしでサーバーの生のレスポンスヘッダーを確認できます。
PostmanのCORSテストで混乱しやすいのはこの点です。デスクトップクライアントで成功してもブラウザで動く証明にはなりませんが、失敗しているレイヤーの特定には役立ちます。
CORSエラーはセキュリティ機能ですか、それともバグですか?
セキュリティ機能です。ブラウザが、サーバーの許可なしにクロスオリジンのレスポンスデータをスクリプトへ公開していないことを示します。
ブラウザのフラグや拡張機能でCORSを無効にしても、自分の環境で症状を隠せるだけです。他のユーザーには問題が残るため、サーバーのヘッダーを修正してください。
Access-Control-Allow-Origin: *はどこでも使えますか?
Cookieや認証情報を使わない公開読み取り専用APIでのみ使用できます。
認証情報付きリクエストではワイルドカードは拒否されます。認証が必要なAPIでは、オリジンの許可リストを保持し、一致したオリジンを返し、共有キャッシュがレスポンスを分離できるようVary: Originも送信してください。
Top comments (0)