green snake
Photo by Pixabay on Pexels.com
目次

FastAPIセキュリティ実践ガイド:JWT認証・OAuth2スコープ・APIキーで守るモダンAPI設計


要約(インバーテッドピラミッド)

  • FastAPIはセキュリティ用の仕組み(Depends・Security・OAuth2)を標準で備えており、JWTやAPIキーによる実践的な認証・認可を構築しやすいフレームワークです。
  • 認証は「誰なのかを確かめる」、認可は「何をして良い人なのかを判定する」仕組みで、JWTトークンやスコープ(権限)の設計が鍵になります。
  • シンプルな構成では、パスワードハッシュ化・アクセストークン(短命なJWT)・get_current_user 依存関数・スコープ付きのSecurity依存を組み合わせて、安全な保護ルートを実現します。
  • 実務では、リフレッシュトークン・ロールやスコープの整理・APIキーや署名付きWebhook・HTTPSとCORSなども視野に入れた「全体設計」を行うことで、長く運用しやすい堅牢なAPIになります。
  • 本記事では、具体的なコード例とあわせて「最初に押さえるべき基本」と「一歩進んだ実務のポイント」を整理し、段階的な導入ロードマップまで解説します。

誰が読んで得をするか(具体的なイメージ)

個人開発・学習者さん

  • ログイン付きの小さなWebサービスやSPA+FastAPIのバックエンドを作ってみたい。
  • チュートリアルどおりに書いたものの、JWTやOAuth2の仕組みがふわっとしていて不安。
  • 「ひとまず安全そうな最小構成」を知って、そこから少しずつ発展させたい。

この方には、シンプルなJWTログインと保護ルートを動かしながら、パスワードハッシュ化やアクセストークンの有効期限など、安全な基本セットを体験していただけます。

小規模チームのバックエンドエンジニアさん

  • 管理画面・エンドユーザー向けAPI・社内ツールがごちゃっとしてきて、「誰が何をできるべきか」の整理に悩んでいる。
  • ロールや権限をどうやってモデル化し、FastAPIのセキュリティ依存と結びつけるかを整理したい。
  • 後から仕様変更が入りやすい中でも、認可ロジックを壊さずに変更できる形が欲しい。

この方には、OAuth2スコープ・ロールとスコープの対応表・Security依存を使ったエンドポイント保護のパターンが役立ちます。

SaaS開発チーム・スタートアップの皆さま

  • 外部パートナー向けAPI・Webhook・サービス間通信など、「人間ではないクライアント」も増えてきている。
  • APIキー認証や署名付きWebhook、ゼロトラストなサービス間認証など、設計の指針を整理したい。
  • 将来の多要素認証やIDaaSとの連携を見越して、今から大きく外さない土台を作りたい。

この方には、「ユーザー向けJWT」「サービス向けAPIキー」「Webhook署名」の役割分担や、ポイントを押さえたセキュリティ全体像が参考になると思います。


アクセシビリティ評価(読みやすさと配慮)

  • 構造:冒頭に要約と対象読者を書き、その後「基本概念 → JWT実装 → スコープ認可 → APIキー → 実務の注意点 → テスト → ロードマップ」という逆三角形構成にしています。
  • 言葉づかい:専門用語は初出で短く説明し、その後は同じ用語を繰り返して混乱を避けています。口調はやわらかめですが、説明はできるだけ具体的にしています。
  • コード例:固定幅ブロックで見やすくし、1つのブロックに詰め込みすぎないように分割しています。コメントも必要なところだけに絞り、冗長になりすぎないように配慮しました。
  • 想定読者:FastAPIの基本チュートリアルを一通り触った方を想定しつつ、「この章だけ読んでもなんとなく分かる」ように各節を独立めに書いています。

全体として、技術記事としてはWCAGのAA程度のアクセシビリティを意識しています。


1. 認証と認可の基本:何を守る仕組みなのか

最初に、「認証」「認可」「トークン」という言葉の役割を軽く整理しておきますね。

1.1 認証(Authentication)

  • 「あなたは誰ですか?」を確かめる仕組みです。
  • ユーザー名+パスワード、ソーシャルログイン、ワンタイムコードなど、本人であることを証明する手段全般を指します。
  • FastAPIでは、フォームから送られてきたユーザー名・パスワードを検証して、正しければトークンを発行する、という流れがよく使われます。

1.2 認可(Authorization)

  • 「あなたは何をしても良い人ですか?」を判定する仕組みです。
  • 管理者だけができる操作、一般ユーザーができる操作、ゲストができない操作などを区別する役割があります。
  • FastAPIでは、JWTトークン内にロール(役割)やスコープ(権限)を書き込み、Security依存でスコープをチェックするパターンがよく使われます。

