開発

FastAPIのミドルウェアの使い方|BaseHTTPMiddlewareと純ASGIの違い・実行順序・CORSをコードで解説

FastAPIのミドルウェアの使い方|BaseHTTPMiddlewareと純ASGIの違い・実行順序・CORSをコードで解説

FastAPIのミドルウェアは、すべてのリクエストがエンドポイントに届く前と、すべてのレスポンスが返る前に、共通の処理を差し込む仕組みです。処理時間の計測、リクエストIDの付与、CORSのヘッダー、レスポンスの圧縮のように、エンドポイントごとに書きたくない処理をまとめて差し込むための置き場所です。この記事では、FastAPI 0.162.7とStarlette 1.10.0を前提に、@app.middleware("http")の書き方から、複数登録したときの実行順序、BaseHTTPMiddlewareと純ASGIミドルウェアの違い、組み込みのCORS・GZipの設定で起きる事故までを、コードとソースの実物で確かめていきます。なお、OSとアプリの間で働くインフラ用語の「ミドルウェア」とは別物で、そちらはミドルウェアの種類と選定の解説で扱っています。

まとめ:FastAPIのミドルウェアを書く前に決めておく3つの結論

手軽に書くなら@app.middleware("http")で足ります。ただし、その正体はBaseHTTPMiddlewareで、WebSocketには効かず、エンドポイントで設定したContextVarも外側から読めません。リクエストIDやトレースのように、WebSocketを含む全経路で動かしたい処理や、ContextVarを使う処理は、純ASGIミドルウェアで書きます。

実行順序は「後から登録したものが外側」です。CORSのように、エラー応答にも付けたいヘッダーは最後に登録して外側に置きます。また、ミドルウェアの中でHTTPExceptionを投げても例外ハンドラーには届かず500になるので、拒否するときはJSONResponseを直接返します。

組み込みのCORSでallow_origins=["*"]とallow_credentials=Trueを同時に指定すると、Starlette 1.10.0は要求元のOriginをそのまま許可して返します。公式ドキュメントの「併用できない」という説明とは違い、エラーにはなりません。資格情報を扱うAPIでは、許可するオリジンを必ず列挙します。

FastAPI 0.162系でミドルウェアを動かす検証環境と最小コードの書き方

まずは1つのミドルウェアを書き、リクエストの前後で処理が動く様子をヘッダーで確かめます。

fastapi[standard]0.162.7とStarlette 1.10導入・起動

2026年11月14日時点のPyPIで、FastAPIの最新は0.162.7(2026年11月13日公開)です。動作要件はPython 3.11以上で、土台のStarletteは1.10.0(2026年10月24日公開)が入ります。FastAPIの起動そのものが初めてなら、FastAPIの最短の起動手順とasync/defの使い分けを先に済ませてください。

# 仮想環境を作り、FastAPIとテスト用のpytestを入れる
python3 -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]==0.162.7" pytest

# 入った版を確かめる(starlette 1.10.0 になっているか)
pip show fastapi starlette | grep -E "^(Name|Version)"

# main.py を置いたディレクトリで開発サーバーを起動する
fastapi dev main.py

FastAPIの依存指定はstarlette>=0.46.0なので、既存の環境では古いStarletteが残っていることがあります。後で触れるGZipの除外設定やボディサイズの上限はStarletteの版で挙動が変わるため、pip showで版を確かめてから読み進めてください。

@app.middlewareで処理時間をヘッダーに付ける最小のコード例

公式のミドルウェアの説明と同じ形で、処理時間をX-Process-Timeヘッダーに入れるミドルウェアを書きます。

# main.py
import time
from collections.abc import Awaitable, Callable

from fastapi import FastAPI, Request, Response

app = FastAPI()


