サイトアイコン IT & ライフハックブログ|学びと実践のためのアイデア集

FastAPIの認証と認可設計ガイド:JWT、OAuth2スコープ、Cookie、CSRFの実装ポイント

green snake

Photo by Pixabay on Pexels.com

FastAPIには認証と認可を組み立てる部品がありますが、どの方式を選び、どこで失効や権限を判定するかはアプリケーション側で決めます。

実装を始める前に、利用者、クライアント、保護するデータ、ログアウト時に必要な失効速度を整理すると、JWTとCookieを誤った二者択一として扱わずに済みます。

この記事では、用語の違いからFastAPIでの実装、CSRF対策、運用テストまでを順に説明します。コードは設計の要点を示す例なので、データベース処理、例外処理、鍵管理を補ったうえで本番環境に適用してください。

認証、認可、セッションを分けて考える

最初に、似ている用語の役割を切り分けます。

用語 役割 具体例
認証 利用者やクライアントが誰であるかを確かめる パスワード、外部のIDプロバイダー、クライアント証明書
認可 認証済みの主体に許す操作を決める 閲覧者は記事を読めるが、編集者だけが更新できる
セッション 認証済みの状態を複数のリクエストで継続する サーバー側セッションとCookie、アクセストークン
JWT 署名付きのクレームを表現するトークン形式 sub、exp、scopeを含むアクセストークン
OAuth 2.0 クライアントへ限定したアクセス権を渡すための枠組み アクセストークンとスコープを使うAPIアクセス

JWTの構造、署名、保存場所の基礎を先に確認すると、この後の設計判断を追いやすくなります。

JWTは通常、内容を暗号化する仕組みではありません。

署名が正しければ改ざんを検出できますが、ペイロードは読めるため、パスワードや機密情報を入れない設計が必要です。

一方、Bearerは「その値を持つ者が利用できる」というトークンの使い方を表します。

JWTをBearerトークンとして使うことはできますが、両者は同じ意味ではありません。

クライアントに合う状態管理を選ぶ

JWTかCookieかは、画面がSPAかどうかだけでは決まりません。

Cookieはトークン形式ではなくブラウザの保存と送信の仕組みなので、Cookieの中には不透明なセッションIDもJWTも入れられます。

構成 候補 設計上の注意
同一オリジン中心のWeb サーバー側セッションとHttpOnly Cookie CSRF対策、セッションIDの更新、サーバー側の失効を組み込む
別オリジンのフロントエンドから呼ぶAPI 短い有効期限のアクセストークン CORS、トークンの保管場所、更新方法、XSS時の影響を一緒に評価する
モバイルアプリや外部クライアント OAuth 2.0に沿ったアクセストークン クライアントの性質に合うフローと安全な保存領域を選ぶ
即時ログアウトや端末別管理が必要 サーバー側セッション、または失効状態を参照するトークン設計 署名検証だけでは即時失効しない点を運用要件に反映する

どの方式でもHTTPSを前提とし、認証情報をURL、アクセスログ、エラーメッセージへ出さないことが共通の出発点です。

パスワードは専用のハッシュ関数で保存する

パスワードハッシュは、入力されたパスワードを元に戻せない値へ変換し、照合に使う仕組みです。

高速な汎用ハッシュではなく、パスワード保存用に計算負荷を調整できる方式を使います。

FastAPIの現行チュートリアルでは、pwdlibの推奨設定を使う例が示されています。

from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("not-a-real-password")

def hash_password(plain_password: str) -> str:
    return password_hash.hash(plain_password)

def verify_password(plain_password: str, stored_hash: str) -> bool:
    return password_hash.verify(plain_password, stored_hash)

存在しない利用者名でログインされた場合もダミーのハッシュを検証すると、利用者の有無による応答時間の差を小さくできます。

失敗時のメッセージも「メールアドレスまたはパスワードが正しくありません」のように揃え、アカウントの存在を推測しにくくします。

JWTでは署名だけでなくクレームを検証する

JWTの検証は、署名が一致したかどうかだけでは終わりません。

受け入れるアルゴリズムをコード側で固定し、発行者、対象API、有効期限、トークン種別を確認します。

クレーム 確認する内容
sub 利用者やクライアントを一意に示す識別子
exp トークンを受け付けなくなる時刻
iat トークンを発行した時刻
iss 想定した発行者かどうか
aud このAPI向けに発行されたかどうか
jti 失効や再利用検知に使えるトークン固有の識別子
type アクセストークンとリフレッシュトークンを取り違えていないか
scope 許可された操作の集合

