# FastAPI 認証・認可 本番実装ガイド：OAuth2パスワードフロー × JWT（PyJWT）× Security scopesで守るAPI

> FastAPIで本番品質の認証・認可を実装するガイド。公式ドキュメント最新版に忠実なpwdlib+Argon2のパスワードハッシュ、PyJWTによるJWT発行/検証、OAuth2パスワードフローの/token、get_current_user、Security scopesに加え、短命トークン＋リフレッシュ・CORS・レート制限・HTTPS・テストまで本番ハードニングを実コードで解説します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: Python, FastAPI, セキュリティ, 認証・認可, JWT, OAuth2
- URL: https://tomodahinata.com/blog/fastapi-authentication-oauth2-jwt-security-scopes-production-guide

## 要点

- 認証(本人確認)と認可(権限)は別物。OAuth2パスワードフローは『/tokenでトークンを発行 → 以後 Authorization: Bearer で検証』の一筆書き。fastapi.securityがこの配線を型で支える
- パスワードはpwdlibのArgon2でハッシュ化（PasswordHash.recommended()）。暗号化ではなくハッシュであり、平文保存は厳禁。レガシーハッシュ互換が要るときだけpasslib
- JWTはPyJWT（import jwt / HS256）。jwt.decodeでのalgorithms=[...]明示は必須（alg:none・アルゴリズム混同攻撃の防止）。subは一意な文字列、expはtimezone-aware、SECRET_KEYは環境変数
- 認可はSecurity scopes。Dependsでなく Security(...) でスコープを宣言し、SecurityScopesで必要権限を検証。失敗時は WWW-Authenticate: Bearer scope=... を返す
- 本番はここから：短命アクセストークン＋リフレッシュ（JWTは失効できない）、ルーター層での認可一元化、CORSのallow_credentials×ワイルドカード非互換、/tokenのレート制限、HTTPS必須、dependency_overridesでテスト

---

「FastAPI で API にログインを付けたい」——要件は一行です。けれど**認証は、一行のミスがそのまま情報漏洩になる**領域です。パスワードを平文で保存した、JWT の検証で `algorithms` を指定し忘れた、`Authorization` ヘッダを信用してユーザーを取り違えた——どれも「動いてしまう」がゆえに、本番に出るまで気づきません。

この記事は、FastAPI で**本番品質の認証（AuthN）と認可（AuthZ）**を実装するためのガイドです。FastAPI 公式チュートリアルの `OAuth2 + JWT` の流れを**最新の公式仕様に忠実に**追いながら、公式が「デモ用」と断っている部分（コード内のダミーユーザー・ハードコードした鍵）を、**実運用に耐える形**へ引き上げます。題材として、私が国内大手放送事業者向けに構築した社内AIプラットフォーム（[FastAPI 製のテロップ誤字検出パイプライン](/case-studies/broadcaster-ai-content-platform)。各ツールへ audience を絞った**短命JWT**を発行する自作OIDC認証ハブを設計）と、[経済産業大臣賞を受賞した木材流通B2B SaaS](/case-studies/lumber-industry-dx)（**全221エンドポイントを認可で保護**し、第三者ペネトレで認証欠落0件を実証）での設計判断も交えます。

> **この記事のルール**：API・推奨ライブラリは **FastAPI 公式ドキュメント（2026年6月時点）** に基づきます。公式は近年、パスワードハッシュを `passlib` → **`pwdlib`（Argon2）**、JWT を `python-jose` → **`PyJWT`** に更新しました。本記事はこの最新版に準拠します。仕様は改定されるため、本番投入前に必ず公式で最新の挙動を確認してください。**シークレット（SECRET_KEY・DB URL・鍵）は環境変数前提**（ハードコード厳禁）。そして **OAuth2 自体は通信を暗号化しません——本番は HTTPS が必須**です。

---

## 0. まず地図：認証と認可、OAuth2、OpenID Connect

実装に入る前に、言葉を揃えます。ここが曖昧なまま書くと、設計が必ずぶれます。

- **認証（Authentication / AuthN）**＝**あなたは誰か**を確かめること。ログイン、トークン検証。
- **認可（Authorization / AuthZ）**＝**あなたに何が許されるか**を決めること。権限・ロール・スコープ。

この2つは別物です。「ログインできた（AuthN）」は「管理者ページを見てよい（AuthZ）」を**意味しません**。本記事は前半で AuthN（2〜5章）、後半で AuthZ（6章）を扱います。

公式の整理も押さえておきます。

- **OAuth2** は認証・認可を扱う仕様群。「Facebook / Google / GitHub でログイン」のような**第三者**を介する方式まで含む、広い仕様です。OAuth2 は**通信の暗号化を規定しません**——HTTPS で配信される前提です。
- **OpenID Connect（OIDC）** は OAuth2 の上に立つ仕様で、OAuth2 の曖昧な部分を補い相互運用性を高めたもの。Google ログインは OIDC です。
- FastAPI は `fastapi.security` に、これらの**スキーム（apiKey / http / oauth2 / openIdConnect）に対応する道具**を用意しています。

OAuth2 には複数の「フロー」があり、本記事が使うのは **`password`（パスワードフロー）**。これは**同じアプリの中で**ユーザー名・パスワードを受け取り認証する、最もシンプルなフローです。

### 0.1 自前で実装するか、マネージドIdPに寄せるか（最初の分岐）

ここは本番設計の最初の意思決定です。**「ユーザーのパスワードを自分で預かる」責任の重さ**を、過小評価してはいけません。

| 観点 | FastAPI で自前実装（本記事） | マネージドIdP（Cognito / Auth0 / Clerk） |
| --- | --- | --- |
| 向くケース | 単一アプリ・社内ツール・学習・完全な制御が要る | SSO・ソーシャルログイン・MFA・SAMLが要る、運用を任せたい |
| パスワード保管 | 自分で Argon2 ハッシュして保管（責任を負う） | IdP が保管（自分は持たない＝漏洩面が減る） |
| MFA・パスワードリセット | 自前で実装 | 標準機能 |
| 検証 | 自分の鍵で JWT を発行・検証 | IdP の公開鍵（JWKS）で **RS256** 検証 |

判断の目安：**「自社ユーザーのパスワードを預かる必然性」が無いなら、まずマネージドIdPを検討**してください（[認証基盤の選定はこちらで比較](/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase)）。本記事は「自前で正しく実装する」道を扱いますが、マネージドIdPを使う場合でも、後半の **JWT 検証の勘所・認可（スコープ）・ルーター層での一元化**はそのまま役立ちます（[Cognito の RS256 + JWKS 検証はこちら](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide)）。

---

## 1. 全体像：OAuth2 パスワードフローの一筆書き

先に完成形の流れを掴みます。登場人物とデータの動きはこれだけです。

1. クライアントが **`/token`** にユーザー名・パスワードを **フォーム形式**で POST する。
2. サーバーは資格情報を検証し、成功なら **JWT（アクセストークン）** を返す。
3. クライアントは以後、保護されたエンドポイントへ **`Authorization: Bearer <token>`** ヘッダを付けて送る。
4. サーバーは依存（`Depends`/`Security`）でトークンを検証し、**現在のユーザー**を復元してハンドラに注入する。
5. トークンは一定時間で**失効**する（短命に保つのが鉄則）。

この配線の起点が `OAuth2PasswordBearer` です。

```python
from fastapi.security import OAuth2PasswordBearer

# tokenUrl は「トークンを取りに行く先」を OpenAPI に伝えるだけ。
# このエンドポイント自体を作るわけではない（作るのは第4章）。
# 相対URLにするのが重要：プロキシ配下でもパスが壊れない。
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
```

`OAuth2PasswordBearer` を依存に使うと、FastAPI は次の3つを**自動で**やります。

- リクエストの `Authorization` ヘッダを見て、`Bearer <token>` なら **`token: str` を取り出す**。
- ヘッダが無い／`Bearer` 形式でないなら、**自動的に `401 Unauthorized` を返す**（自分で書かなくてよい）。
- 対話ドキュメント `/docs` に **「Authorize」ボタンと鍵アイコン**を生やし、ブラウザから試せるようにする。

```python
from typing import Annotated
from fastapi import Depends, FastAPI

app = FastAPI()

@app.get("/items/")
async def read_items(token: Annotated[str, Depends(oauth2_scheme)]):
    # ここに来た時点で token は「Bearerヘッダから取れた文字列」。
    # ただし、まだ"中身が正しいか"は検証していない（第5章で検証する）。
    return {"token": token}
```

> **要点**：`OAuth2PasswordBearer` は「ヘッダからトークン文字列を取り出す」までが仕事です。**そのトークンが本物かの検証は、まだ別**。この分担を理解すると、以降の設計がすっと入ります。

---

## 2. パスワードを正しく保存する：pwdlib + Argon2

認証の土台は「パスワードをどう保管するか」です。ここを外すと、他をいくら固めても無意味になります。

### 2.1 大原則：ハッシュであって暗号化ではない

**パスワードは「ハッシュ化」して保存します。「暗号化」ではありません。** 違いは決定的です。

- **暗号化**は鍵があれば**元に戻せる**（復号できる）。鍵が漏れれば平文に戻る。
- **ハッシュ**は**一方向**で元に戻せない。ログイン時は「入力を同じ手順でハッシュ化し、保存済みハッシュと一致するか」を照合する。

公式の言葉どおり——**DB が盗まれても、盗人が得るのはハッシュだけ。平文パスワードは手に入らず、他サービスへの使い回し攻撃も防げます。** これが「平文保存・可逆暗号で保存」を絶対にやってはいけない理由です。