@app.middleware("http")
async def add_process_time(
    request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
    start = time.perf_counter()
    response = await call_next(request)  # ここでエンドポイントまで処理が進む
    response.headers["X-Process-Time"] = f"{time.perf_counter() - start:.4f}"
    return response


@app.get("/items/{item_id}")
async def read_item(item_id: int):
    return {"item_id": item_id}
# 別の端末から呼び、レスポンスヘッダーを表示する
curl -i "http://127.0.0.1:8000/items/1"

応答ヘッダーにx-process-time: 0.0003のような行が付けば、ミドルウェアが動いています。存在しないパスで404が返る場合や、型が合わず422が返る場合も、ミドルウェアは同じように通ります。

call_nextの前後でリクエストとレスポンスを触る範囲の線引き

call_nextより前に書いた処理はエンドポイントの前に、後ろに書いた処理はエンドポイントの後に動きます。前ではヘッダーやパス、クエリを読み、後ろではステータスコードとヘッダーを書き換えるのが基本の使い方です。

本文の書き換えは避ける方針です。call_nextが返すレスポンスは、Starlette 1.10.0のソースではチャンク単位で流れるストリームになっていて、本文を書き換えるには全体を読み切る必要があります。大きなファイルの配信で読み切ると、その分だけメモリの消費が増える仕組みです。本文を変えたい場合は、後述の純ASGIミドルウェアでhttp.response.bodyのメッセージを扱い、Content-Lengthも合わせて直します。

add_middlewareの登録順と例外ハンドラーとの位置関係を実測で確かめる

ミドルウェアが2つ以上になると、どれが先に動くかで結果が変わります。FastAPIのソースと実測で、積み方を確かめます。

後から登録したミドルウェアが外側になる積み順とログでの確認方法

Starlette 1.10.0のadd_middlewareは、登録したミドルウェアをリストの先頭に差し込みます。そのため、後から登録したものほど外側に来て、リクエストでは先に、レスポンスでは後に動く順序です。@app.middleware("http")も内部ではadd_middlewareを呼ぶだけなので、デコレーターとメソッドを混ぜても同じ規則に従います。

app.add_middleware(MiddlewareA)  # 先に登録:内側
app.add_middleware(MiddlewareB)  # 後に登録:外側

# Starlette 1.10.0 で実測した呼び出し順
# B:in → A:in → endpoint → A:out → B:out

アプリが最初のリクエストを受けた時点でミドルウェアの積み方は確定し、その後にadd_middlewareを呼ぶと「Cannot add middleware after an application has started」のRuntimeErrorになります。ミドルウェアの登録は、アプリを作ったモジュールの読み込み時に済ませます。

ミドルウェア内のHTTPExceptionが500になる理由と返し方

FastAPI 0.162.7のbuild_middleware_stackは、外側から順に次のように積みます。表の例外テレメトリー層はExceptionTelemetryMiddlewareを指す表記です。

位置 ミドルウェア
最も外側 ServerErrorMiddleware
2番目 例外テレメトリー層
3番目 利用者が登録したミドルウェア
4番目 ExceptionMiddleware
最も内側 AsyncExitStackMiddleware

exception_handlersを処理するExceptionMiddlewareは、利用者のミドルウェアより内側にあります。そのため、ミドルウェアの中でraise HTTPException(403)と書いても例外ハンドラーには届かず、外側のServerErrorMiddlewareが受けて500を返します。Starlette 1.10.0で確かめたところ、403のつもりの応答が「500 Internal Server Error」になりました。

from fastapi.responses import JSONResponse


@app.middleware("http")
async def block_by_header(request: Request, call_next):
    if request.headers.get("x-api-key") != "expected-key":
        # raise HTTPException ではなく、レスポンスを直接返す
        return JSONResponse({"detail": "Forbidden"}, status_code=403)
    return await call_next(request)

エンドポイントの中で起きたHTTPExceptionは、内側のExceptionMiddlewareが通常どおり処理するため、ミドルウェアからは整形済みの応答として見えます。

yield依存の終了処理とBackgroundTasksが走るタイミング

公式の説明では、yieldを使う依存関係の終了処理はミドルウェアの後に、バックグラウンドタスクはすべてのミドルウェアの後に実行されます。ミドルウェアで計った処理時間には、DBセッションのクローズや送信後のメール処理は入りません。

古いStarletteでは、BaseHTTPMiddlewareを挟むとバックグラウンドタスクの終わりまで応答が待たされる問題がありました。Starletteのリリースノートによると、1.7.0(2026年9月)で「応答の送信後にだけバックグラウンドタスクを実行する」よう修正されています。重いタスクで応答が遅いと感じたら、まずStarletteの版を確かめます。

BaseHTTPMiddlewareと純ASGIミドルウェアの違いと書き分けの基準

FastAPIで書けるミドルウェアには、call_nextを使うBaseHTTPMiddleware型と、ASGIのscope・receive・sendを直接扱う純ASGI型の2種類があります。

@app.middlewareの正体がBaseHTTPMiddlewareである点と制約

FastAPI 0.162.7のmiddleware()メソッドの中身は、渡した関数をadd_middleware(BaseHTTPMiddleware, dispatch=func)で登録する数行だけです。つまり、デコレーターで書いた関数も、BaseHTTPMiddlewareを継承してdispatchを書いたクラスも、動きは同じになります。

なお、土台のStarletteは1.0.0rc1で@app.middleware()デコレーターを削除しました(PR 3117)。FastAPIは自前の実装としてデコレーターを残しているため、FastAPIでは今も使えますが、Starletteの素のアプリに同じコードを移すと動きません。

ContextVarが外側へ伝わらない制約とWebSocketに効かない仕様

Starletteのミドルウェアの説明は、BaseHTTPMiddlewareの既知の制約として、エンドポイントで変更したcontextvars.ContextVarの値がミドルウェア側へ伝わらない点を挙げています。実測でも、エンドポイントで値を入れたContextVarをcall_nextの後で読むと、既定値の"unset"のままでした。同じ処理を純ASGIミドルウェアで書くと、入れた値が読めます。

もう1つの制約はWebSocketです。BaseHTTPMiddlewareはscope["type"]が"http"以外のときは何もせず素通しするため、WebSocketの接続ではdispatchが一度も呼ばれません。認証やリクエストIDをWebSocketにも効かせたいなら、純ASGIで書くのが前提です。WebSocket側の実装はFastAPIのWebSocket実装の解説で扱っています。

観点 BaseHTTPMiddleware 純ASGI
書きやすさ call_nextで簡単 scopeとsendを扱う
WebSocket 呼ばれない 扱える
ContextVar 外側へ伝わらない 伝わる
本文の書き換え 全体の読み切りが要る チャンク単位で可能

純ASGIミドルウェアでリクエストIDを付与する実装とテスト

純ASGI型で、リクエストIDを付けるミドルウェアを書きます。受け取ったX-Request-IDを使い回しますが、外部から来る値なので、英数字とハイフンの64文字以内に合わない場合は捨てて新しく発行します。改行などの制御文字をそのまま応答ヘッダーやログに流さないための形式検証です。

# request_id.py
import re
import uuid

from starlette.datastructures import Headers, MutableHeaders
from starlette.types import ASGIApp, Message, Receive, Scope, Send

_VALID_ID = re.compile(r"[A-Za-z0-9-]{1,64}")


class RequestIDMiddleware:
    def __init__(self, app: ASGIApp, header_name: str = "x-request-id") -> None:
        self.app = app
        self.header_name = header_name

    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        # lifespan など、HTTPとWebSocket以外はそのまま通す
        if scope["type"] not in ("http", "websocket"):
            await self.app(scope, receive, send)
            return

        incoming = Headers(scope=scope).get(self.header_name, "")
        request_id = incoming if _VALID_ID.fullmatch(incoming) else uuid.uuid4().hex
        # request.state.request_id で読めるよう scope["state"] に入れる
        scope.setdefault("state", {})["request_id"] = request_id

        async def send_with_id(message: Message) -> None:
            if message["type"] == "http.response.start":
                MutableHeaders(scope=message)[self.header_name] = request_id
            await send(message)

        await self.app(scope, receive, send_with_id)
# main.py に追加
from request_id import RequestIDMiddleware

app.add_middleware(RequestIDMiddleware)


@app.get("/whoami")
async def whoami(request: Request):
    return {"request_id": request.state.request_id}

状態は__call__の中のローカル変数に閉じ、インスタンスの属性には書きません。ASGIのミドルウェアは同時に来る複数のリクエストで1つのインスタンスを共有するため、selfに書くとリクエスト同士で値が混ざります。テストは次のように書けます。

# test_request_id.py
from fastapi.testclient import TestClient

from main import app


def test_request_id_is_reused_or_regenerated():
    with TestClient(app) as client:
        res = client.get("/whoami", headers={"x-request-id": "abc-123"})
        assert res.headers["x-request-id"] == "abc-123"
        assert res.json() == {"request_id": "abc-123"}

        # 形式に合わない値は捨て、32桁のIDを新しく発行する
        res = client.get("/whoami", headers={"x-request-id": "abc\tdef"})
        assert len(res.headers["x-request-id"]) == 32

Starlette 1.10.0単体で同じミドルウェアを動かしたところ、HTTPとWebSocketの両方でIDが入り、タブ文字を含む値は新しいIDに置き換わりました。このIDをログの各行に載せて相関を取る方法は、FastAPIのloggingの設定方法|uvicornのログ統合・JSON化・リクエストIDをコードで解説で、ContextVarとlogging.Filterを使うコードとともに解説しています。

CORS・GZip・TrustedHostなど組み込みミドルウェアの設定値と落とし穴

FastAPIはfastapi.middlewareからStarletteの組み込みミドルウェアをそのまま使えるようにしています。設定値の既定と、実物で確かめた挙動を整理します。

CORSMiddlewareの全オリジン許可と資格情報を併用する危険

公式のCORSの説明は、allow_credentials=Trueのときはallow_originsなどを["*"]にできないと書いています。ところがStarlette 1.10.0のCORSMiddlewareは、この組み合わせを拒否せず、要求のOriginをそのままAccess-Control-Allow-Originに入れて返します。

# 実測:allow_origins=["*"], allow_credentials=True のアプリへ
curl -i -H "Origin: https://evil.example" "http://127.0.0.1:8000/"
# access-control-allow-origin: https://evil.example
# access-control-allow-credentials: true

これは、どのサイトからでもCookie付きでAPIの応答を読める状態です。ブラウザの制限を外す設定なので、資格情報を使うAPIでは許可するオリジンを列挙します。

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],  # "*" にしない
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["Authorization", "Content-Type"],
    expose_headers=["X-Request-ID"],  # ブラウザのJSから読ませるヘッダー
)

