FastAPIの公式チュートリアル「OAuth2 with Password (and hashing), Bearer with JWT tokens」で長年紹介されてきたpasslibが、0.118.0で正式にpwdlibへ置き換わりました。背景には、Python 3.13でcryptモジュールが完全に削除されること、そしてpasslib自体のメンテナンスが鈍っていることがあります。GitHubのPR #13917で議論された経緯を読むと、単なる依存ライブラリの差し替えではなく、Argon2をデフォルトにする方針転換でもありました。
仕事でFastAPIに認証を組み込む機会があり、せっかくなので最新のチュートリアル方針に沿ってPyJWT 2.12.1とpwdlibで一通り組み直してみました。動かしてみて初めて気づいたハマりどころも含めて、実装手順をまとめます。
FastAPI 0.118系でJWT認証チュートリアルが大きく変わった
passlibからpwdlibへの移行は、FastAPI公式が推奨する書き方そのものの変更です。bcryptをargon2に変えただけ、と思って読み流すと意外な落とし穴があります。
passlibからpwdlibに切り替わった理由
公式ドキュメントとPR #13917の説明を読み比べてみると、移行の動機は次の3点に集約できます。
- passlibは内部で
cryptモジュールに依存している箇所があり、Python 3.13でこのモジュールが完全削除される - passlibはここ数年、ほぼメンテされていない(PyPIのリリース履歴を見ても更新が止まっている)
- pwdlib作者のFrançois Voron氏(fastapi-usersの作者でもある)が「モダンPython時代向けのpassword hash helper」として設計した
個人的には、認証まわりのライブラリが止まっているとセキュリティ修正が降りてこないリスクが大きいので、0.118系へのアップデートと同時に切り替えてしまうのが現実的だと思います。
PyJWT 2.12.1の現行仕様
PyJWTは2026年3月13日にリリースされた2.12.1が現時点の最新です。Python 3.9以上が必要で、HS256/RS256/PS256/ES256/EdDSAをサポートします。RS256など非対称鍵を使う場合はcryptographyパッケージが必須なので、依存追加を忘れないようにします。
実装で使うライブラリと環境バージョン
記事中のコードは次の環境で確認しました。
| ライブラリ | バージョン | 役割 |
|---|---|---|
| Python | 3.12.7 | ランタイム |
| FastAPI | 0.118.2 | Webフレームワーク |
| PyJWT | 2.12.1 | JWTのencode/decode |
| pwdlib[argon2] | 0.2.1 | パスワードハッシュ化 |
| uvicorn | 0.32.0 | ASGIサーバー |
インストール手順
uvを使っている場合はこんな感じです。pipでも同じパッケージ名で入ります。
uv add fastapi==0.118.2 "pyjwt==2.12.1" "pwdlib[argon2]==0.2.1" "uvicorn[standard]==0.32.0" python-multipart
Resolved 28 packages in 412ms
Installed 5 packages in 187ms
+ fastapi==0.118.2
+ pyjwt==2.12.1
+ pwdlib==0.2.1
+ python-multipart==0.0.20
+ uvicorn==0.32.0
注意点として、OAuth2PasswordRequestFormを使うのでpython-multipartを必ず入れます。これを忘れるとリクエストボディのパースで500エラーになります。
SECRET_KEYの生成
FastAPI公式チュートリアルが案内しているとおり、HS256で署名する場合はopenssl rand -hex 32で256ビットの乱数を生成します。
openssl rand -hex 32
9d4a8f1e7c5b2d3e6f0a8b9c2d4e6f8a1b3c5d7e9f0a2b4c6d8e0f2a4b6c8d0e
これを.envファイルに置き、os.getenv("SECRET_KEY")で読み込みます。コードに直書きすると、誤ってgitにコミットしたときの被害が大きいので避けたいところです。
PyJWTでトークンを発行・検証する基本パターン
FastAPIの話に入る前に、PyJWTのencodeとdecodeだけを切り出して動かしてみます。これを押さえておくと、後で認証フローで詰まったときに切り分けやすくなります。
jwt.encodeでアクセストークンを作る
まずは最小構成のサンプルです。
from datetime import datetime, timedelta, timezone
import jwt
SECRET_KEY = "9d4a8f1e7c5b2d3e6f0a8b9c2d4e6f8a1b3c5d7e9f0a2b4c6d8e0f2a4b6c8d0e"
ALGORITHM = "HS256"
payload = {
"sub": "user-001",
"exp": datetime.now(timezone.utc) + timedelta(minutes=30),
"iat": datetime.now(timezone.utc),
}
token = jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
print(token)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTAwMSIsImV4cCI6MTc2MTQzNTYwMCwiaWF0IjoxNzYxNDMzODAwfQ.kQv7s1xJjL2pN8wY5rT3hZ6mB4cV9eA0bQ8sW2rU7gI
ポイントはdatetime.now(timezone.utc)でタイムゾーン付きのUTC時刻を渡すこと。古いサンプルでよく見るdatetime.utcnow()はPython 3.12で非推奨になり、3.13で警告が出るようになりました。
jwt.decodeで検証するときの落とし穴
デコード時にalgorithmsを指定しない、あるいはalgorithms=["none"]を許容してしまうと、署名なしトークンが通る古典的な脆弱性に直結します。常にリスト形式で明示するのが鉄則です。
try:
decoded = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
print(decoded)
except jwt.ExpiredSignatureError:
print("token has expired")
except jwt.InvalidTokenError as e:
print(f"invalid token: {e}")
{'sub': 'user-001', 'exp': 1761435600, 'iat': 1761433800}
PyJWT 2系の例外階層はInvalidTokenErrorが親で、ExpiredSignatureError / InvalidSignatureError / InvalidIssuerError / InvalidAudienceErrorなどがその子です。FastAPIで認証ミドルウェアを書くときは、まずExpiredSignatureErrorだけ別ハンドリングして401で「期限切れ」を返し、それ以外はInvalidTokenErrorで一括処理する構成が扱いやすいです。
登録クレーム(exp/iss/aud)の扱い
RFC 7519で定義された登録クレームのうち、最低限expとsubは必ず入れます。マイクロサービス間で使うならiss(発行者)とaud(受信者)も指定し、デコード時に検証するのが安全です。
jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"],
audience="api.mohablog.com",
issuer="auth.mohablog.com",
options={"require": ["exp", "iat", "sub"]},
)
options={"require": [...]}を指定すると、列挙したクレームが欠けているトークンはMissingRequiredClaimErrorで弾けます。これは触ってみないと気づきにくい機能ですが、トークン発行側のバグを早期に発見できるので入れておく価値があります。
pwdlibでパスワードをハッシュ化する
pwdlibは内部でArgon2をデフォルトに採用しています。bcryptと比べて、GPU攻撃への耐性が高いとされる一方、メモリ使用量が大きいのでパラメータ調整が要ります。
PasswordHash.recommended()の中身
pwdlibの推奨初期化はこの一行です。
from pwdlib import PasswordHash
password_hash = PasswordHash.recommended()
hashed = password_hash.hash("super-secret-password")
print(hashed)
print(password_hash.verify("super-secret-password", hashed))
$argon2id$v=19$m=65536,t=3,p=4$Y3J5cHRvX3NhbHRfMTYK$0vqL3xXz...
True
recommended()は内部的にArgon2idハッシャーを返します。m=65536(メモリ64MB)、t=3(イテレーション3回)、p=4(並列度4)というOWASP推奨に近い設定です。
passlibからの移行で詰まった点
既存のpasslib(bcrypt)で作ったハッシュをpwdlibでそのまま検証できるかというと、できます。pwdlibはbcryptハッシャーも内蔵しているので、PasswordHash((BcryptHasher(), Argon2Hasher()))のように両対応で初期化しておけば、ログイン時に旧ハッシュを検証→Argon2でリハッシュという段階移行が可能です。
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from pwdlib.hashers.bcrypt import BcryptHasher
password_hash = PasswordHash((Argon2Hasher(), BcryptHasher()))
# 検証は両方式で動く。新規ハッシュは先頭に指定したArgon2で生成される
これは「passlibで運用してきたサービスをpwdlibに乗せ換える」現場では必須テクニックです。一括でハッシュを作り直すとログイン時に再認証を強いることになるので、段階移行が望ましいです。
FastAPIでBearerトークン認証を組み込む
ライブラリ単体の動きを確認したら、FastAPIに統合します。OAuth2PasswordBearerとOAuth2PasswordRequestFormが肝です。
トークン発行エンドポイント
/tokenエンドポイントはOAuth2のパスワードフローに準拠した形で作ります。
from datetime import datetime, timedelta, timezone
from typing import Annotated
import os
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError, ExpiredSignatureError
from pwdlib import PasswordHash
from pydantic import BaseModel
SECRET_KEY = os.environ["SECRET_KEY"]
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
password_hash = PasswordHash.recommended()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
# 簡易ユーザーストア(実際はSQLAlchemy等で永続化)
fake_users_db = {
"alice": {
"username": "alice",
"hashed_password": password_hash.hash("wonderland"),
},
}
def authenticate_user(username: str, password: str):
user = fake_users_db.get(username)
if not user or not password_hash.verify(password, user["hashed_password"]):
return None
return user
def create_token(sub: str, expires_delta: timedelta, token_type: str) -> str:
payload = {
"sub": sub,
"exp": datetime.now(timezone.utc) + expires_delta,
"iat": datetime.now(timezone.utc),
"type": token_type,
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
@app.post("/token", response_model=Token)
async def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]):
user = authenticate_user(form_data.username, form_data.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access = create_token(user["username"], timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), "access")
refresh = create_token(user["username"], timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh")
return Token(access_token=access, refresh_token=refresh)
動作確認はcurlでこんな感じです。
curl -s -X POST http://127.0.0.1:8000/token -d "username=alice&password=wonderland" -H "Content-Type: application/x-www-form-urlencoded" | python -m json.tool
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer"
}
保護されたエンドポイントの作り方
Depends(oauth2_scheme)を挟むだけで、リクエストヘッダからAuthorization: Bearer ...を抽出してくれます。中身の検証は自前のDependencyで実装します。
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
if payload.get("type") != "access":
raise credentials_exception
username: str | None = payload.get("sub")
if username is None:
raise credentials_exception
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token expired",
headers={"WWW-Authenticate": 'Bearer error="invalid_token"'},
)
except InvalidTokenError:
raise credentials_exception
user = fake_users_db.get(username)
if user is None:
raise credentials_exception
return user
@app.get("/users/me")
async def read_users_me(current_user: Annotated[dict, Depends(get_current_user)]):
return {"username": current_user["username"]}
地味に重要なのがpayload.get("type") != "access"のチェックです。リフレッシュトークンを誤って通常APIに使われるのを防ぐため、トークン種別をペイロードに含めて用途を分離しています。これを忘れると、リフレッシュトークン(長寿命)でAPIが叩き放題になりかねません。
リフレッシュトークンを使った安全なセッション設計
サジェストでもよく出る「fastapi jwt refresh token」のテーマです。アクセストークンを短命(15〜30分)にし、リフレッシュトークンで再発行するのが定石ですが、実装には設計上の選択肢が複数あります。
リフレッシュエンドポイントの実装
class RefreshRequest(BaseModel):
refresh_token: str
@app.post("/token/refresh", response_model=Token)
async def refresh_token(req: RefreshRequest):
try:
payload = jwt.decode(req.refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
except ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Refresh token expired")
except InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Wrong token type")
sub = payload["sub"]
access = create_token(sub, timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), "access")
refresh = create_token(sub, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh")
return Token(access_token=access, refresh_token=refresh)
新しいリフレッシュトークンも同時に発行する、いわゆるリフレッシュトークンローテーションのパターンです。Auth0などでも採用されており、漏洩したリフレッシュトークンの再利用を検出できます。
cookie保存とlocalStorage保存の比較
クライアント側でJWTをどこに保存するかは、毎回議論になるポイントです。サジェストにも「fastapi jwt cookie」「fastapi jwt httponly cookie」が並んでいるとおり、Cookieに置く設計が好まれる傾向があります。
| 保存先 | XSS耐性 | CSRF耐性 | 実装の手間 |
|---|---|---|---|
| localStorage | 低(JSから読める) | 高(自動送信されない) | 低 |
| HttpOnly Cookie | 高(JSから読めない) | 低(CSRF対策が別途必要) | 中 |
| HttpOnly + SameSite=Strict | 高 | 高(同一サイト限定) | 中 |
SPAをサブドメイン構成で運用するならSameSite=Laxに妥協するか、CSRFトークンを併用する形になります。個人的には、リフレッシュトークンだけHttpOnly Cookieに入れ、アクセストークンはメモリ(JSの変数)に保持する構成が安全と運用性のバランスが良いと感じています。
jose(python-jose)からPyJWTに乗り換えるメリット
古いFastAPIチュートリアルではpython-joseが使われていましたが、現在の公式はPyJWTを採用しています。joseはJWE/JWSをまとめて扱える反面、最近の更新が緩慢で、依存のecdsaに脆弱性が見つかった経緯もあります。新規プロジェクトなら、より積極的にメンテされているPyJWT一択でいいと思います。
JWT認証で踏みやすい落とし穴
動かしてみると、ドキュメントだけでは見えない罠がいくつかあります。実際にハマったものを共有します。
algorithms指定を文字列で渡してしまう
PyJWT 2系ではjwt.decode(token, key, algorithms="HS256")のように文字列を渡しても動いてしまいます。ただし将来的にリスト必須になる動きがあるので、必ずリストで指定するクセをつけておくと安全です。
JWTにパスワードや個人情報を入れてしまう
JWTのペイロードはBase64URLエンコードされているだけで、暗号化ではありません。デコーダーに通せば誰でも中身を読めます。subにはユーザーIDなど公開しても問題ない情報のみを入れ、メールアドレスやロール詳細などはサーバー側でDBから引くのが鉄則です。
echo "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyLTAwMSIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20ifQ.xxx" | cut -d. -f2 | base64 -d 2>/dev/null
{"sub":"user-001","email":"alice@example.com"}
このように、署名検証なしでも中身は丸見えです。
サーバー時刻のずれでExpiredSignatureErrorが頻発する
マイクロサービス間でJWTをやり取りすると、サーバー間のNTP同期がずれてトークンが「未来から来た」扱いになることがあります。jwt.decodeにはleeway=10のような猶予秒数を指定できるので、5〜10秒の許容を入れると本番運用が安定します。
jwt.decode(token, SECRET_KEY, algorithms=["HS256"], leeway=10)
ログアウトを実装しようとすると詰む
JWTはステートレスが売りですが、その代償としてサーバー側で能動的に無効化できないという性質があります。即時ログアウトを実現するには、Redisにjti(トークンID)のブラックリストを持つ、またはバージョン番号をユーザーレコードに持たせて検証時に照合する、といった対応が必要です。「JWT=完全ステートレス」の信仰を捨てて、ハイブリッド構成を選ぶ判断が現場では現実的です。
関連記事として、Pydantic V2のバリデーション設計—よくあるミスと正しい書き方も参考になります。Tokenスキーマの定義やリクエストボディの検証で活きる内容です。また、データベースとつなぎ込む段階ではSQLAlchemy 2.0の新API—select()と型ヒント対応で変わった書き方が参考になるはずです。
まとめ
FastAPI 0.118系に合わせたJWT認証の組み立てを整理しました。要点はこのあたりです。
- FastAPI公式はpasslib → pwdlib(Argon2)に移行済み。Python 3.13対応も兼ねるので新規実装はpwdlib一択
- JWT発行・検証は
PyJWT 2.12.1で十分。algorithmsは必ずリストで指定する - アクセストークン(短命)とリフレッシュトークン(長命)を
typeクレームで明確に分ける - cookieに保存するならHttpOnly + SameSiteで、メモリ保持と組み合わせるとXSS/CSRF両方への耐性が出る
- JWTペイロードは暗号化されない。機密情報は入れない、サーバー側でDB引きする
- 即時ログアウトが必要ならRedisでjtiブラックリストを持つハイブリッド構成を選ぶ
passlibを長く使ってきたプロジェクトでも、両ハッシャー併用で段階移行できます。0.118系へのアップデートを機に、認証まわりを一気に近代化してしまうのが結果的に楽だと思います。