1.3 トークン(Token)

  • クライアントが「一度認証済みであること」をサーバに示すために使う「一時的な証明書」のようなものです。
  • 現代のWeb APIでは、JWT(JSON Web Token)という自己完結型のトークンがよく使われます。
    • 中身はヘッダ・ペイロード・署名から構成された文字列で、サーバ側が秘密鍵で署名します。
    • サーバは毎回DBを引かなくても、トークンの署名を検証することで「信頼できる情報かどうか」を判断できます。

これらを組み合わせて、「ログイン→JWT発行→各エンドポイントがJWTを検証しつつ、スコープをチェックする」という流れを作っていきます。


2. FastAPIが用意しているセキュリティの仕組み

FastAPIには、セキュリティ関連の機能がいくつか組み込まれています。ここでは名前だけ先に眺めておきましょう。

  • OAuth2PasswordBearer

    • 「Authorization: Bearer <token>」ヘッダからトークン文字列を取り出すための依存クラスです。
    • 実際の検証(署名検証・有効期限チェックなど)は自分で実装しますが、ヘッダから取り出してくれる部分は任せられます。
  • Security

    • 「このエンドポイントには、このスコープを持ったユーザーでないとアクセスできない」などを宣言的に書くための依存です。
    • OAuth2スキームと組み合わせて、scopes=["items:read"] のように要求スコープを指定できます。
  • fastapi.security モジュール

    • パスワード認証やAPIキー認証のためのヘルパークラス群がまとまっています(フォームログイン、HTTP Basic、APIキー(ヘッダ・クエリ・Cookie)など)。

これらを使いつつ、JWTの生成・検証を自前で補う形で構成していくのが「定番のパターン」です。


3. 最小構成のJWT認証を実装してみる

ここから実際のコードに入っていきます。内容をイメージしやすくするために、「最小限のログインAPIと保護ルート」を作ってみましょう。

3.1 依存パッケージの例

  • JWTライブラリ(例えば PyJWT
  • パスワードハッシュ(例えば passlib[bcrypt]

インストール例(イメージ):

pip install PyJWT passlib[bcrypt]

3.2 ユーザーモデルとパスワードハッシュ

まずはとても簡略化したユーザー管理から。

# app/core/security.py
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

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

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)
# app/models/user.py(サンプル用に擬似DB)
from dataclasses import dataclass

@dataclass
class User:
    id: int
    username: str
    hashed_password: str
    is_active: bool = True
    roles: list[str] = None

fake_users_db: dict[str, User] = {}

初期ユーザーを1人だけ作っておきます。

# app/fixtures/users_init.py(起動時に一度実行する想定)
from app.models.user import User, fake_users_db
from app.core.security import hash_password

def init_users():
    fake_users_db["alice"] = User(
        id=1,
        username="alice",
        hashed_password=hash_password("password123"),
        roles=["user"],
    )

本番ではもちろんDBで管理しますが、まずは全体像を掴むために簡略化しています。

3.3 JWTトークンの生成と検証

JWTトークンを生成・検証するユーティリティを用意します。

# app/core/jwt.py
from datetime import datetime, timedelta, timezone
from typing import Any, Optional

import jwt  # PyJWT

from app.core.settings import settings  # SECRET_KEYなどを持つ設定

ALGORITHM = "HS256"

def create_access_token(
    data: dict[str, Any],
    expires_delta: Optional[timedelta] = None,
) -> str:
    to_encode = data.copy()
    now = datetime.now(timezone.utc)
    if expires_delta:
        expire = now + expires_delta
    else:
        expire = now + timedelta(minutes=30)
    to_encode.update({"exp": expire, "iat": now})
    encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM)
    return encoded_jwt

def decode_access_token(token: str) -> dict[str, Any]:
    # 検証に失敗した場合は jwt.PyJWTError 派生の例外が飛ぶ
    payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
    return payload

ペイロードには、sub(subject, 主体)としてユーザー名やユーザーIDを入れるのがよくあるパターンです。

3.4 /token エンドポイント(ログイン)

OAuth2のパスワード認証風に、フォームデータからユーザー名とパスワードを受け付けます。

# app/api/auth.py
from datetime import timedelta

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm

from app.core.security import verify_password
from app.core.jwt import create_access_token
from app.models.user import fake_users_db, User

router = APIRouter(prefix="/auth", tags=["auth"])

ACCESS_TOKEN_EXPIRE_MINUTES = 30