許可外のオリジンやメソッドのプリフライトには、400で返る応答は「Disallowed CORS origin」「Disallowed CORS method」です。CORSは最後に登録して最も外側に置くと、内側のミドルウェアが返した拒否応答にもCORSヘッダーが付き、ブラウザ側で原因を読めるようになります。CORSの仕組みやブラウザのエラー文の読み方はCORSの仕組みとエラー文別の解決法、Reactと組むときの設定はReact×FastAPI連携の手順にまとめています。

GZipMiddlewareのminimum_sizeと圧縮対象外の条件

GZipMiddlewareは、Accept-Encodingにgzipを含むリクエストの応答を圧縮します。既定のminimum_sizeは500バイト、compresslevelは9です。

from fastapi.middleware.gzip import GZipMiddleware

# 1KB未満は圧縮しない。CPUを抑えたいなら圧縮率を下げる
app.add_middleware(GZipMiddleware, minimum_size=1000, compresslevel=5)

Starlette 1.10.0で確かめると、600バイトの応答は圧縮され、100バイトの応答とtext/event-streamの応答は圧縮されませんでした。Server-Sent Eventsや画像・動画・zipなどは既定の除外リストに入っており、これは1.5.0(2026年8月)で追加されたexclude_content_typesで変更できます。SSEを圧縮しない扱いは0.46.0(2025年2月)からで、それより古いStarletteではSSEも圧縮の対象でした。

