CR2課の前田です。
お客様環境でCORSのプリフライトリクエスト及び後続のクロスオリジンリクエストが失敗する事象があり、Application Load Balancer(ALB)の機能で解消できそうだったので検証内容をまとめます。
課題
Web基盤と認証基盤が別ドメイン(別オリジン)に分かれている構成で、Web基盤のJavaScriptから認証基盤にリクエストを送る際、ブラウザはCORSの仕組みに基づきプリフライトリクエスト(OPTIONSメソッド)を自動送信します。
しかし認証基盤のEC2(認証)はOPTIONSメソッドに対応しておらず405 Errorを返すため、プリフライトリクエストが失敗し、後続のリクエストも同一オリジンポリシーに抵触し、ブラウザによってブロックされている…という事象が発生していました。


結論
ALBで以下の設定を行うことで、バックエンドの認証サーバを改修せずに解消できました。
- OPTIONSメソッドのリクエストに対して、固定レスポンス(200 OK)を返すようにする
- リスナー属性の設定において、CORSのためのレスポンスヘッダーを付与する
これにより、プリフライトリクエスト(OPTIONSメソッド)はバックエンドの認証サーバに到達せず、ALBがCORSのためのレスポンスヘッダーを含む200 OKを返すことで、ブラウザが後続の本来のリクエストを送信できるようになります。

いきなり検証
各用語についての説明を挟むと長くなってしまうため、動作検証した様子をご覧ください。
ブラウザにおいてリクエスト送信している場面でよくわからない単語があるかもしれないのですが後述するため、まずはALB設定だけでも何をやっているかご覧いただけると幸いです。
検証環境
ALB2台とEC2 2台で検証環境を構築しました。EC2(認証役)は課題の環境を再現し、OPTIONSに対して405 Errorを返す簡易サーバを動かしています。
なお、今回の検証ではリスナーをHTTP(ポート80)で構成しています。CORSの挙動はHTTP/HTTPSに依存しないため、プリフライトリクエストの動作確認としては問題ありません。

EC2(認証役)では、認証サーバの挙動を模擬した簡易Pythonサーバを動作させています。OPTIONSメソッドに対して405 Errorを返すだけのシンプルなものです。
EC2(認証役)の簡易サーバコード(クリックで展開)
from http.server import HTTPServer, BaseHTTPRequestHandler class Handler(BaseHTTPRequestHandler): def do_OPTIONS(self): # 認証サーバの挙動を再現: OPTIONSメソッドを受け付けない self.send_response(405) self.send_header('Content-Type', 'text/plain') self.send_header('Allow', 'GET') self.end_headers() self.wfile.write(b'Method Not Allowed') def do_GET(self): if self.path == '/health': self.send_response(200) self.send_header('Content-Type', 'text/plain') self.end_headers() self.wfile.write(b'ok') elif self.path == '/auth/check': self.send_response(200) self.send_header('Content-Type', 'application/json') self.end_headers() self.wfile.write(b'{"auth":"ok"}') else: self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.end_headers() self.wfile.write(b'<h1>Auth Login Page</h1>') HTTPServer(('0.0.0.0', 8080), Handler).serve_forever()
EC2(Web役)では、クロスオリジンでfetchを送るHTMLページを配信しています。
EC2(Web役)の簡易サーバコード(クリックで展開)
from http.server import HTTPServer, BaseHTTPRequestHandler import os AUTH_ALB_DNS = os.environ.get('AUTH_ALB_DNS', 'localhost') class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path == '/': self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.end_headers() html = f'''<!DOCTYPE html> <html><head><meta charset="utf-8"><title>CORS Preflight Test</title></head> <body> <h1>CORS Preflight Test</h1> <p>Auth ALB: <code>http://{AUTH_ALB_DNS}</code></p> <button onclick="callApi()">Call API (cross-origin GET with custom header)</button> <pre id="result"></pre> <script> function callApi() {{ const authOrigin = 'http://{AUTH_ALB_DNS}'; document.getElementById('result').textContent = 'Sending...'; fetch(authOrigin + '/auth/check', {{ method: 'GET', headers: {{'X-Custom-Header': 'test'}}, }}) .then(r => {{ document.getElementById('result').textContent = 'Status: ' + r.status; return r.text(); }}) .then(t => document.getElementById('result').textContent += '\\nBody: ' + t) .catch(e => document.getElementById('result').textContent = 'ERROR: ' + e.message); }} </script> </body></html>''' self.wfile.write(html.encode()) elif self.path == '/health': self.send_response(200) self.send_header('Content-Type', 'text/plain') self.end_headers() self.wfile.write(b'ok') else: self.send_response(404) self.end_headers() HTTPServer(('0.0.0.0', 8080), Handler).serve_forever()
修正前: 問題再現
EC2(Web役)にホストしている画面から、ボタンを押下します。
別ドメインのALB(認証役)配下にあるEC2(認証役)へJavaScriptによってGETリクエストを送り、上手くコンテンツを取得できるか試します。
※画像の認証サーバ役とはEC2(認証役)のことです