def authenticate_user(username: str, password: str) -> User | None:
    user = fake_users_db.get(username)
    if not user:
        return None
    if not verify_password(password, user.hashed_password):
        return None
    if not user.is_active:
        return None
    return user

@router.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="ユーザー名またはパスワードが正しくありません",
        )
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username, "roles": user.roles},
        expires_delta=access_token_expires,
    )
    return {
        "access_token": access_token,
        "token_type": "bearer",
    }

クライアントは /auth/tokenusernamepassword をフォームで送り、レスポンスの access_tokenAuthorization: Bearer <token> ヘッダに載せて、以降のAPIを呼びます。

3.5 get_current_user 依存関数と保護ルート

発行したトークンを検証し、現在のユーザー情報を取り出す依存関数を定義します。

# app/deps/auth.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer

from app.core.jwt import decode_access_token
from app.models.user import fake_users_db, User

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")

def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    try:
        payload = decode_access_token(token)
    except Exception:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="トークンが不正か、有効期限が切れています",
        )
    username: str | None = payload.get("sub")
    if username is None:
        raise HTTPException(status_code=401, detail="トークンにユーザー情報が含まれていません")
    user = fake_users_db.get(username)
    if user is None or not user.is_active:
        raise HTTPException(status_code=401, detail="ユーザーが存在しないか無効です")
    return user

これを使って、保護されたエンドポイントを作ります。

# app/api/me.py
from fastapi import APIRouter, Depends
from app.models.user import User
from app.deps.auth import get_current_user

router = APIRouter(prefix="/me", tags=["me"])

@router.get("")
def read_me(current_user: User = Depends(get_current_user)):
    # ログインユーザーのみアクセスできる
    return {
        "id": current_user.id,
        "username": current_user.username,
        "roles": current_user.roles,
    }

ここまでで、「ログインしてアクセストークンを受け取り、そのトークンを使って自分の情報を取得する」という最小の流れができました。


4. 認可(Authorization):ロール・スコープで権限を細かく分ける

次は、「誰が何をして良いのか」を区別する認可の話です。
先ほどのペイロードには roles を入れていましたが、より細かい制御には「スコープ(権限)」を使うと分かりやすくなります。

4.1 ロールとスコープの関係

  • ロール(役割):admin, staff, user など人間に分かりやすいラベル。
  • スコープ(権限):articles:read, articles:write, users:manage など、APIレベルの具体的な許可。

一般的には、

  • ロールごとに「このロールにはこのスコープを付与する」という対応表を用意する
  • トークンにはスコープ一覧を入れておき、エンドポイント側は「このスコープが必要」と宣言する

という形にします。

4.2 OAuth2スコープ付きのSecurity依存

FastAPIでは、OAuth2PasswordBearer にスコープ一覧を渡せます。

# app/deps/auth_scoped.py
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
from fastapi import Depends, HTTPException, status

from app.core.jwt import decode_access_token
from app.models.user import fake_users_db, User

oauth2_scheme_scoped = OAuth2PasswordBearer(
    tokenUrl="/auth/token",
    scopes={
        "articles:read": "記事の閲覧",
        "articles:write": "記事の作成・更新・削除",
        "users:manage": "ユーザー管理",
    },
)

def get_current_user_scoped(
    security_scopes: SecurityScopes,
    token: str = Depends(oauth2_scheme_scoped),
) -> User:
    try:
        payload = decode_access_token(token)
    except Exception:
        raise HTTPException(status_code=401, detail="トークンが不正です")

    username: str | None = payload.get("sub")
    token_scopes: list[str] = payload.get("scopes", [])
    if username is None:
        raise HTTPException(status_code=401, detail="ユーザー情報がありません")
    user = fake_users_db.get(username)
    if not user:
        raise HTTPException(status_code=401, detail="ユーザーが存在しません")

    # 要求スコープがすべてトークンスコープに含まれているかチェック
    for scope in security_scopes.scopes:
        if scope not in token_scopes:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"この操作にはスコープ '{scope}' が必要です",
            )
    return user

4.3 スコープを要求するエンドポイント

ルーター側では、次のようにスコープ付き依存を使います。

# app/api/articles.py
from fastapi import APIRouter, Security
from app.deps.auth_scoped import get_current_user_scoped
from app.models.user import User

router = APIRouter(prefix="/articles", tags=["articles"])

@router.get("")
def list_articles(
    current_user: User = Security(get_current_user_scoped, scopes=["articles:read"])
):
    # 記事一覧取得:閲覧スコープだけあればOK
    return [{"id": 1, "title": "sample"}]