TrustedHostとHTTPSRedirectを本番のプロキシ構成で使う条件

公式の高度なミドルウェアの説明にあるTrustedHostMiddlewareは、Hostヘッダーが許可リストに無いリクエストに400を返します。Hostヘッダーを使った攻撃の対策で、Hostを信頼した認可の迂回が問題になった例はStarletteのCVE-2026-48710の解説で扱っています。

HTTPSRedirectMiddlewareは、ロードバランサーでTLSを終端する構成で無限リダイレクトを起こしやすい部品です。アプリにはHTTPで届くため、そのままでは毎回HTTPSへ転送してしまいます。Uvicornの設定では、--proxy-headersは既定で有効ですが、X-Forwarded-Protoを信頼するのは--forwarded-allow-ips(既定は127.0.0.1と::1)からの接続だけです。ロードバランサーのアドレスを許可するか、HTTPSへの転送自体をロードバランサー側で行います。

ミドルウェアに書く処理とDependsやプロキシ層へ回す処理の判断基準

ミドルウェアは全リクエストに効く分、置き場所を誤ると影響も全体に及びます。判断の軸は「全経路に一律で効かせるか」です。

ルーター単位で掛けたい処理をミドルウェアに置かない理由と代替

FastAPI 0.162.7のAPIRouterにはmiddleware引数がありません。FastAPIのリリースノートでは、0.137.0(2026年6月)の内部変更で「ルーターごとのミドルウェア」が今後可能になる機能として挙がっているだけで、まだ実装されていない状態です。

管理画面のAPIだけ認証したい、といったルーター単位の処理は、ミドルウェアでパスを文字列判定するより、APIRouter(dependencies=[Depends(...)])に書きます。パスの判定はprefixの変更で簡単に漏れますが、依存はルーターに付いて移動します。書き方はAPIRouterのprefixと依存の指定方法、どちらに置くかの線引きはDependsとミドルウェアの線引きを含むDependsの解説を参照してください。