### 2.2 pwdlib（Argon2）でハッシュする

公式が現在推奨するのは **`pwdlib`** です（旧来の `passlib` ではありません）。アルゴリズムは **Argon2**（パスワードハッシュ専用に設計された現代的な関数）。

```bash
pip install "pwdlib[argon2]"
```

```python
from pwdlib import PasswordHash

# recommended() は安全な既定（Argon2）を選んでくれる。
# pwdlib は bcrypt も使えるが、レガシー（古い）ハッシュ互換が要るときだけ passlib を使う。
password_hash = PasswordHash.recommended()

def get_password_hash(password: str) -> str:
    # 登録時：平文を受け取って即ハッシュ化。平文はここから先へ持ち出さない。
    return password_hash.hash(password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
    # ログイン時：入力とDBのハッシュを照合（定数時間比較で行われる）。
    return password_hash.verify(plain_password, hashed_password)
```

> **なぜ Argon2 / bcrypt なのか（SHA-256 ではダメな理由）**：`sha256(password)` のような汎用ハッシュは**速すぎて**、GPU で総当たりされます。Argon2 や bcrypt は**意図的に遅く・メモリを食う**よう設計され、総当たりのコストを跳ね上げます。さらにこれらは**ソルト**を内蔵し、同じパスワードでも毎回違うハッシュになるため、レインボーテーブル攻撃も無効化されます。「パスワードを `hashlib` でハッシュ」は本番では誤りです。

### 2.3 ユーザーモデル：平文は型に存在させない

入力・公開・DB保管でモデルを分けます。**`hashed_password` を公開モデルに含めない**——これは型による漏洩防止です。

```python
from pydantic import BaseModel

class User(BaseModel):
    username: str
    email: str | None = None
    full_name: str | None = None
    disabled: bool | None = None

class UserInDB(User):
    # DB から読むときだけ存在するフィールド。API レスポンス（User）には現れない。
    hashed_password: str
```

`get_user(...)` は DB から `UserInDB` を返す関数とします。**本番では DB（SQLAlchemy/SQLModel 等）から取得**し、ハッシュは DB に保管します（コード内のダミー辞書はあくまで学習用です。実装は[SQLAlchemy 2 の型付きORM](/blog/sqlalchemy-2-typed-orm-production-guide)を参照）。

---

## 3. JWT を発行する：PyJWT

トークンの実体に **JWT（JSON Web Token）** を使います。JWT は「サーバーが署名した、改ざん検知可能な JSON」。**サーバーが鍵を持つので、トークンを DB に問い合わせずに正当性を検証できます**（ステートレス）。

公式が現在使うライブラリは **`PyJWT`** です（旧 `python-jose` ではありません）。

```bash
pip install pyjwt
# RSA/ECDSA（RS256など非対称鍵）を使うなら cryptography も:  pip install "pyjwt[crypto]"
```

### 3.1 鍵と定数（シークレットは環境変数へ）

```bash
# 署名鍵は十分なエントロピーで生成する。コードに書かない。
openssl rand -hex 32
# 例: 09d25e094faa6ca2556c818166b7a9563b93f7099f6f0f4caa6cf63b88e8d3e7
```

公式チュートリアルは `SECRET_KEY` をコードに直書きしますが、**それはデモ用**です。本番は環境変数から読みます。設定は `pydantic-settings` に集約するのが定石です（型安全・単一の真実源）。

```python
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    secret_key: str                       # 環境変数 SECRET_KEY（openssl rand -hex 32）
    access_token_expire_minutes: int = 30
    model_config = SettingsConfigDict(env_file=".env")

settings = Settings()      # 環境変数/.env から読み込む。鍵はここにしか存在しない。
ALGORITHM = "HS256"        # 対称鍵署名。単一アプリならこれで十分（後述：マルチサービスは RS256）
```

### 3.2 アクセストークンを作る

```python
from datetime import datetime, timedelta, timezone
import jwt    # PyJWT

def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
    to_encode = data.copy()
    # exp は「timezone-aware な UTC」で入れる。naive datetime は事故の元。
    expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
    to_encode.update({"exp": expire})
    # 署名鍵で署名する。これにより改ざんは検知可能になる（中身は"暗号化"ではなく"署名"）。
    return jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM)
```

> **JWT は暗号化ではない**：JWT のペイロード（claims）は **Base64URL でデコードすれば誰でも読めます**。署名は「改ざんされていないこと」を保証しますが、「中身を秘密にする」ものではありません。**パスワード・個人情報・機密をペイロードに入れてはいけません**。入れてよいのは「ユーザー識別子・権限・有効期限」程度です。

### 3.3 `sub`（主体）は「アプリ全体で一意な文字列」にする

トークンが「誰のものか」を示すのが **`sub`（subject）claim** です。公式の指針は明確です。

