Chuyển đến nội dung chính
友田 陽大
Python backend
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・テストまで本番ハードニングを実コードで解説します。

Published
Reading time
26 min read
Author
友田 陽大
Chia sẻ
Mục lục

「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か、トークン設計や権限境界をどう引くべきか」——その判断から実装・監査・運用まで、一気通貫で伴走します。 要件整理の段階からでも、お気軽にご相談ください。


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

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc