「FastAPI で API にログインを付けたい」——要件は一行です。けれど認証は、一行のミスがそのまま情報漏洩になる領域です。パスワードを平文で保存した、JWT の検証で algorithms を指定し忘れた、Authorization ヘッダを信用してユーザーを取り違えた——どれも「動いてしまう」がゆえに、本番に出るまで気づきません。
この記事は、FastAPI で本番品質の認証(AuthN)と認可(AuthZ)を実装するためのガイドです。FastAPI 公式チュートリアルの OAuth2 + JWT の流れを最新の公式仕様に忠実に追いながら、公式が「デモ用」と断っている部分(コード内のダミーユーザー・ハードコードした鍵)を、実運用に耐える形へ引き上げます。題材として、私が国内大手放送事業者向けに構築した社内AIプラットフォーム(FastAPI 製のテロップ誤字検出パイプライン。各ツールへ audience を絞った短命JWTを発行する自作OIDC認証ハブを設計)と、経済産業大臣賞を受賞した木材流通B2B SaaS(全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を検討してください(認証基盤の選定はこちらで比較)。本記事は「自前で正しく実装する」道を扱いますが、マネージドIdPを使う場合でも、後半の JWT 検証の勘所・認可(スコープ)・ルーター層での一元化はそのまま役立ちます(Cognito の RS256 + JWKS 検証はこちら)。
1. 全体像:OAuth2 パスワードフローの一筆書き
先に完成形の流れを掴みます。登場人物とデータの動きはこれだけです。
- クライアントが
/tokenにユーザー名・パスワードを フォーム形式で POST する。 - サーバーは資格情報を検証し、成功なら JWT(アクセストークン) を返す。
- クライアントは以後、保護されたエンドポイントへ
Authorization: Bearer <token>ヘッダを付けて送る。 - サーバーは依存(
Depends/Security)でトークンを検証し、現在のユーザーを復元してハンドラに注入する。 - トークンは一定時間で失効する(短命に保つのが鉄則)。
この配線の起点が 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_token と token_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.decode は exp(有効期限)を自動で検証し、期限切れなら 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 Security と SecurityScopes:ここが肝
公式の最重要ポイント:スコープを要求するときは 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=True と allow_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 は使わないので緩められますが、オリジンの絞り込みは常に推奨です。
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 等の多層防御)。 - アカウントロックアウト/指数バックオフ:連続失敗で一時ロック。
- 強固なパスワードポリシー:登録時に 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 + 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) — OAuth2・OpenID Connect・パスワードフローの概念と
fastapi.securityの道具 - Security - First Steps(FastAPI) —
OAuth2PasswordBearer・Authorization: Bearer・/docs 連携 - Get Current User(FastAPI) — 依存で現在のユーザーを復元する
- Simple OAuth2(FastAPI) —
OAuth2PasswordRequestFormとトークン応答の形 - OAuth2 with Password (and hashing), Bearer with JWT tokens(FastAPI) — pwdlib(Argon2)・PyJWT・タイミング攻撃対策・
subの指針 - OAuth2 scopes(FastAPI) —
Security・SecurityScopes・スコープの累積 - CORS(FastAPI) —
CORSMiddlewareとallow_credentialsの制約 - PyJWT ドキュメント —
encode/decode・algorithms・aud/iss検証 - pwdlib ドキュメント — Argon2/bcrypt のパスワードハッシュ