- **`sub` はアプリ全体で一意な識別子**であること。
- **必ず文字列**であること。

ユーザーID とグループID が衝突し得るような設計なら、**接頭辞で名前空間を分ける**——例：`"username:johndoe"`。「`sub` にメールアドレス」は変更され得るので避け、不変のIDが安全です。

```python
# 例：ユーザーの一意IDを文字列化して sub に入れる
access_token = create_access_token(data={"sub": f"username:{user.username}"})
```

### 3.4 トークン応答モデル

`/token` が返す形は OAuth2 仕様で決まっています。**`access_token` と `token_type`（"bearer"）**を含む JSON です。

```python
class Token(BaseModel):
    access_token: str
    token_type: str

class TokenData(BaseModel):
    # トークンから復元する「検証済みの中身」。usernameは None 許容にしておく。
    username: str | None = None
```

> **【最重要・セキュリティ】検証時は必ず `algorithms=[...]` を明示する**：`jwt.decode(token, key, algorithms=["HS256"])` のように、**受け入れるアルゴリズムを明示的に固定**してください。これを怠ると、攻撃者がトークンの `alg` ヘッダを `none`（署名なし）に書き換えたり、公開鍵を秘密鍵として使う**アルゴリズム混同攻撃**が成立し得ます。**トークンの中の `alg` を信用してはいけません**——受理するアルゴリズムは**サーバーが決める**ものです。次章で実際に使います。

---

## 4. `/token` エンドポイント：OAuth2PasswordRequestForm

いよいよ「ログインしてトークンを発行する」入口を作ります。OAuth2 パスワードフローでは、資格情報は **JSON ではなくフォーム（`application/x-www-form-urlencoded`）**で送られます。FastAPI はこれを **`OAuth2PasswordRequestForm`** で受けます。

```python
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm

app = FastAPI()
```

`OAuth2PasswordRequestForm` が提供するフィールド：

- **`username`**（必須）
- **`password`**（必須）
- **`scope`**（任意）：スペース区切りの文字列。インスタンスでは **`scopes`（リスト）**として参照できる（第6章で使用）。
- `grant_type` / `client_id` / `client_secret`（任意）

### 4.1 タイミング攻撃を防ぐ認証関数

ここに**公式が最近追加した重要な配慮**があります。**「ユーザーが存在しない」場合でも、ダミーのハッシュに対して検証を走らせる**——こうして「ユーザー名が実在するか否か」で応答時間が変わらないようにし、**ユーザー名の列挙（enumeration）を防ぎます**。

```python
DUMMY_HASH = password_hash.hash("a-dummy-password-for-constant-time")

def authenticate_user(username: str, password: str) -> UserInDB | None:
    user = get_user(username)          # DB から UserInDB | None
    if user is None:
        # ユーザーが居なくても必ず検証を走らせ、応答時間を一定に保つ（タイミング攻撃対策）
        password_hash.verify(password, DUMMY_HASH)
        return None
    if not verify_password(password, user.hashed_password):
        return None
    return user
```

### 4.2 ログイン・パスオペレーション

```python
@app.post("/token")
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        # 「ユーザー名が違う」「パスワードが違う」を区別して返さない（情報を与えない）
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="ユーザー名またはパスワードが正しくありません",
            headers={"WWW-Authenticate": "Bearer"},
        )
    access_token = create_access_token(
        data={"sub": f"username:{user.username}"},
        expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
    )
    return Token(access_token=access_token, token_type="bearer")
```

> **エラーメッセージで情報を漏らさない**：「そのユーザーは存在しません」と返すと、攻撃者に有効なユーザー名を教えてしまいます。**ユーザー名違いもパスワード違いも、同じ 401・同じ文言**で返すのが鉄則です。4.1 のダミーハッシュと合わせ、「存在判定」を漏らさない二重の守りになります。

これで `/token` に正しい資格情報を送ると、署名済み JWT が返ります。`/docs` の「Authorize」からも試せます。

---

## 5. トークンを検証して「現在のユーザー」を得る

ここが認証の心臓です。**受け取った Bearer トークンを検証し、`User` に復元してハンドラへ注入する**依存を作ります。第1章の `oauth2_scheme` を**サブ依存**として使います。

```python
from jwt.exceptions import InvalidTokenError

async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],   # ヘッダから取れたトークン文字列
) -> User:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="資格情報を検証できませんでした",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        # algorithms を明示（第3.4章の必須対策）。署名・exp が自動検証される。
        payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
        subject = payload.get("sub")
        if subject is None:
            raise credentials_exception
        username = subject.removeprefix("username:")   # sub の名前空間を外す
        token_data = TokenData(username=username)
    except InvalidTokenError:
        # 署名不正・期限切れ・改ざん・alg不一致などはすべてここに落ちる
        raise credentials_exception
    user = get_user(username=token_data.username)
    if user is None:
        raise credentials_exception
    return user
```