エラーが発生しました。検証ツールで見てみると、GETリクエストの前に送られたプリフライトリクエストがEC2(認証役)のプログラムによって405 Errorとなり、
プリフライトリクエストが失敗したためGETリクエストも失敗しています。

プリフライトリクエストの中身は画像のようになります。後続のGETリクエストで送りたいカスタムヘッダー・メソッド・オリジンが記載されており、
これらがALBのレスポンスヘッダーで定義する必要があると分かります。

GETリクエストの中身には何も入っていません。プリフライトリクエストに失敗したため、ブラウザによって同一オリジンポリシーに抵触したとみなされ、送信がブロックされています。

修正適用
ALB(認証役)のリスナールールに、OPTIONSメソッドのリクエストに対して、200 OKの固定レスポンスを返す設定を施します。

レスポンスヘッダーを設定します。

修正後: 動作確認
EC2(認証役)へのGETリクエストが成功しました。

GETリクエストの前に送られるプリフライトリクエストではALBによって200 OKが返っており、ALBによって付与されたレスポンスヘッダーが存在することが分かります。
このレスポンスヘッダーのおかげで、後続のクロスオリジンのGETリクエストが同一オリジンポリシーに抵触しません。

EC2(認証役)へのGETリクエストも成功しています。なお、この時もALBによって付与されたレスポンスヘッダーが存在することが分かります。