次の例は、検証条件を関数の引数へ明示し、アクセストークンとリフレッシュトークンを区別します。

from datetime import datetime, timedelta, timezone
from uuid import uuid4

import jwt
from jwt.exceptions import InvalidTokenError

ALGORITHM = "HS256"
ISSUER = "https://auth.example.com"
AUDIENCE = "example-api"

def create_token(*, subject: str, token_type: str, scope: str, lifetime: timedelta, secret: str) -> str:
    now = datetime.now(timezone.utc)
    claims = {
        "sub": subject,
        "iss": ISSUER,
        "aud": AUDIENCE,
        "iat": now,
        "exp": now + lifetime,
        "jti": str(uuid4()),
        "type": token_type,
        "scope": scope,
    }
    return jwt.encode(claims, secret, algorithm=ALGORITHM)

def decode_token(token: str, *, expected_type: str, secret: str) -> dict:
    claims = jwt.decode(
        token,
        secret,
        algorithms=[ALGORITHM],
        issuer=ISSUER,
        audience=AUDIENCE,
        options={"require": ["sub", "iss", "aud", "iat", "exp", "jti", "type"]},
    )
    if claims["type"] != expected_type:
        raise InvalidTokenError("unexpected token type")
    return claims

秘密鍵、有効期限、発行者、対象APIは設定から読み込み、環境ごとに分離します。

例のHS256をそのまま採用するのではなく、発行者と検証者の配置、鍵の共有範囲、ローテーション方法に合う署名方式を選びます。

検証時に受け入れるアルゴリズムをトークンのalgだけから決めてはいけません。

FastAPIのOAuth2スコープで認可を集中管理する

RBACは管理者や編集者といった役割へ権限をまとめる考え方で、スコープはarticles:readやarticles:writeのように許可する操作を表します。

ロールをトークンへ書くだけで各ルートが独自に判定すると、権限変更時に条件が分散します。

ロールからスコープへの対応を一か所で管理し、ルート側には必要なスコープを宣言すると、変更箇所とテスト対象が明確になります。

属性や所有者まで含む判断が必要な場合は、FastAPIにおけるRBACとABACの設計も判断材料になります。

from typing import Annotated

from fastapi import Depends, HTTPException, Security, status
from fastapi.security import OAuth2PasswordBearer, SecurityScopes

oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="/auth/token",
    scopes={
        "articles:read": "記事を読む",
        "articles:write": "記事を作成、更新する",
    },
)

async def require_user(
    security_scopes: SecurityScopes,
    token: Annotated[str, Depends(oauth2_scheme)],
):
    try:
        claims = decode_token(token, expected_type="access", secret=settings.jwt_secret)
    except InvalidTokenError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="invalid credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )

    granted = set(str(claims.get("scope", "")).split())
    required = set(security_scopes.scopes)
    if not required.issubset(granted):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="insufficient scope",
        )

    user = get_active_user(claims["sub"])
    if user is None:
        raise HTTPException(status_code=401, detail="invalid credentials")
    return user

@router.post(
    "/articles",
    dependencies=[Security(require_user, scopes=["articles:write"])],
)
def create_article():
    return {"ok": True}

認証情報がない、または無効なら401を返し、有効な主体に必要な権限がなければ403を返します。

一覧の閲覧、本人のデータ更新、組織管理者の操作は別々の条件なので、一つの「ログイン済み」判定へまとめないことが大切です。

CookieセッションではCSRFを別に防ぐ

ブラウザは条件に合うCookieを自動送信します。

そのため、Cookieだけで認証する状態変更リクエストには、攻撃者のサイトから意図しない操作を送らせるCSRFへの対策が必要です。

HttpOnlyはJavaScriptからCookieを読み取りにくくする属性であり、CSRFを防ぐ属性ではありません。

サーバー側セッションを使う場合、Cookieには推測困難なセッションIDだけを保存し、ログイン成功時と権限変更時にIDを更新します。

response.set_cookie(
    key="__Host-session",
    value=session_id,
    httponly=True,
    secure=True,
    samesite="lax",
    path="/",
)

__Host-接頭辞を使うCookieにはSecureとPath=/を設定し、Domainを指定しません。

SameSiteの値は外部サイトから戻る導線や埋め込み要件に影響するため、要件を確認して選びます。