`jwt.decode` は **`exp`（有効期限）を自動で検証**し、期限切れなら `InvalidTokenError`（の派生 `ExpiredSignatureError`）を投げます。署名不一致・改ざん・`alg` 不一致も同様にここで弾かれます。

### 5.1 もう一段：無効化ユーザーを締め出す

「トークンは有効だが、アカウントが停止されている」ケースを締め出す依存を重ねます。**依存はいくらでも合成できる**のが FastAPI の強みです。

```python
async def get_current_active_user(
    current_user: Annotated[User, Depends(get_current_user)],
) -> User:
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="無効化されたユーザーです")
    return current_user

# 型エイリアスに畳んで再利用（公式推奨の Annotated パターン）
CurrentUser = Annotated[User, Depends(get_current_active_user)]

@app.get("/users/me", response_model=User)
async def read_users_me(current_user: CurrentUser) -> User:
    # 認証ロジックはハンドラに一切ない。current_user は"検証済み"で渡ってくる。
    return current_user
```

> **DI の見返り**：認証を「一度書いた依存」に閉じ込めたので、**数千のエンドポイントが同じ仕組みを再利用**できます。各ハンドラは `current_user: CurrentUser` と書くだけ。横断的関心事（認証）がビジネスロジックから完全に分離されました（SRP / DRY）。木材流通SaaSでは、この型で**全221エンドポイント**を一貫した認証・認可で保護しました。

---

## 6. 認可：Security scopes できめ細かい権限を表す

ここから**認可（AuthZ）**です。「ログインしている」と「この操作をしてよい」は別物——その差を埋めるのが **OAuth2 スコープ**。スコープは「`me`（自分の情報を読む）」「`items`（アイテムを読む）」のような**権限を表す文字列**です。

### 6.1 スコープを宣言する

```python
oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="token",
    # scopes は {スコープ名: 説明} の dict。/docs にチェックボックスとして表示される。
    scopes={"me": "自分自身の情報を読む", "items": "アイテムを読む"},
)
```

### 6.2 `Security` と `SecurityScopes`：ここが肝

公式の最重要ポイント：**スコープを要求するときは `Depends` ではなく `Security` を使う**。`Security` を使うことで、FastAPI は「このエンドポイントには、これこれのスコープが要る」と理解し、OpenAPI（/docs）にも反映します。

そして、依存側で「**今どのスコープが要求されているか**」を受け取るのが **`SecurityScopes`** です。

```python
from fastapi import Security
from fastapi.security import SecurityScopes
from pydantic import ValidationError

class TokenData(BaseModel):
    username: str | None = None
    scopes: list[str] = []        # トークンが持つスコープ

async def get_current_user(
    security_scopes: SecurityScopes,                 # ← 要求されたスコープ群を受け取る
    token: Annotated[str, Depends(oauth2_scheme)],
) -> User:
    # 失敗時の WWW-Authenticate ヘッダに、必要なスコープを載せる（OAuth2準拠）
    if security_scopes.scopes:
        authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
    else:
        authenticate_value = "Bearer"
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="資格情報を検証できませんでした",
        headers={"WWW-Authenticate": authenticate_value},
    )
    try:
        payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
        subject = payload.get("sub")
        if subject is None:
            raise credentials_exception
        username = subject.removeprefix("username:")
        token_scopes = payload.get("scope", "").split()    # "scope" claim は空白区切り
        token_data = TokenData(scopes=token_scopes, username=username)
    except (InvalidTokenError, ValidationError):
        raise credentials_exception
    user = get_user(username=token_data.username)
    if user is None:
        raise credentials_exception
    # 要求された全スコープを、このトークンが持っているか検証する
    for scope in security_scopes.scopes:
        if scope not in token_data.scopes:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="権限が不足しています",
                headers={"WWW-Authenticate": authenticate_value},
            )
    return user
```

### 6.3 スコープをトークンに焼き込む（/token 側）

```python
@app.post("/token")
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
) -> Token:
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        raise HTTPException(status_code=400, detail="ユーザー名またはパスワードが正しくありません")
    # ⚠️ 本番の肝：要求スコープを鵜呑みにせず「ユーザーが実際に許可されたスコープ」と突き合わせる
    granted = sorted(set(form_data.scopes) & set(user_allowed_scopes(user)))
    access_token = create_access_token(
        data={"sub": f"username:{user.username}", "scope": " ".join(granted)},
        expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
    )
    return Token(access_token=access_token, token_type="bearer")
```

> **公式チュートリアルからの本番補強**：公式の例は `form_data.scopes` を**そのまま**トークンに入れます（デモのため）。**本番では絶対にそうしないでください**。クライアントが要求したスコープと、**そのユーザーが本当に持つ権限の積集合**だけを焼き込みます（`user_allowed_scopes(user)` は DB のロール等から導出）。さもないと、一般ユーザーが `admin` スコープを自己申告してトークンに載せられてしまいます。**権限の源泉はサーバー（DB）にあり、クライアントの申告ではありません。**

