PythonでCORS対応をしよう
この章では、FastAPIのAPIサーバに対してブラウザからのCORSエラーを体験しつつ、CORSMiddlewareで解消する方法をハンズオン形式で学習しながら実装します。これにより、CORSの仕組みを理解した上で、ブラウザからのクロスオリジン呼び出しを許可するAPIサーバが構築できるようになります。
1. 本章の概要
1.1 本章の目的
前章 PythonでREST APIを作ろう までで、FastAPIとSQLModelを組み合わせたREST APIをcurlから呼び出せるところまで確認しました。実際のアプリケーションでは、ブラウザで開いた画面からAPIを呼び出す場面が中心になります。このときブラウザは、異なるオリジン(プロトコル・ホスト・ポートの組み合わせ)へのリクエストに対してCORS(Cross-Origin Resource Sharing)と呼ばれる仕組みで通信可否を判定するため、API側で明示的に許可設定を返さないと画面からの呼び出しが失敗します。本章では、CORSエラーを実際に画面から発生させたうえで、FastAPIのCORSMiddlewareで解消し、単純リクエストとプリフライトリクエストの両方の挙動を確認します。
1.2 ハンズオンの流れ
APIサーバと、それを呼び出す画面(静的HTMLを配信するフロントエンド用サーバ)の2つを立ち上げ、ブラウザからAPIを呼び出したときにCORSエラーが発生することを確認します。そのうえで、FastAPIのCORSMiddlewareを組み込んでエラーを解消します。
1.3 事前準備
必要なツール
この章では、以下のツールを使用します。まだインストールしていない場合は、リンク先の手順に沿って準備をお願いします。
| ツール名 | 関連箇所 | 理由 |
|---|---|---|
| Visual Studio Code | Visual Studio Codeのインストール | APIサーバ・フロントエンドのコードを記述するエディタとして使用する |
| Python | Pythonのインストール | APIサーバ・フロントエンド用サーバの実行環境として使用する |
| Webブラウザ | – | CORSエラーとその解消を確認する画面として使用する(Google Chromeなど) |
2. CORSエラーの体験
まずは、APIサーバとフロントエンド用サーバを立ち上げ、ブラウザから画面越しにAPIを呼び出したときにCORSエラーが発生することを確認します。ここで実際にエラーの挙動を見ておくと、後続の対策の必要性が具体的に理解できます。
2.1 APIサーバの準備
任意の場所にcors-hands-onフォルダを作成し、Visual Studio Codeの「ファイル」→「フォルダーを開く」からcors-hands-onフォルダを開いてください。以降の操作は、Visual Studio Codeのターミナルから行います。
cors-hands-on/ ← このフォルダを作成
以下のコマンドで、Pythonがインストールされていることを確認します。
Windowsの場合:
python --version
Macの場合:
python3 --version
以下のようにPythonのバージョンが表示されれば、インストールは確認できています。
Python 3.12.x
バージョンが表示されない場合は、Pythonのインストールを先に実施してください。
続いて、FastAPIとuvicornをインストールします。
Windowsの場合:
pip install fastapi uvicorn
Macの場合:
pip3 install fastapi uvicorn
Visual Studio Codeのエクスプローラーでcors-hands-onフォルダを右クリックし、「新しいファイル」を選択してmain.pyを作成します。
cors-hands-on/
└── main.py ← このファイルを作成
作成したファイルに以下の内容を記述して保存します。これは、GET /notesでノートの固定データをJSONで返し、PUT /notes/{note_id}で仮の更新レスポンスを返す、最小構成のFastAPIサーバです。PUTは後半でプリフライトリクエストを確認するときに使います。
from fastapi import FastAPI
app = FastAPI()
@app.get("/notes")
def list_notes():
return [
{"id": 1, "title": "買い物"},
{"id": 2, "title": "打ち合わせ資料"},
]
@app.put("/notes/{note_id}")
def update_note(note_id: int):
return {"id": note_id, "status": "updated"}
以下のコマンドでAPIサーバを起動します。
uvicorn main:app --reload
以下のような実行結果が表示されます。
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
ポート8000でAPIサーバがリクエスト待ちの状態になりました。動作確認のために、別のターミナルからcurlで叩きます。
curl http://localhost:8000/notes
以下のような実行結果が表示されます。
[{"id":1,"title":"買い物"},{"id":2,"title":"打ち合わせ資料"}]
APIサーバはこのまま起動したままにしておきます。次はフロントエンド側を準備します。
2.2 フロントエンド用サーバの準備
APIをブラウザから呼び出すには、画面を配信するサーバも別に必要です。ここでは、Python標準のhttp.serverモジュールで静的HTMLを配信するだけの構成を用意します。
Visual Studio Codeのエクスプローラーでcors-hands-onフォルダを右クリックし、「新しいフォルダー」を選択してfrontendという名前でフォルダを作成します。続けて、frontendフォルダの中にindex.htmlを作成します。
cors-hands-on/
├── main.py
└── frontend/ ← このフォルダを作成
└── index.html ← このファイルを作成
index.htmlに以下の内容を記述して保存します。これは、http://localhost:8000/notesをfetchで呼び出し、結果を画面に表示する最小のHTMLです。本章の主眼はCORSの動作確認なので、HTML/JavaScriptの文法解説は省略します。フロントエンドのコード自体を詳しく知りたい場合は、フロントエンド講座 を参照してください。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Notes</title>
</head>
<body>
<h1>Notes</h1>
<ul id="list"></ul>
<script>
fetch("http://localhost:8000/notes")
.then((res) => res.json())
.then((notes) => {
const list = document.getElementById("list");
for (const note of notes) {
const li = document.createElement("li");
li.textContent = `${note.id}: ${note.title}`;
list.appendChild(li);
}
})
.catch((err) => {
document.getElementById("list").textContent = "取得に失敗: " + err.message;
});
</script>
</body>
</html>
Visual Studio Codeで新しいターミナルを追加し(APIサーバが動いているターミナルは残したまま)、frontendフォルダに移動してから静的ファイルサーバを起動します。
cd frontend
Windowsの場合:
python -m http.server 5173
Macの場合:
python3 -m http.server 5173
以下のような実行結果が表示されます。
Serving HTTP on :: port 5173 (http://[::]:5173/) ...
ポート5173でフロントエンド用サーバが起動しました。APIサーバ(:8000)とフロントエンド用サーバ(:5173)の両方が起動している状態になっています。
2.3 ブラウザからの呼び出しでのエラー確認
準備が整ったので、Webブラウザでhttp://localhost:5173/を開きます。画面にはNotesの見出しの下に「取得に失敗: Failed to fetch」と表示されます。
続いて、ブラウザの開発者ツールを開きます。Google Chromeであれば、右クリック→「検証」を選ぶか、F12キーで開きます。「Console」タブを見ると、以下のようなエラーが表示されています。
Access to fetch at 'http://localhost:8000/notes' from origin 'http://localhost:5173' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
APIサーバ自体はcurlで叩けば200 OKでノートを返せる状態ですが、ブラウザはhttp://localhost:5173のページからhttp://localhost:8000へのリクエストを、オリジンが異なると判断してレスポンスの受け取りをブロックしました。次の見出しで、この挙動の背景にあるCORSの仕組みを整理します。
3. CORSの仕組み
エラーの解消に入る前に、なぜブラウザがこのような挙動をするのかを整理します。CORSの前提となるSame-Origin Policy、CORSが解決する範囲、そしてプリフライトリクエストの役割を順に確認します。
3.1 オリジンとは
CORSの話に入る前に、そもそもオリジン(Origin)が何を指すのかを押さえておきます。オリジンは、Webにおいてリソースの「出どころ」を識別する単位で、プロトコル・ホスト・ポートの3つの組み合わせで決まります。1つでも違うオリジンは別のオリジンとして扱われます。
| URL | オリジン |
|---|---|
http://localhost:5173/index.html |
http://localhost:5173 |
http://localhost:8000/notes |
http://localhost:8000 |
https://example.com/api/users |
https://example.com |
http://api.example.com/users |
http://api.example.com |
先ほど体験したケースでは、フロントエンドはhttp://localhost:5173、APIはhttp://localhost:8000で、ポート番号が違うため別オリジンとして扱われます。Webブラウザは、この「オリジンが異なるかどうか」を基準にセキュリティ制約を適用します。
3.2 Same-Origin PolicyとCORS
Same-Origin Policy(同一オリジンポリシー)は、Webブラウザが「あるオリジンで読み込まれたページから、別のオリジンのリソースへ自由にアクセスできないようにする」ためのセキュリティ制約です。ここでのオリジンは、プロトコル・ホスト・ポートの3つの組み合わせで決まります。1つでも異なれば別オリジンとして扱われます。
| ページのオリジン | APIのオリジン | 同一オリジンか |
|---|---|---|
http://localhost:5173 |
http://localhost:5173/api |
同一オリジンである |
http://localhost:5173 |
http://localhost:8000 |
別オリジンである(ポートが違う) |
http://example.com |
https://example.com |
別オリジンである(プロトコルが違う) |
http://example.com |
http://api.example.com |
別オリジンである(ホストが違う) |
Same-Origin Policyだけだと、別オリジンのAPIサーバをフロントエンドから呼び出す構成が成立しません。そこで、サーバ側から「このオリジンからのアクセスは許可します」と明示的に宣言できる仕組みが必要になります。それがCORS(Cross-Origin Resource Sharing)です。
CORSでは、サーバがレスポンスにAccess-Control-Allow-Originなどの専用ヘッダを付けることで、ブラウザに対して「このオリジンからのクロスオリジンリクエストは許可する」と伝えます。ブラウザはこのヘッダを見て、リクエスト結果のスクリプトへの受け渡しを許可するかどうかを判定します。
| 📝 CORSの詳細仕様 |
|---|
| CORSの詳細な仕様は、Cross-Origin Resource Sharing (CORS)(MDN Web Docs)に記載があります。 |
3.3 単純リクエストとプリフライトリクエスト
CORSは、リクエストの内容によって挙動が変わります。ブラウザから送られるクロスオリジンリクエストは、大きく単純リクエストとプリフライトが必要なリクエストの2種類に分かれます。
単純リクエストは、以下のような条件を満たすリクエストです。
- メソッドが
GET・HEAD・POSTのいずれか Content-Typeがtext/plain・application/x-www-form-urlencoded・multipart/form-dataのいずれか- カスタムヘッダ(
Authorizationなど)を付けていない
これらの条件に該当する場合、ブラウザはリクエストをそのまま送信し、レスポンスのAccess-Control-Allow-Originヘッダを見て許可判定を行います。先ほど体験したGET /notesは単純リクエストに該当し、レスポンスにAccess-Control-Allow-Originが無かったためブラウザがブロックしました。
プリフライトが必要なリクエストは、PUT・DELETE・Authorizationヘッダ付き・Content-Type: application/jsonなど、単純リクエストの条件から外れるリクエストです。これらは従来のHTMLフォームでは送信できない形式で、リソースの更新・削除といったサーバ側の状態を変える操作や、任意のヘッダを付けたリクエストが該当します。もしチェックなしでブラウザが送信を許してしまうと、悪意のあるサイトから許可なく送られたリクエストによってサーバ側のデータが書き換えられたり、認証情報付きのリクエストが偽装されたりする恐れがあります。
そこでブラウザは、本来のリクエストを送る前に、OPTIONSメソッドでプリフライトリクエストを送り、「これから送るメソッド・ヘッダは許可されているか」をサーバに問い合わせる形にしています。単純リクエストがCORS登場以前から送れていた(HTMLフォームで送れる範囲の)操作でそのまま送信を許すのに対し、これらのリクエストは「先に許可を取ってから送る」という一段強い制約を敷くことで、意図しないクロスオリジンの書き込み・認証利用を防いでいます。
sequenceDiagram
participant B as ブラウザ
participant S as APIサーバ
B->>S: OPTIONS /notes<br>Access-Control-Request-Method: PUT
S-->>B: 200 OK<br>Access-Control-Allow-Origin: http://localhost:5173<br>Access-Control-Allow-Methods: PUT
B->>S: PUT /notes<br>本来のリクエスト
S-->>B: 200 OK<br>Access-Control-Allow-Origin: http://localhost:5173
サーバがプリフライトに対して許可を返せば、ブラウザは本来のリクエストを送信します。許可されないヘッダやメソッドが含まれていれば、本来のリクエスト自体が送信されずにブロックされます。
| 📝 CORSはブラウザの仕組み |
|---|
CORSはあくまでブラウザが実装している安全装置であり、APIサーバ自体を保護するものではありません。curlやサーバ間の通信ではCORSヘッダの有無にかかわらずリクエストは通ります。APIサーバのアクセス制御は、CORSとは別に認証・認可の仕組みで守る必要があります。 |
4. FastAPIのCORSMiddlewareによる対応
CORSの仕組みが分かったところで、APIサーバにAccess-Control-Allow-Originヘッダを返す設定を組み込みます。FastAPIには、Starletteが提供するCORSMiddlewareが同梱されており、これをapp.add_middlewareで登録することで実現できます。
4.1 ミドルウェアの導入
ミドルウェアの追加
main.pyを以下のように書き換えて保存します。これは、http://localhost:5173からのアクセスを許可するCORSミドルウェアを組み込んだFastAPIアプリケーションです。GET /notesとPUT /notes/{note_id}のルートは変えずに、app.add_middleware(...)の呼び出しを追加した形になります。
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "Authorization"],
)
@app.get("/notes")
def list_notes():
return [
{"id": 1, "title": "買い物"},
{"id": 2, "title": "打ち合わせ資料"},
]
@app.put("/notes/{note_id}")
def update_note(note_id: int):
return {"id": note_id, "status": "updated"}
動作確認
uvicornは--reloadオプションで起動しているので、ファイルの保存でサーバが自動的に再起動されます。ターミナルに以下のようなメッセージが表示されます。
WARNING: WatchFiles detected changes in 'main.py'. Reloading...
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
ブラウザに戻って、http://localhost:5173/を再読み込みしてください。画面に以下のようにノート一覧が表示されれば、CORS対応は成功しています。
Notes
・ 1: 買い物
・ 2: 打ち合わせ資料
レスポンスヘッダにAccess-Control-Allow-Origin: http://localhost:5173が実際に付いていることは、別のターミナルからcurlで確認できます。ブラウザからの呼び出しを再現するため、-H "Origin: http://localhost:5173"でリクエスト元のオリジンを付けて送り、-iオプションでレスポンスヘッダも表示します。
curl -i -H "Origin: http://localhost:5173" http://localhost:8000/notes
以下のような実行結果が表示されます。
HTTP/1.1 200 OK
access-control-allow-origin: http://localhost:5173
content-type: application/json
content-length: 71
[{"id":1,"title":"買い物"},{"id":2,"title":"打ち合わせ資料"}]
access-control-allow-origin: http://localhost:5173の行があれば、CORSヘッダが正しく付与されています。逆に-H "Origin: ..."を付けずに叩くと、ブラウザ以外からの通常のリクエスト扱いになり、このヘッダは付きません(ミドルウェアがオリジン判定して付与するため)。
コードの解説
from fastapi.middleware.cors import CORSMiddleware
CORSMiddlewareはFastAPIに同梱されているミドルウェアで、Starlette由来の実装が使われます。追加のインストールは不要です。
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
...
)
app.add_middlewareで、ミドルウェアクラスとその設定を渡します。ここに登録したミドルウェアは、すべてのリクエストに対して自動的にCORSヘッダの付与とプリフライト(OPTIONS)の応答を行うようになります。
allow_origins=["http://localhost:5173"]
CORSで許可するオリジンをリストで指定します。ここに含まれるオリジンからのリクエストに対してのみ、Access-Control-Allow-Originヘッダが付与されます。複数のオリジンを列挙することもできます。
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]
プリフライトリクエストで許可するHTTPメソッドを指定します。OPTIONSはプリフライト自身のメソッドで、通常は明示的に含めておきます。
allow_headers=["Content-Type", "Authorization"]
リクエストで許可するヘッダを列挙します。Content-Type: application/jsonでJSONを送る場合や、Authorizationヘッダでトークンを送る場合はここに含めておく必要があります。
| ⚠️ 「Failed to fetch」がブラウザで解消しない場合 |
|---|
ブラウザは一度受け取ったレスポンス(CORS判定を含む)をキャッシュする場合があります。ミドルウェアを追加したのに画面上でエラーが続く場合は、ブラウザの再読み込みを「スーパーリロード」(ChromeではShift + F5またはCtrl + Shift + R/Cmd + Shift + R)で行い、開発者ツールを開いた状態で「Network」タブのDisable cacheにチェックを入れて再度確認してください。 |
4.2 プリフライトリクエストの確認
これまで確認してきたGET /notesは「単純リクエスト」に該当するため、ブラウザはOPTIONSを送らずにGETをそのまま投げていました。ここでは、単純リクエストの条件から外れるPUT /notes/{note_id}(main.pyに登録済み)に対して、プリフライト(OPTIONS)が実際に発生することをcurlで確認します。
プリフライトリクエストの再現
OPTIONSリクエストにプリフライト固有のヘッダを付けて送ります。書き方は以下のとおりです。
curl -X OPTIONS -i \
-H "Origin: 呼び出し元のオリジン" \
-H "Access-Control-Request-Method: 本来送るメソッド" \
-H "Access-Control-Request-Headers: 本来送るヘッダ" \
URL
上記の書式に沿って、本来送りたいメソッドPUTと、本来付けたいヘッダContent-Typeを伝えるプリフライトを、別のターミナルから送ります。
curl -X OPTIONS -i \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: PUT" \
-H "Access-Control-Request-Headers: Content-Type" \
http://localhost:8000/notes/1
以下のような実行結果が表示されます。
HTTP/1.1 200 OK
access-control-allow-origin: http://localhost:5173
access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers: Content-Type, Authorization
vary: Origin
access-control-allow-methodsにPUTが、access-control-allow-headersにContent-Typeが含まれています。ブラウザはこの応答を見て「PUTとContent-Typeヘッダは許可されている」と判断し、続けて本来のPUTリクエストを送ります。
コードを解説します。
-H "Origin: http://localhost:5173"
呼び出し元のオリジンをサーバに伝えるヘッダです。ブラウザはクロスオリジンリクエストの際に自動でこれを付けますが、curlでは明示的に指定します。
-H "Access-Control-Request-Method: PUT"
本来これから送るリクエストのHTTPメソッドを、プリフライトの段階でサーバに事前通知します。サーバはこれとallow_methodsを突き合わせて許可可否を判定します。
-H "Access-Control-Request-Headers: Content-Type"
本来これから送るリクエストに含める非単純リクエスト用のヘッダを、プリフライトの段階でサーバに事前通知します。サーバはこれとallow_headersを突き合わせて許可可否を判定します。
access-control-allow-methods: GET, POST, PUT, DELETE, OPTIONS
access-control-allow-headers: Content-Type, Authorization
サーバが許可するメソッドとヘッダを列挙するレスポンスヘッダです。CORSMiddlewareがallow_methods・allow_headersの値をもとに自動で組み立てます。
vary: Origin
「同じURLでも、Originリクエストヘッダの内容が変わるとレスポンスが変わる」ことをブラウザやプロキシに伝えるヘッダです。CORSレスポンスは呼び出し元のオリジンに応じて変わるため、Varyでキャッシュの取り違えを防ぎます。
本来のリクエストの送信
プリフライトが通ったあと、ブラウザは本来送りたいリクエストを続けて投げます。この段階もcurlで再現します。書き方は以下のとおりです。
curl -X メソッド -i \
-H "Origin: 呼び出し元のオリジン" \
-H "Content-Type: application/json" \
-d 'JSON文字列' \
URL
上記の書式に沿って、PUT /notes/1にJSONを送ります。
curl -X PUT -i \
-H "Origin: http://localhost:5173" \
-H "Content-Type: application/json" \
-d '{"title":"更新済み"}' \
http://localhost:8000/notes/1
以下のような実行結果が表示されます。
HTTP/1.1 200 OK
access-control-allow-origin: http://localhost:5173
content-type: application/json
content-length: 27
{"id":1,"status":"updated"}
ブラウザ経由の場合、OPTIONS → PUTの2ステップが自動的に行われます。プリフライトが失敗した場合(access-control-allow-methodsに対象メソッドが含まれない、など)は、本来のPUTリクエストは送信すらされずにブロックされる点が、単純リクエストとの違いです。CORSMiddlewareは、allow_methods・allow_headersに含まれる範囲であれば、OPTIONSへの応答を自動で組み立てます。
allow_origins に * を使うことの是非
CORSMiddlewareでは、すべてのオリジンを許可するallow_origins=["*"]という指定も可能ですが、以下の理由から利用には注意が必要です。
- 悪意のある第三者のサイトからも、そのAPIをブラウザ経由で呼び出せる状態になる
- Cookieによる認証(
allow_credentials=True)とallow_origins=["*"]は同時に使えず、ブラウザにブロックされる
原則としては、allow_originsに明示的なオリジンを列挙する運用が安全です。開発時に一時的に緩めたいだけであっても、allow_origins=["*"]はデフォルトの選択肢にはしません。
| 💡 ポイント |
|---|
本章ではallow_originsにオリジンをハードコードしていますが、実際には開発環境・ステージング・本番環境で許可するオリジンが変わります。運用では環境変数や.envファイル(前章 PythonでREST APIを作ろう で扱ったpython-dotenv)からオリジンを読み込み、コードを書き換えずに切り替えられる形にするのが一般的です。 |
| 📝 CORSと認証の関係 |
|---|
CookieやAuthorizationヘッダを伴うクロスオリジンリクエストを許可するには、allow_credentials=Trueと、allow_originsに具体的なオリジンを指定する必要があります(allow_origins=["*"]は同時に使えない)。次章の PythonでJWT認証を実装しよう でAuthorizationヘッダによる認証を扱う際、この組み合わせが前提となります。 |
5. 不要リソースの削除
ハンズオンで起動した2つのサーバは、Ctrl + Cで停止すればリソースが残ることはありません。フォルダ(cors-hands-on/)はそのまま残しておいても支障ありませんが、不要であれば削除してください。
6. まとめ
この章では、CORSの仕組みを学びつつ、実際にFastAPIのCORSMiddlewareでブラウザからのクロスオリジン呼び出しを許可する実装を体験しました。
- Same-Origin Policyにより、ブラウザは異なるオリジンへの
fetch結果をデフォルトでブロックする - オリジンは、プロトコル・ホスト・ポートの3つの組み合わせで決まる
- CORSは、サーバが
Access-Control-Allow-Originなどのヘッダで「このオリジンからのアクセスを許可する」と明示する仕組みである - プリフライトが必要なリクエストでは、ブラウザが本リクエスト前に
OPTIONSメソッドで許可を問い合わせる - CORSはブラウザの安全装置であり、
curlやサーバ間通信には効かない。APIサーバのアクセス制御は認証・認可で別途行う - FastAPIでは
CORSMiddlewareをapp.add_middlewareで登録することで、CORSヘッダとプリフライト応答を自動化できる allow_originsに許可オリジンを明示的に列挙する運用が基本であり、allow_origins=["*"]は原則使わないPUT・DELETEやContent-Type: application/jsonなどを含むリクエストではプリフライト(OPTIONS)が先に飛び、CORSMiddlewareはこれにも自動応答する
次の章では、PythonでJWT認証を実装する流れをハンズオン形式で学びつつ、ログインAPIからトークン検証・失効管理までを体験します。