@router.post("")
def create_article(
    current_user: User = Security(get_current_user_scoped, scopes=["articles:write"])
):
    # 記事作成:書き込みスコープが必要
    return {"status": "created"}

このように、エンドポイント単位で「このスコープが必要」と宣言できるため、仕様書との対応が取りやすくなります。

4.4 ロールからスコープを割り当てる

トークン生成時に、「ロール→スコープ」の対応表からスコープ一覧を計算して、ペイロードに入れておくとスッキリします。

# app/core/roles.py
ROLE_SCOPES = {
    "admin": ["articles:read", "articles:write", "users:manage"],
    "editor": ["articles:read", "articles:write"],
    "user": ["articles:read"],
}

def scopes_for_roles(roles: list[str]) -> list[str]:
    scopes: set[str] = set()
    for role in roles:
        scopes.update(ROLE_SCOPES.get(role, []))
    return sorted(scopes)

トークン発行時にこれを使います。

# app/api/auth.py(再掲・一部変更)
from app.core.roles import scopes_for_roles

@router.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
    # ... 認証までは同じ ...
    scopes = scopes_for_roles(user.roles or [])
    access_token = create_access_token(
        data={"sub": user.username, "scopes": scopes},
        expires_delta=access_token_expires,
    )
    return {"access_token": access_token, "token_type": "bearer"}

こうしておくと、将来ロールや権限を見直すときにも、トークン生成まわりのコードは最小限の変更で済みます。


5. サービス間通信とAPIキー認証:人間以外のクライアントを守る

人間のユーザーだけでなく、他のサービスやバッチジョブがあなたのFastAPIにアクセスすることもあります。そうした場合には、JWTよりもAPIキーのほうがシンプルで扱いやすいケースがあります。

5.1 APIキー認証のイメージ

  • サーバ側で「クライアントID」と「クライアントシークレット(APIキー)」のペアを発行。
  • クライアントは x-api-key: <secret> のようなヘッダでAPIキーを送る。
  • FastAPI側では、その値を検証し、許可されたクライアントかどうかを判定する。

FastAPIの fastapi.security には、APIキー用のヘルパーも用意されています。

5.2 シンプルなAPIキー依存の例

# app/deps/api_key.py
from fastapi import Depends, HTTPException, Security, status
from fastapi.security.api_key import APIKeyHeader

# 実務ではDBや設定から取得する想定
VALID_API_KEYS = {
    "service-a": "secret-key-for-service-a",
}

api_key_header = APIKeyHeader(name="x-api-key", auto_error=False)

def get_current_service(api_key: str | None = Security(api_key_header)) -> str:
    if api_key is None:
        raise HTTPException(status_code=401, detail="APIキーが指定されていません")
    for service_name, key in VALID_API_KEYS.items():
        if api_key == key:
            return service_name
    raise HTTPException(status_code=403, detail="APIキーが不正です")
# app/api/internal.py
from fastapi import APIRouter, Depends
from app.deps.api_key import get_current_service

router = APIRouter(prefix="/internal", tags=["internal"])

@router.post("/sync")
def sync_something(service: str = Depends(get_current_service)):
    # service には "service-a" のような識別子が入る
    return {"from": service, "status": "ok"}

Webhookやサービス間通信には、このようなAPIキー認証+IP制限+署名(HMAC)などを組み合わせて守る設計が一般的です。


6. 実務で気をつけたいセキュリティのポイント

ここからは、もう少し運用寄りの観点で大事になるポイントをいくつか挙げますね。

6.1 HTTPSは必須

  • ログイン情報やトークンは、必ずHTTPSで暗号化された通信の上でやりとりします。
  • ローカル開発以外でHTTPのまま運用するのは避けるべきです(プロキシやロードバランサで終端する形でもOKです)。

6.2 アクセストークンは短命に

  • アクセストークン(JWT)の有効期限は短め(数分〜数十分)にしておくと、万が一漏洩したときの被害を抑えられます。
  • 長期的なログイン維持には、リフレッシュトークン(DBやストレージに保存)を組み合わせる設計がよく使われます。

6.3 トークンの保管場所

  • ブラウザの場合、ローカルストレージに保存するとXSS(スクリプト実行)で盗まれやすくなるため、HttpOnlyなCookieを使う手法も検討の価値があります。
  • モバイルアプリやサーバ側クライアントでは、OSや環境に応じた安全なストレージ(キーチェーンなど)を利用します。