CSRFトークンの検証手順

  1. 状態を変えるPOST、PUT、PATCH、DELETEだけを保護対象にするのではなく、GETで状態を変えないことも確認する
  2. サーバー側セッションなら、セッションに結び付けた同期トークンを発行する
  3. ステートレス方式が必要なら、セッション固有情報に結び付けた署名付き二重送信トークンを使う
  4. フロントエンドはトークンをカスタムヘッダーへ入れ、サーバーはCookieの送信だけを証明として扱わない
  5. OriginまたはRefererの検証とSameSiteを追加の防御層として使う

固定文字列や、Cookieとヘッダーの値が等しいかだけを見る単純な実装は避けます。

単純な二重送信方式は、攻撃者が関連するサブドメインなどからCookieを書き込める条件で突破されるおそれがあるためです。

FastAPIのJSON Content-Type検査も入力境界を確認する材料になりますが、Content-Type検査だけでCSRF対策を置き換えることはできません。

リフレッシュトークンは回転と再利用検知を一体にする

アクセストークンを短い有効期限にしても、利用者がログアウトした直後に必ず無効になるとは限りません。

即時失効が必要なら、jti、セッションの世代番号、利用者の失効時刻などをサーバー側で確認する設計が必要です。

リフレッシュトークンを回転させる処理は、次の順序を一つのトランザクションとして扱います。

  1. 署名、有効期限、発行者、対象API、トークン種別を検証する
  2. 保存した識別子と照合し、未使用であることを確認する
  3. 古いトークンを使用済みにして、新しいペアを発行する
  4. 使用済みトークンが再送されたら、同じトークン系列を失効させて記録する

発行した新しいトークンを返すだけでは回転になりません。

古いトークンを再利用できない状態へ変え、競合する二つの更新要求が両方成功しないようにする必要があります。

監査ログとエラー応答を運用設計へ含める

認証の安全性は、ログイン処理が動いた時点では確定しません。

権限変更、鍵の更新、トークン失効、異常な再利用を追跡できるようにします。

本番前に通すテスト

正常系だけでは、期限切れ、権限不足、再利用といった境界の欠陥を見つけられません。

依存性注入を使ったテストの組み立て方は、FastAPIの型定義、依存性注入、テストの実践でも確認できます。

テスト対象 入力例 期待する結果
ログイン 存在しない利用者、誤ったパスワード、無効な利用者 情報を増やさない同じ失敗応答になる
JWT 期限切れ、改ざん、異なる発行者や対象API、誤った種別 401で拒否される
認可 必要なスコープの一部だけを持つアクセストークン 403で拒否される
Cookie Secure、HttpOnly、SameSite、Pathの設定 環境と要件に合う属性が付く
CSRF トークンなし、不一致、別セッションのトークン、許可しないOrigin 状態変更が拒否される
更新 同じリフレッシュトークンの同時使用と再使用 一度だけ成功し、再利用が検知される
ログ 認証失敗、権限変更、ログアウト 必要なイベントだけが残り、秘密情報は伏せられる

実装を進める順序

  1. 利用者、クライアント、保護対象、失効要件を決める
  2. パスワードハッシュと一貫したログイン失敗応答を実装する
  3. サーバー側セッションかアクセストークンを選び、検証条件を固定する
  4. ロールからスコープへの対応と、ルートごとの認可条件を実装する
  5. Cookieを使う経路へCSRF対策とセッションID更新を加える
  6. 更新トークンの回転、失効、再利用検知を実装する
  7. レート制限、監査ログ、鍵の更新手順を整える
  8. 失敗系と競合を含むテストを継続的に実行する

小さなAPIでも、認証、認可、状態管理を別々の責務として実装すると、後から利用者や権限が増えたときに境界を見直しやすくなります。

参考資料

認証基盤を運用できる形にする

FastAPIの認証と認可では、ライブラリの選択よりも、信頼境界と失効要件を先に決めることが設計の土台になります。

JWTを使うならクレームとトークン種別を検証し、Cookieを使うならCSRFとセッション固定化を別々に防ぎます。

そのうえで、スコープ、更新トークンの回転、監査ログ、失敗系テストを一つの運用手順へつなげてください。

この記事に関連する株式会社greedenの取り組み

認証と認可は、利用者と運用に合う境界設計から始まります。株式会社greedenはWebシステム開発を通じ、要件整理から構築、公開後の改善まで継続して支援します。

モバイルバージョンを終了