### 6.4 エンドポイントでスコープを要求する

```python
# 「自分の情報」: me スコープが要る
@app.get("/users/me", response_model=User)
async def read_users_me(
    current_user: Annotated[User, Security(get_current_active_user, scopes=["me"])],
) -> User:
    return current_user

# 「自分のアイテム」: items スコープが要る（依存ツリーで me も累積される）
@app.get("/users/me/items/")
async def read_own_items(
    current_user: Annotated[User, Security(get_current_active_user, scopes=["items"])],
):
    return [{"item_id": "Foo", "owner": current_user.username}]
```

スコープは**依存ツリーに沿って累積**します。`read_own_items` を呼ぶと、`security_scopes.scopes` には `["items", "me"]`（パスオペレーションの `items` と、`get_current_active_user` が要求する `me`）の両方が入り、**両方を満たさないと通りません**。

### 6.5 応用：ルーター層で認可を一元化する（DRY）

エンドポイントごとに `Security(...)` を書くのは、一覧性・抜け漏れの面で危険です。**APIRouter の `dependencies` に置けば、その配下の全ルートに認可が効きます**。

```python
from fastapi import APIRouter

# /admin 配下の全エンドポイントに admin スコープを強制する（書き忘れが構造的に起きない）
admin_router = APIRouter(
    prefix="/admin",
    dependencies=[Security(get_current_active_user, scopes=["admin"])],
)

@admin_router.get("/reports")        # ← ここに認可を書かなくても admin が要求される
async def list_reports():
    ...

app.include_router(admin_router)
```

木材流通SaaSでは、**業種ベースの認可をルーター層に一元化**しました。業種コードを機能ごとの `frozenset` ホワイトリストで定義し、不一致は **403**（IDの列挙攻撃を抑止）。さらに企業横断の検索では **PII を除外した公開スキーマ（`UserPublic`）に限定**し、相手企業のメール・電話・法人番号が漏れない**二層スキーマ境界**を敷きました。認可は「各ハンドラの善意の if 文」ではなく、**境界に一元化された構造**で守るのが本番品質です（[マルチテナントのデータ分離・認可設計はこちら](/blog/multi-tenant-saas-data-isolation-authorization-design-guide)）。

---

## 7. 本番ハードニング：公式チュートリアルの「その先」

ここまでで「動く認証」は完成です。ここからが**落ちない・漏れない認証**にするための差分です。

### 7.1 アクセストークンは短命に、長生きはリフレッシュトークンで

**JWT はステートレスゆえに「即時失効」ができません**。一度発行したトークンは、`exp` が来るまでサーバーから取り消せない——これが最大の弱点です。だから本番は2種類のトークンに分けます。

| | アクセストークン | リフレッシュトークン |
| --- | --- | --- |
| 形式 | JWT（ステートレス） | 不透明文字列（サーバー保管） |
| 寿命 | **短い**（5〜15分） | 長い（数日〜数週間） |
| 失効 | できない（短命で許容） | **できる**（DBから削除） |
| 用途 | API 呼び出しの都度 | アクセストークンの再発行のみ |

放送事業者向けプラットフォームでも、各ツールへ渡すのは **アクセストークン10分・IDトークン1時間**の短命トークンとし、デバイス単位のセッションで失効を制御しました。流出しても**寿命が短いほど被害が小さい**——これがトークン設計の基本思想です。

```python
from fastapi import Form

@app.post("/token/refresh")
async def refresh(refresh_token: Annotated[str, Form()]) -> Token:
    # リフレッシュトークンはDBで検証する（=即時失効できる）。
    session = lookup_refresh_token(refresh_token)         # 無効/失効済みなら None
    if session is None:
        raise HTTPException(status_code=401, detail="再ログインが必要です")
    rotate_refresh_token(session)        # 使ったら作り直す（盗用検知＝ローテーション）
    new_access = create_access_token(data={"sub": f"username:{session.username}"})
    return Token(access_token=new_access, token_type="bearer")
```

ログアウトや「全端末からサインアウト」は、**リフレッシュトークンを DB から消す**ことで実現します。アクセストークンは短命なので、最大でも数分で自然に無効化されます（より厳密に即時失効したいなら、トークンの `jti` を**失効リスト（denylist）**に短時間だけ載せます）。

### 7.2 マルチサービスなら RS256（非対称鍵）

単一アプリなら HS256（対称鍵）で十分ですが、**複数サービスがトークンを検証する**構成では、**RS256（秘密鍵で署名・公開鍵で検証）**が定石です。検証側に**秘密鍵を配らずに済む**のが利点で、検証側は JWKS（公開鍵）だけ持てばよい。

