メインコンテンツへスキップ
友田 陽大
Pythonバックエンド
Python
FastAPI
セキュリティ
認証・認可
JWT
OAuth2

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

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

公開日
読了時間
26分
著者
友田 陽大
シェア
目次

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

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

この記事のルール:API・推奨ライブラリは FastAPI 公式ドキュメント(2026年6月時点) に基づきます。公式は近年、パスワードハッシュを passlibpwdlib(Argon2)、JWT を python-josePyJWT に更新しました。本記事はこの最新版に準拠します。仕様は改定されるため、本番投入前に必ず公式で最新の挙動を確認してください。シークレット(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を検討してください(認証基盤の選定はこちらで比較)。本記事は「自前で正しく実装する」道を扱いますが、マネージドIdPを使う場合でも、後半の JWT 検証の勘所・認可(スコープ)・ルーター層での一元化はそのまま役立ちます(Cognito の RS256 + JWKS 検証はこちら)。


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

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

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

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

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」ボタンと鍵アイコンを生やし、ブラウザから試せるようにする。
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(パスワードハッシュ専用に設計された現代的な関数)。

pip install "pwdlib[argon2]"
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 を公開モデルに含めない——これは型による漏洩防止です。

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を参照)。


3. JWT を発行する:PyJWT

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

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

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

3.1 鍵と定数(シークレットは環境変数へ)

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

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

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 アクセストークンを作る

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が安全です。

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

3.4 トークン応答モデル

/token が返す形は OAuth2 仕様で決まっています。**access_tokentoken_type("bearer")**を含む JSON です。

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 で受けます。

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)を防ぎます

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 ログイン・パスオペレーション

@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サブ依存として使います。

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.decodeexp(有効期限)を自動で検証し、期限切れなら InvalidTokenError(の派生 ExpiredSignatureError)を投げます。署名不一致・改ざん・alg 不一致も同様にここで弾かれます。

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

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

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 スコープを宣言する

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

6.2 SecuritySecurityScopes:ここが肝

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

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

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 側)

@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 エンドポイントでスコープを要求する

# 「自分の情報」: 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 に置けば、その配下の全ルートに認可が効きます

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 文」ではなく、境界に一元化された構造で守るのが本番品質です(マルチテナントのデータ分離・認可設計はこちら)。


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

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

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

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

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

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

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(公開鍵)だけ持てばよい。

# 発行(鍵を持つ認証サーバーだけ)
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 検証の実装はこちらID/アクセストークンの違いはこちら)。

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

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

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

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 は使わないので緩められますが、オリジンの絞り込みは常に推奨です。

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

  • 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 等の多層防御)。
  • アカウントロックアウト/指数バックオフ:連続失敗で一時ロック。
  • 強固なパスワードポリシー:登録時に Pydantic の field_validator で長さ・複雑さを検証。

7.6 HTTPS は必須(再掲)

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


8. テスト:認証を dependency_overrides で差し替える

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

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 + Argon2PasswordHash.recommended())でハッシュ。暗号化でもSHA-256でもない。平文保存は厳禁。レガシー互換だけ passlib。
  • JWTPyJWTimport jwt)。jwt.decodealgorithms=[...] 明示は必須alg:none・アルゴリズム混同攻撃の防止)。sub は一意な文字列、exp は timezone-aware、SECRET_KEY は環境変数。ペイロードに機密を入れない。
  • /tokenOAuth2PasswordRequestForm でフォーム受け。ユーザー不在でもダミーハッシュ検証(タイミング攻撃対策)。ユーザー名違いもパスワード違いも同じ 401。
  • 検証get_current_userget_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か、トークン設計や権限境界をどう引くべきか」——その判断から実装・監査・運用まで、一気通貫で伴走します。 要件整理の段階からでも、お気軽にご相談ください。


参考(公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

国内大手放送事業者向け社内AIプラットフォーム(FastAPI製パイプライン+per-tool短命JWTの認証ハブを構築)

ケーススタディを見る