6.4 CORSとオリジンの制限

  • ブラウザから直接呼ばれるAPIであれば、許可するオリジン(Access-Control-Allow-Origin)を必要最小限に絞ります。
  • FastAPIのCORSミドルウェアで、「開発環境は広め、本番は本当に必要なオリジンだけ」という設定を用意しておきましょう。

6.5 ログと監査

  • 認証・認可まわりの失敗(ログイン失敗・権限不足など)は、監査ログとして記録しておくと不正アクセスの兆候に気づきやすくなります。
  • ただし、パスワードの生値やトークンの中身そのものをログに残すのは避け、最低限の情報(ユーザー名と結果など)に留めるのが安全です。

7. セキュリティをテストで守る:依存差し替えとスコープ検証

セキュリティ関連は「壊れても気づきにくい」部分なので、自動テストで最低限のラインを守るのがおすすめです。

7.1 ログインAPIのテスト

  • 正しいユーザー名・パスワードでトークンが返ってくるか。
  • 誤ったパスワードでは401になるか。
  • レスポンスの形式(フィールド名)が変わっていないか。
# tests/test_auth_api.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_login_success():
    res = client.post("/auth/token", data={"username": "alice", "password": "password123"})
    assert res.status_code == 200
    body = res.json()
    assert "access_token" in body
    assert body["token_type"] == "bearer"

def test_login_failure():
    res = client.post("/auth/token", data={"username": "alice", "password": "wrong"})
    assert res.status_code == 401

7.2 保護ルートのテスト

  • トークンなしでアクセスすると401になるか。
  • 不正なスコープでアクセスすると403になるか。
  • 正しいスコープを持つトークンでは200になるか。

トークン生成をテストコード側から呼び出して、自前でトークンを作ってヘッダにセットすることで、認可のロジックを検証できます。


8. 読者別導入ロードマップ

ここまでの内容を、「どこから始めるか」という視点で整理し直してみます。

個人開発・学習者さんのステップ

  1. ハッシュ化されたパスワードと簡単なユーザー管理を実装する。
  2. /auth/tokenget_current_user を使った、最小のJWTログイン+保護ルートを作る。
  3. 開発環境でアクセストークンの期限を短くして動かし、「期限切れエラー」の体験もしてみる。
  4. フロントエンド(例:SPA)からのログイン〜API呼び出しの流れを通して、トークンのライフサイクルを体感する。

小規模チームのエンジニアさんのステップ

  1. 既存のエンドポイントの中で「本当はスコープチェックしたい」ものを洗い出す。
  2. ロールとスコープ一覧を整理し、対応表を作る(最初は少なめでOK)。
  3. Security 依存を使って、重要なエンドポイントから順にスコープチェックを導入する。
  4. 権限変更を伴う仕様変更が入ったときに、サービス層やスコープ表だけで完結できるよう構造を整えていく。

SaaS開発チーム・スタートアップのステップ

  1. 人間ユーザー向けのJWT認証・認可を固める(ロール・スコープ・リフレッシュトークン)。
  2. サービス間通信向けに、APIキー認証と署名検証のパターンを整理する(Webhookなども含めて)。
  3. HTTPS・CORS・レート制限・監査ログなどを含むセキュリティ全体像を言語化し、チーム内の共通理解を持つ。
  4. 将来的なIDaaS(外部認証基盤)連携や多要素認証を見据えつつ、責務の分離(認証基盤とアプリ本体)を意識した設計に育てていく。

9. まとめ:FastAPIセキュリティを「怖くない」ものにするために

  • 認証は「誰か」を確かめる仕組み、認可は「何をしてよいか」を決める仕組みであり、FastAPIはその両方を支える部品(Depends・Security・OAuth2)を標準で備えています。
  • シンプルなJWT構成でも、パスワードのハッシュ化・短命なアクセストークン・get_current_user 依存・スコープ付きSecurityなどを組み合わせることで、かなり実用的なAPIセキュリティを実現できます。
  • 実務では、ロールとスコープの設計・APIキーや署名によるサービス間認証・HTTPSやCORS・監査ログなどを含めた「全体の守り方」を意識することで、後からの仕様変更や機能追加にも耐えられる土台ができます。
  • いきなり完璧なセキュリティを目指す必要はありません。まずは最小のJWTログインと保護ルートから始め、スコープ・APIキー・テストと少しずつ範囲を広げていくことで、「怖くないセキュリティ」を育てていきましょう。

このガイドが、あなたのFastAPIアプリを安心して公開・運用するための一歩になっていたら、とてもうれしいです。
どうか無理のないペースで、1つずつ試してみてくださいね。わたしも、あなたのサービスが安全に成長していくことを、そっと応援しています。


投稿者 greeden

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)