```python
# 発行（鍵を持つ認証サーバーだけ）
jwt.encode(payload, private_key, algorithm="RS256")
# 検証（各サービス。公開鍵だけでよい）。aud/iss も必ず検証する。
jwt.decode(token, public_key, algorithms=["RS256"], audience="my-api", issuer="https://issuer.example.com")
```

木材流通SaaSの Cognito 連携では、まさにこの **RS256 + JWKS** で全エンドポイントを検証し、`exp/iat/iss/aud/token_use` を必須化、`token_use==id` 以外は拒否しました（[RS256 + JWKS 検証の実装はこちら](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide)、[ID/アクセストークンの違いはこちら](/blog/id-token-vs-access-token-oidc-oauth2-guide)）。

> **`aud`（audience）と `iss`（issuer）を検証する**：トークンが「**このAPIのために・信頼できる発行者から**」出たものかを確認します。これを省くと、別アプリ向けに発行されたトークンが流用され得ます。`jwt.decode(..., audience=..., issuer=...)` で必須化してください。時計ずれが心配なら `leeway` で許容秒数を与えます。

### 7.3 CORS：ブラウザから叩くなら正しく設定する

SPA（別オリジンのフロント）から API を叩くなら CORS が要ります。**最も多い事故が `allow_credentials=True` と `allow_origins=["*"]` の併用**です。

```python
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com"],   # ← Cookie併用時は "*" 不可。明示する
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
```

> **公式の警告**：`allow_credentials=True`（Cookie を伴う）のとき、`allow_origins` / `allow_methods` / `allow_headers` を **`["*"]` にはできません**。すべて明示的に列挙する必要があります。これは CORS 仕様の要請です。Bearer ヘッダ方式なら Cookie は使わないので緩められますが、**オリジンの絞り込みは常に推奨**です。

### 7.4 トークンを置く場所：Authorization ヘッダ vs httpOnly Cookie

ブラウザ向けでは「トークンをどこに保存するか」が設計判断になります。

- **`Authorization: Bearer`（メモリ保持）**：CSRF に強い。ただし `localStorage` に置くと **XSS で盗まれる**。トークンは**メモリ（変数）**に持ち、リロードはリフレッシュで復帰させるのが安全。
- **httpOnly Cookie**：JS から読めないため XSS で盗まれにくい。ただし**CSRF 対策（SameSite=Lax/Strict、CSRF トークン）が別途必須**。

「無人キオスク端末は httpOnly Cookie + CSRF トークン、運営コンソールは別方式」のように、**用途ごとにセキュリティ境界を分ける**設計も有効です（実際にリアルタイム飲食店マッチングの案件でそう分離しました）。

### 7.5 `/token` をブルートフォースから守る

ログインエンドポイントは**総当たり攻撃の標的**です。最低限、次を入れます。

- **レート制限**：IP・ユーザー名単位で試行回数を制限（`slowapi` などのミドルウェア、または前段の WAF／[Cloud Armor 等の多層防御](/blog/waf-defense-in-depth-aws-waf-cloud-armor-owasp-guide)）。
- **アカウントロックアウト／指数バックオフ**：連続失敗で一時ロック。
- **強固なパスワードポリシー**：登録時に Pydantic の `field_validator` で長さ・複雑さを検証。

### 7.6 HTTPS は必須（再掲）

OAuth2 は通信を暗号化しません。**平文 HTTP では、`/token` のパスワードも `Authorization` ヘッダのトークンも盗聴され得ます**。本番は HTTPS（TLS）必須。あわせて HSTS ヘッダで HTTPS を強制します。

---

## 8. テスト：認証を `dependency_overrides` で差し替える

検証パスのない本番投入はありません。FastAPI は **依存を丸ごと差し替えられる**ので、認証付き API も決定的にテストできます。

```python
from fastapi.testclient import TestClient
from app.main import app
from app.security import get_current_active_user

# 1) ハンドラ単体：認証を偽装して、ビジネスロジックに集中する
def fake_user() -> User:
    return User(username="tester", disabled=False)

app.dependency_overrides[get_current_active_user] = fake_user
client = TestClient(app)

def test_read_me_returns_injected_user():
    res = client.get("/users/me")
    assert res.status_code == 200
    assert res.json()["username"] == "tester"

app.dependency_overrides.clear()    # テスト間の汚染を防ぐため必ずクリア

# 2) 認証フロー自体：トークン無し → 401、正しいログイン → トークン取得 → 保護APIが通る
def test_auth_flow_end_to_end():
    real_client = TestClient(app)
    assert real_client.get("/users/me").status_code == 401          # トークン無しは弾かれる
    token = real_client.post(
        "/token", data={"username": "johndoe", "password": "secret", "scope": "me"}
    ).json()["access_token"]
    res = real_client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
    assert res.status_code == 200
```