説明
CORS(Cross-Origin Resource Sharing)とは
ブラウザにはSame-Origin Policy(同一オリジンポリシー)というセキュリティ機能があります。JavaScriptコードが別オリジン(スキーム+ホスト+ポートの組み合わせが異なるURL)のリソースを勝手に読み取ることを制限するものです。
CORSは、この制限を「サーバ側が明示的に許可した場合のみ緩和する」仕組みです。サーバがレスポンスヘッダーで「このオリジンからのアクセスはOK」と宣言することで、ブラウザがクロスオリジンリクエストを許可します。
ポイントは、ブラウザのアドレスバーに入力してページ遷移する分には、同一オリジンポリシーに抵触しないということです。あくまでJavaScript(fetch/XMLHttpRequest)から別オリジンへリクエストする場合にのみ適用されます。
プリフライトリクエストとは
ブラウザがクロスオリジンで「単純でないリクエスト」を送る際、本来のリクエストの前に自動的に送信するOPTIONSメソッドのリクエストのことです。
なお、クロスオリジンリクエストの全てでプリフライトリクエストが飛ぶわけではありません。以下の条件を全て満たす単純リクエスト(Simple Request)の場合は、プリフライトリクエストなしで直接送られます。
- メソッドが
GET、HEAD、POSTのいずれか - ヘッダーが
Accept、Accept-Language、Content-Language、Content-Typeのみ - Content-Typeが
application/x-www-form-urlencoded、multipart/form-data、text/plainのいずれか
逆に言えば、Content-Type: application/jsonを使う場合やカスタムヘッダー(Authorization等)を付ける場合はプリフライトリクエストが発生します。
例えば業務用アプリではカスタムヘッダーを定義することがあるため、プリフライトリクエストが発生することも多くなると思われます。
プリフライトリクエストに対して2xx以外のステータスが返された場合や、適切なCORSのためのレスポンスヘッダーが含まれていない場合、ブラウザは本来のリクエストを送信せずにエラーとします。
CORSのためのレスポンスヘッダー
プリフライトリクエストに対して、サーバは「後続の本来のリクエストを許可する」ことを示すレスポンスヘッダーを返す必要があります。
これらのヘッダーがない、または値が合致しない場合、ブラウザは本来のリクエストを送信せずブロックします。
| ヘッダー | 値 | 意味 |
|---|---|---|
Access-Control-Allow-Origin |
単一オリジン(例: https://web.example.com)または * |
クロスオリジンリクエストを許可するオリジン。 完全一致で1つのみかワイルドカード単体( *)のみ指定可能。部分ワイルドカード( *.example.com等)やカンマ区切りの複数指定は不可。*はcredentialsなしのリクエストでのみ有効 |
Access-Control-Allow-Methods |
カンマ区切りのメソッド一覧(例: GET, POST, OPTIONS)または * |
クロスオリジンリクエストで許可するHTTPメソッド。*はcredentialsなしのリクエストでのみ有効 |
Access-Control-Allow-Headers |
カンマ区切りのヘッダー名一覧(例: Content-Type, Authorization)または * |
クロスオリジンリクエストで許可するリクエストヘッダー。*はcredentialsなしのリクエストでのみ有効。Authorizationヘッダーはワイルドカードの対象外で、常に明示的な指定が必要 |
Access-Control-Allow-Credentials |
true のみ |
クロスオリジンリクエストでCookie送信を許可するか。 Cookie送信を伴うリクエストの場合、上記3つのヘッダーに *を指定しているとリクエストに失敗する |
Access-Control-Max-Age |
秒数(例: 7200) |
プリフライトリクエストの結果をキャッシュする秒数。 キャッシュされている間はプリフライトリクエストが送信されない |
Access-Control-Expose-Headers |
カンマ区切りのヘッダー名一覧または * |
リクエスト元のJavaScriptから読み取り可能にするレスポンスヘッダーの一覧。 デフォルトで読み取れるのは Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragmaのみ。*はcredentialsなしのリクエストでのみ有効 |
ALBのレスポンスヘッダー挿入機能
2024年11月にALBに追加された機能です。リスナー属性として設定でき、ALBが返すレスポンスに自動的にHTTPヘッダーを挿入できます。固定レスポンスだけでなく、ターゲットグループへのフォワード時のレスポンスにも適用されます。
前述のCORS関連のレスポンスヘッダーの他に、Strict-Transport-Security(HSTS)やX-Content-Type-Optionsなどのセキュリティヘッダーも設定可能です。
解決策の詳細
ALB(認証サーバ向け)で、OPTIONSメソッドをバックエンドに届ける前にALBが対応して、CORSのためのレスポンスヘッダー付きの200 OKを返します。
これはプリフライトリクエストをバックエンドの手前で処理するアプローチです。
Amazon API GatewayではCORS設定を有効にするとOPTIONSへの自動応答が組み込み機能として提供されています。ALBにはCORS専用の機能はありませんが、固定レスポンスルールとレスポンスヘッダー挿入を組み合わせることで同等の対応が可能です。
設定内容
リスナールール追加:
| 優先順位 | 条件 | アクション |
|---|---|---|
| 高(新規) | HTTPリクエストメソッド = OPTIONS | 固定レスポンス 200 |
| デフォルト(既存) | — | ターゲットグループへ転送 |
リスナー属性(レスポンスヘッダー挿入):
| 属性キー | 値 |
|---|---|
routing.http.response.access_control_allow_origin.header_value |
http://web.example.com |
routing.http.response.access_control_allow_methods.header_value |
GET |
routing.http.response.access_control_allow_headers.header_value |
X-Custom-Header |
routing.http.response.access_control_max_age.header_value |
7200 |
今回の検証ではプリフライトリクエストの解消が主題のため、必須の3ヘッダー(Allow-Origin、Allow-Methods、Allow-Headers)+Max-Ageに絞っています。
Access-Control-Allow-Credentials(Cookie送信許可)やAccess-Control-Expose-Headers(JSからのレスポンスヘッダー読み取り許可)は、今回の検証構成では不要なため設定していません。
実際に導入する際は、アプリケーションの仕様(Cookieの使用有無、JSで読み取るレスポンスヘッダーの有無等)を踏まえて設定をご検討ください。
Access-Control-Max-Ageは7200(2時間)を設定しています。これはChrome/Edge(Chromiumベース)のプリフライトリクエストキャッシュ上限が7200秒のためです。Firefoxは86400秒(24時間)まで対応していますが、全ブラウザで最大限効かせるには7200が実質的な上限値です。
実装時の注意点
リスナー属性はリスナー全体に適用される
リスナー属性のレスポンスヘッダー挿入はルール単位ではなく、そのリスナーを通る全ての通信に適用されます。OPTIONSの固定レスポンスだけでなく、通常のフォワード時のレスポンスにもCORSのためのレスポンスヘッダーが付きます。適用範囲をご確認の上で設定してください。バックエンドが既にCORSのためのレスポンスヘッダーを返している場合、重複によりCORSエラーが発生する
ALB配下のサーバで既にCORS対応(Access-Control-Allow-Origin等の返却)が実装されている場合、ALBのレスポンスヘッダー挿入と重複します。同一ヘッダーが複数値で返されるとブラウザはCORSエラーとして扱うため、既存の実装と競合しないか事前に確認が必要です。
補足: CORSのためのレスポンスヘッダーに部分ワイルドカードは使えない
CORSのためのレスポンスヘッダーの値に指定できるワイルドカードは *(単体)のみです。*.example.comのような部分ワイルドカードは仕様上無効です。
ALBのリスナー属性には文字列として設定できてしまいますが、ブラウザはこれを有効な指定として認識しません。
特に影響が大きいのはAccess-Control-Allow-Originです。指定できる値は以下のいずれかのみです。
*(全オリジン許可。ただしAllow-Credentials: trueと併用不可)- 完全一致の単一オリジン(例:
https://web.example.com)
なお、Access-Control-Allow-MethodsやAccess-Control-Allow-Headersについては、そもそも部分ワイルドカードという概念自体が存在せず、*(全許可)か具体的な値のカンマ区切りかの二択です。
まとめ
- バックエンド(ALB背後のサーバ)がOPTIONSメソッドを処理できないことでプリフライトリクエストが失敗するケースは、ALBのリスナールール(固定レスポンス)+レスポンスヘッダー挿入で解消できる
- バックエンドの設定変更は不要
- リスナー属性のレスポンスヘッダー挿入はリスナー全体に適用される点と、既にバックエンドでレスポンスヘッダーを返している場合はCORSエラーが発生するため注意
どなたかの一助になれば幸いです。