認証・ボディ検査・レート制限をミドルウェアに入れて失敗する例

認証をミドルウェアで書くと、OpenAPIのドキュメントに認証の情報が出ず、エンドポイントごとの例外(ヘルスチェックだけ認証なし、など)がパスの一覧で管理されます。認証はDependsに置き、ミドルウェアは全経路に共通の処理(リクエストID・処理時間・CORS・圧縮)に絞るのが安全です。

リクエストボディをawait request.body()で読んで検査する書き方は動きますが、BaseHTTPMiddlewareは本文全体をメモリに保持して下流へ渡します。巨大なアップロードで使うとメモリを圧迫するため、上限はStarlette 1.6.0で入ったRequestBodyLimitMiddlewareで掛ける方法です。設定はFastAPIでPOSTを受け取る方法のボディ上限の章が設定の参照先です。レート制限は、ワーカーやコンテナが複数あるとプロセス内のカウンターでは数えられないため、API GatewayやリバースプロキシかRedisなどの共有ストアに寄せます。既存のAPIで共通処理の置き場所を整理したい、認証や流量制御の設計から相談したい場合は、API開発・システム連携の受託開発で受け付けています。

よくある質問

FastAPIのミドルウェアについて、実装の途中で出てくることの多い質問に、2026年11月時点の版で答えます。

特定のパスだけミドルウェアの処理を飛ばすにはどうしますか?

ミドルウェアの先頭でrequest.url.pathを見て、対象外ならreturn await call_next(request)で素通しします。ヘルスチェックや静的ファイルを処理時間の計測から外す、といった用途です。ただし、除外の一覧が増えるならルーター単位の処理になっている合図なので、APIRouterのdependenciesに移します。

ミドルウェアでリクエストボディを読んでもエンドポイントで使えますか?

エンドポイントでも同じ本文を使うことが可能です。Starlette 1.10.0のBaseHTTPMiddlewareは、dispatchの中でawait request.body()を呼ぶと本文をメモリに保持し、下流のエンドポイントに同じ本文を渡します。一方、request.stream()で読み切った場合は、下流には空の本文が渡されます。署名の検証のように本文が要る処理は、エンドポイントか依存関数で読む方が素直です。

@app.middleware(“http”)以外の種類は指定できますか?

FastAPI 0.162.7のソースでは、引数の説明は「現在はhttpだけに対応」という記述です。WebSocketやlifespanのイベントに処理を差し込みたいなら、純ASGIミドルウェアでscope["type"]を見て分岐します。起動時と終了時の処理は、ミドルウェアではなくlifespanで書く方法を使います。

ミドルウェアが多いと遅くなりますか?

1つ挟むごとに増えるのは関数呼び出しの1段で、ヘッダーを付ける程度の処理なら負担は小さく済みます。遅くなる原因の多くは中身で、BaseHTTPMiddlewareで本文を読み切る処理、同期のDBアクセス、外部APIの呼び出しがよくある例です。ミドルウェアの中で外部へ通信するなら、httpx.AsyncClientを使い回してawaitで呼びます。

Starlette 1.10でTestClientを使うと警告が出るのはなぜですか?

Starlette 1.10.0では、テストクライアントの通信にhttpxを使うと「httpx2を入れるように」という非推奨の警告が出ます。テストはそのまま動くので、まずは警告として扱って構いません。警告を消したい場合は、使っているFastAPIとStarletteの組み合わせで推奨されるパッケージを、各ライブラリのリリースノートで確認してから入れ替えます。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.13 テックブログ 宝くじ公式サイト不正アクセス:4,436名の対象確認とパスワード再設定、変更された仕組み
  2. 2026.11.13 テックブログ KARTEの不正アクセス:全機能停止で利用企業がタグ・配信・データ連携を確かめる手順
  3. 2026.11.14 テックブログ ヤマダデンキの不正アクセス:リフォーム管理システム最大350万件と、住所・見積を持つ営業記録の守り方
  4. 2026.10.21 テックブログ マネックス証券の不正アクセス:メールアドレス最大約113万件と本人確認画像1,592件の漏えいのおそれ、口座開設停止とPHP脆弱性との関係
  5. 2026.11.11 テックブログ Microsoft 2026年11月の月例更新:Outlookの悪用済みCVE-2026-124194と修正版の確認方法

RELATED POSTS 関連記事

目次