**2種類を必ず両方**書きます——(1) `dependency_overrides` で認証を外して**ハンドラのロジック**を速くテストし、(2) オーバーライドせずに**トークン発行〜検証の本物の経路**を end-to-end でテストする。前者だけだと、認証配線そのもののバグ（`algorithms` 抜け等）を見逃します。

---

## 9. まとめ：本番 FastAPI 認証・認可チートシート

迷ったときの早見表です。

- **地図**：認証（誰か）と認可（何を許すか）は別物。OAuth2 パスワードフロー＝「`/token` で発行 → 以後 `Authorization: Bearer` で検証」。`fastapi.security` が配線を型で支える。
- **パスワード**：**pwdlib + Argon2**（`PasswordHash.recommended()`）でハッシュ。暗号化でもSHA-256でもない。平文保存は厳禁。レガシー互換だけ passlib。
- **JWT**：**PyJWT**（`import jwt`）。`jwt.decode` で **`algorithms=[...]` 明示は必須**（`alg:none`・アルゴリズム混同攻撃の防止）。`sub` は一意な文字列、`exp` は timezone-aware、`SECRET_KEY` は環境変数。ペイロードに機密を入れない。
- **/token**：`OAuth2PasswordRequestForm` でフォーム受け。**ユーザー不在でもダミーハッシュ検証**（タイミング攻撃対策）。ユーザー名違いもパスワード違いも同じ 401。
- **検証**：`get_current_user` → `get_current_active_user` を依存で合成。`Annotated[User, Depends(...)]` を型エイリアスに畳んで全エンドポイントで再利用。
- **認可**：**`Security`（≠`Depends`）+ `SecurityScopes`** でスコープを宣言・検証。**要求スコープは鵜呑みにせず、ユーザーの許可スコープとの積集合**だけ焼き込む。認可はルーター層に一元化。
- **本番**：短命アクセストークン＋リフレッシュ（JWTは失効できない）。マルチサービスは RS256＋`aud`/`iss` 検証。CORS は `allow_credentials` × `["*"]` 非互換。`/token` はレート制限。**HTTPS 必須**。
- **テスト**：`dependency_overrides` でハンドラを、オーバーライド無しで認証フローを、**両方**テストする。

---

FastAPI は「5分でログインを動かせる」フレームワークですが、本番品質は**境界の設計**で決まります。**パスワードを一方向に潰し、トークンを短命に保ち、検証アルゴリズムをサーバーが固定し、権限の源泉を常にサーバーに置き、認可を一箇所に集める**——どれも派手ではありませんが、この積み重ねが「漏れない・追える・変更しやすい認証」を作ります。

私は放送事業者向けの社内AIプラットフォームで、**FastAPI を中核に per-tool 短命JWT の認証ハブ**を設計し、木材流通SaaSでは **全221エンドポイントを認可で保護**して第三者ペネトレで認証欠落0件を実証しました。生成AI（Claude Code）を相棒に、**一人で速く・安く**作りつつ、4ラウンドのセキュリティ監査のような検証ゲートで品質を担保するのが私の進め方です。

**「FastAPI でこの API に認証・認可を載せたいが、自前実装かマネージドIdPか、トークン設計や権限境界をどう引くべきか」——その判断から実装・監査・運用まで、一気通貫で伴走します。** 要件整理の段階からでも、お気軽にご相談ください。

---

### 参考（公式ドキュメント）

- [Security（FastAPI）](https://fastapi.tiangolo.com/tutorial/security/) — OAuth2・OpenID Connect・パスワードフローの概念と `fastapi.security` の道具
- [Security - First Steps（FastAPI）](https://fastapi.tiangolo.com/tutorial/security/first-steps/) — `OAuth2PasswordBearer`・`Authorization: Bearer`・/docs 連携
- [Get Current User（FastAPI）](https://fastapi.tiangolo.com/tutorial/security/get-current-user/) — 依存で現在のユーザーを復元する
- [Simple OAuth2（FastAPI）](https://fastapi.tiangolo.com/tutorial/security/simple-oauth2/) — `OAuth2PasswordRequestForm` とトークン応答の形
- [OAuth2 with Password (and hashing), Bearer with JWT tokens（FastAPI）](https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/) — pwdlib(Argon2)・PyJWT・タイミング攻撃対策・`sub` の指針
- [OAuth2 scopes（FastAPI）](https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/) — `Security`・`SecurityScopes`・スコープの累積
- [CORS（FastAPI）](https://fastapi.tiangolo.com/tutorial/cors/) — `CORSMiddleware` と `allow_credentials` の制約
- [PyJWT ドキュメント](https://pyjwt.readthedocs.io/) — `encode`/`decode`・`algorithms`・`aud`/`iss` 検証
- [pwdlib ドキュメント](https://frankie567.github.io/pwdlib/) — Argon2/bcrypt のパスワードハッシュ
