FastAPIでJWT認証を組むときに手が止まるのは、ライブラリの選び方と、トークンの検証をどこまで厳しく書くかの2点です。この記事では、FastAPI 0.162.7とPyJWT 2.15.1を使い、ログインでアクセストークンとリフレッシュトークンを発行し、OAuth2PasswordBearerとDependsで保護したルートを作るまでをコードで示します。exp・aud・issの検証、PyJWTが送出する例外の扱い、リフレッシュトークンのローテーションと再利用の検知も、手元で実行した結果つきで扱います。最後に示すのは、JWTを自前で発行せずIdPに任せるべき条件です。
まとめ:FastAPIでJWT認証を組むときのライブラリ選定と実装の結論
ライブラリはPyJWTとpwdlibを選びます。FastAPIの公式チュートリアルは0.111.1でpython-joseからPyJWTへ、0.118.0でpasslibからpwdlib(Argon2)へ書き換えられました。python-joseの最終リリースは2025年5月の3.5.0、passlibは2020年10月の1.7.4で止まっています。古い記事のコードを写すと、この2つを新しく入れることになります。
検証の結論は3つです。jwt.decodeにはalgorithmsを必ず渡し、audienceとissuerも照合します。PyJWTの例外はjwt.InvalidTokenErrorでまとめて受け、応答をすべて401に寄せる方針です。アクセス用とリフレッシュ用はtypeクレームで区別し、取り違えを拒否します。
リフレッシュトークンはjtiをサーバー側に保存し、使うたびに新しい1本と交換します。使用済みの1本が再び来たら漏えいとみなし、その利用者のリフレッシュトークンを全て失効させる処理です。外部のIdP(Auth0、Amazon Cognito、Microsoft Entra IDなど)でログインさせるなら、発行は書かずにJWKSでの検証だけを書きます。
FastAPI 0.162系とPyJWT 2.15系でJWT認証の検証環境を作る手順
最初に、版と依存ライブラリを固めます。JWTまわりは古い記事が多く、版の違いがそのまま挙動の違いになるためです。
Python 3.11以上でJWT認証用の依存ライブラリをそろえる手順
2026年11月14日時点のPyPIで、FastAPIの最新は0.162.7(2026年11月13公開)で、動作要件はPython 3.11以上です。PyJWTは2.15.1(2026年9月28日公開)、pwdlibは0.3.1が最新でした。この記事のコードはdatetime.UTCを使うので、3.11以上が前提です。FastAPI自体の起動手順はFastAPIの最短の起動手順とasync/defの使い分けで扱っています。
# 仮想環境を作り、FastAPI・PyJWT・pwdlib(Argon2)・pytestを入れる
python3 -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]==0.162.7" "pyjwt==2.15.1" "pwdlib[argon2]==0.3.1" pytest
# 署名用の鍵を作り、環境変数で渡す(コードやGitに書かない)
export JWT_SECRET_KEY="$(openssl rand -hex 32)"
fastapi dev main.py
fastapi[standard]には、ログインのフォームを読むのに要るpython-multipartが含まれます。RS256などの公開鍵署名を使う場合は、PyJWTをpyjwt[crypto]で入れてcryptographyを足します。
python-joseとpasslibからPyJWTとpwdlibへ移った公式の経緯
FastAPIのリリースノートをたどると、JWTの説明は一度入れ替わっています。0.59.0(2020年7月)でPyJWTからpython-joseに替わり、0.111.1(2024年7月)でPyJWTへ戻りました。パスワードのハッシュは0.118.0(2025年9月)でpasslibからpwdlibとArgon2に替わっています。
日本語の解説記事には、from jose import jwtとCryptContext(schemes=["bcrypt"])の組み合わせがまだ多く残っています。新しく書くコードでそれを選ぶ理由はありません。既存システムでpython-joseを使っている場合も、jwt.encodeとjwt.decodeの引数はほぼ同じ形なので、置き換えは例外クラスの名前をJWTErrorからInvalidTokenErrorに直す作業が中心になります。
tokens.pyでアクセストークンを発行しPyJWTで検証するコード
トークンの発行と検証は、FastAPIに依存しないtokens.pyに分けます。HTTPを通さずにテストでき、後でIdPの検証に差し替えるときも触るのはこのファイルだけで済みます。
# tokens.py
import os
import uuid
from datetime import UTC, datetime, timedelta
import jwt
# openssl rand -hex 32 で作った値を環境変数で渡す(コードに書かない)
SECRET_KEY = os.environ["JWT_SECRET_KEY"]
ALGORITHM = "HS256"
ISSUER = "https://api.example.jp"
AUDIENCE = "example-api"
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=14)
# 有効なリフレッシュトークンの jti と sub の対応。本番では Redis やDBのテーブルに置く
REFRESH_STORE: dict[str, str] = {}
def create_token(sub: str, token_type: str, ttl: timedelta) -> tuple[str, str]:
now = datetime.now(UTC)
jti = uuid.uuid4().hex
payload = {
"sub": sub,
"type": token_type,
"jti": jti,
"iat": now,
"exp": now + ttl,
"iss": ISSUER,
"aud": AUDIENCE,
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM), jti
def decode_token(token: str, expected_type: str) -> dict:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=[ALGORITHM],
audience=AUDIENCE,
issuer=ISSUER,
leeway=30,
options={"require": ["exp", "iat", "sub", "jti", "type"]},
)
if payload["type"] != expected_type:
raise jwt.InvalidTokenError("unexpected token type")
return payload
def issue_pair(sub: str) -> dict:
access, _ = create_token(sub, "access", ACCESS_TTL)
refresh, jti = create_token(sub, "refresh", REFRESH_TTL)
REFRESH_STORE[jti] = sub
return {"access_token": access, "refresh_token": refresh, "token_type": "bearer"}
def rotate(refresh_token: str) -> dict:
payload = decode_token(refresh_token, "refresh")
if REFRESH_STORE.pop(payload["jti"], None) is None:
# 使用済みのリフレッシュトークンが再提示された。漏えいを疑い、本人の分を全て失効させる
for jti in [k for k, v in REFRESH_STORE.items() if v == payload["sub"]]:
del REFRESH_STORE[jti]
raise jwt.InvalidTokenError("refresh token reused")
return issue_pair(payload["sub"])
sub・exp・iss・aud・jtiを入れたペイロードと有効期限15分の設計
クレームの名前と意味はRFC 7519で決まっています。subに入れる値は、利用者を一意に指す文字列です。公式チュートリアルも、subはアプリ全体で一意な文字列にし、ID空間が重なるならusername:johndoeのように接頭辞を付けるよう書いています。管理者と一般利用者を別テーブルで持つ場合はadmin:1とuser:1のように分け、同じ番号を取り違えないようにします。
PyJWTはdatetimeを渡せばUNIX秒に変換して書き込みます。アクセストークンを15分にしたのは、署名の検証だけで通すJWTは期限が来るまで止められないからです。止められない時間を短くし、その代わりにリフレッシュトークンで更新させます。寿命の決め方の考え方はリフレッシュトークンの寿命設計とローテーションの解説にまとめています。
decodeのalgorithms指定と発行者・受信者の照合が必要な理由
algorithmsは省略できません。PyJWT 2.15.1で省くとDecodeErrorになり、「It is required that you pass in a value for the “algorithms” argument」と返ります。ヘッダーのalgをそのまま信じると、alg: noneの署名なしトークンを通す事故につながるためで、RFC 8725の3.1節も、使うアルゴリズムを受け手が決めて照合するよう求めています。手元でalg: noneのトークンを作って渡すと、InvalidAlgorithmErrorで拒否されました。
audienceとissuerの照合(RFC 8725の3.8節と3.9節)は、同じ鍵を持つ別のサービスが出したトークンを受け付けないための設定です。ここで注意したいのは、次の1点です。aud入りのトークンをaudience未指定で検証すると、PyJWTはInvalidAudienceErrorで拒否します。発行側だけaudを足すと、検証側の全エンドポイントが401を返し始める原因になるため、発行側だけの変更は禁物です。発行と検証の設定は同時に変えます。
PyJWT 2.15.1で実測した例外の種類と401に寄せるときの書き分け
PyJWTのUsage Examplesに載っている例外を、2.15.1で実際に起こして確かめました。下の表の例外は、どれもjwt.InvalidTokenErrorの子クラスです。
| 起こした状況 | 送出された例外 |
|---|---|
| 期限切れ(exp超過) | ExpiredSignatureError |
| 鍵が違う | InvalidSignatureError |
| audの不一致・未指定 | InvalidAudienceError |
| issの不一致 | InvalidIssuerError |
| requireのクレーム欠落 | MissingRequiredClaimError |
| subが整数 | InvalidSubjectError |
| alg=none | InvalidAlgorithmError |
応答はすべて401にそろえ、どの検査で落ちたかは本文に書きません。期限切れだけをクライアントに知らせたい場合は、ExpiredSignatureErrorを先にexceptしてWWW-Authenticateにerror="invalid_token"を付けます。RFC 6750の3.1節が定める形で、フロントエンドはこれを見て更新に進める仕組みです。subを整数で入れると検証側で落ちる点は見落としやすく、DBの主キーをそのまま入れるときはstr()で文字列にします。
OAuth2PasswordBearerとDependsでログインと保護ルートを実装する
次に、tokens.pyをFastAPIにつなぎます。ログイン、トークンの更新、保護したルートの3本です。
# main.py
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pwdlib import PasswordHash
from pydantic import BaseModel
import tokens
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("dummy-password")
# 検証用の利用者表。本番ではDBから引く
USERS = {"alice": {"id": "user:1", "hashed_password": password_hash.hash("wonderland")}}
def credentials_error() -> HTTPException:
return HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
class RefreshRequest(BaseModel):
refresh_token: str
@app.post("/token")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
user = USERS.get(form.username)
if user is None:
password_hash.verify(form.password, DUMMY_HASH) # 応答時間をそろえる
raise credentials_error()
if not password_hash.verify(form.password, user["hashed_password"]):
raise credentials_error()
return tokens.issue_pair(user["id"])
@app.post("/token/refresh")
def refresh(body: RefreshRequest):
try:
return tokens.rotate(body.refresh_token)
except jwt.InvalidTokenError:
raise credentials_error() from None
def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> str:
try:
payload = tokens.decode_token(token, "access")
except jwt.InvalidTokenError:
raise credentials_error() from None
return payload["sub"]
CurrentUser = Annotated[str, Depends(get_current_user)]
@app.get("/users/me")
def read_me(user_id: CurrentUser):
return {"user_id": user_id}
/tokenでログインフォームを受けてトークンを発行する処理
OAuth2PasswordRequestFormは、OAuth2のパスワードフローが決めるusernameとpasswordをフォーム形式で受け取ります。JSONではない点に注意してください。フロントエンドからfetchでJSONを送ると422になります。
存在しない利用者名でもDUMMY_HASHに対してパスワードの照合を1回走らせているのは、公式チュートリアルにある対策です。照合を省くと、存在しない名前だけ応答が速く返り、応答時間から登録済みの利用者名を割り出されます。Argon2の照合は意図的に重く作られているので、この差は外から測れる大きさになります。
get_current_userで署名検証してユーザーを引く依存関数の書き方
oauth2_schemeはAuthorization: Bearer <トークン>ヘッダーから文字列を取り出すだけで、中身は見ません。FastAPI 0.162.7のソース(fastapi/security/oauth2.py)では、ヘッダーが無いかBearer以外なら、detailが「Not authenticated」の401をWWW-Authenticate: Bearerつきで返します。0.122.0より前のHTTPBearerなどは、この場面で403を返していました。古い版でテストを書いていた場合は期待値を見直します。
署名と期限の検証はget_current_userの中でtokens.decode_tokenに任せます。CurrentUserを引数に書いたルートだけが守られ、Dependsの連鎖としてoauth2_schemeから順に解決される仕組みです。連鎖の仕組みやdependency_overridesでの差し替えはFastAPIのDependsの使い方で、ルーター単位で一括して掛ける方法はAPIRouterでの依存の掛け方で扱っています。ヘッダーの書式そのものはBearer Tokenの書き方とJWTとの違いが詳しく説明しています。
Swagger UIのAuthorizeボタンとcurlで401と200を確かめる手順
OAuth2PasswordBearer(tokenUrl="token")を使うと、/docsの右上にAuthorizeボタンが出ます。利用者名とパスワードを入れると/tokenが呼ばれ、以後の試行にトークンが自動で付きます。本番で/docsを隠す設定はFastAPIのSwagger UIを本番で隠す手順を参照してください。端末から試す場合は次のとおりです。
# ログイン(フォーム形式で送る)
curl -s -X POST http://127.0.0.1:8000/token -d "username=alice&password=wonderland"
# 返った access_token を付けて呼ぶ(200)
curl -s http://127.0.0.1:8000/users/me -H "Authorization: Bearer 取得したaccess_token"
# トークンなしは 401 {"detail":"Not authenticated"}
curl -i http://127.0.0.1:8000/users/me
リフレッシュトークンのローテーションと再利用検知をjtiで実装する
アクセストークンを15分で切る以上、更新の仕組みが要ります。ここで手を抜くと、14日有効なリフレッシュトークンが盗まれたときに止める手段がありません。
typeクレームでアクセス用とリフレッシュ用の取り違えを防ぐ書き方
アクセス用とリフレッシュ用を同じ鍵・同じクレームで作ると、リフレッシュトークンをAuthorizationヘッダーに入れても通ってしまいます。14日有効なトークンがAPIの鍵として使える状態です。decode_tokenがexpected_typeを照合しているのはこのためで、RFC 8725の3.11節(明示的な型付け)と3.12節(種類ごとに排他な検証規則)が求める対策にあたります。
より強く分けるなら、リフレッシュ用だけaudをexample-api-refreshのように変えるか、鍵を別にします。どちらにしても、検証側が「このルートではどの種類を受け付けるか」を必ず指定する点は同じです。
使用済みjtiの再提示で本人の全リフレッシュトークンを失効させる
rotateは、受け取ったリフレッシュトークンのjtiを保存先から取り除き、新しい組を発行します。同じトークンは2回使えません。使用済みのjtiが来たら、正規の利用者と攻撃者のどちらかが古い1本を持っていることになります。どちらが本物かはサーバーから区別できないので、その利用者のリフレッシュトークンを全て失効させ、再ログインさせます。
サンプルは保存先を辞書にしていますが、複数のワーカーやサーバーで動かすなら共有の保存先が要ります。Redisならjtiをキーにして有効期限14日で置き、GETDELで取り出しと削除を1回で行うと、同時に届いた2回の更新を両方通す競合を防げます。ログアウトは、この保存先からその利用者のjtiを消す処理です。
test_tokens.pyで期限切れ・改ざん・再利用の拒否を確かめるテスト
tokens.pyはFastAPIに依存しないので、HTTPを通さずにテストできます。期限の境目、種類の取り違え、再利用の検知、alg: noneの拒否を確かめます。
# test_tokens.py(JWT_SECRET_KEY を設定してから pytest で実行する)
from datetime import timedelta
import jwt
import pytest
import tokens
def test_refresh_cannot_be_used_as_access():
pair = tokens.issue_pair("user:42")
with pytest.raises(jwt.InvalidTokenError):
tokens.decode_token(pair["refresh_token"], "access")
def test_expired_and_leeway():
expired, _ = tokens.create_token("user:42", "access", timedelta(seconds=-60))
with pytest.raises(jwt.ExpiredSignatureError):
tokens.decode_token(expired, "access")
recent, _ = tokens.create_token("user:42", "access", timedelta(seconds=-10))
assert tokens.decode_token(recent, "access")["sub"] == "user:42" # leeway 30秒以内
def test_reuse_revokes_all():
first = tokens.issue_pair("user:7")
second = tokens.rotate(first["refresh_token"])
with pytest.raises(jwt.InvalidTokenError):
tokens.rotate(first["refresh_token"]) # 使用済みの再提示
with pytest.raises(jwt.InvalidTokenError):
tokens.rotate(second["refresh_token"]) # 巻き添えで失効している
def test_alg_none_rejected():
forged = jwt.encode({"sub": "admin", "type": "access"}, None, algorithm="none")
with pytest.raises(jwt.InvalidAlgorithmError):
tokens.decode_token(forged, "access")
同じ内容をPyJWT 2.15.1で実行し、4本の検査がすべて期待どおりに動くことを確認しました。leeway=30は、発行したサーバーと検証するサーバーの時計のずれを吸収するための幅です。10秒前に切れたトークンは通り、60秒前に切れたトークンは拒否されます。
自前のJWT発行を採用する条件とIdPのJWKS検証に任せる判断基準
ここまでのコードは、利用者とパスワードを自分のAPIが持つ場合の形です。すべてのFastAPIアプリがこれを書くべきではありません。
HS256の共有鍵で足りる構成とRS256へ切り替えるべき構成の境目
HS256は、発行と検証に同じ鍵を使います。発行するのも検証するのも同じFastAPIアプリ1つなら、HS256で対応できる構成です。鍵はopenssl rand -hex 32で32バイト以上にします。PyJWT 2.15.1は6バイトの鍵"secret"で署名するとInsecureKeyLengthWarningを出し、32バイト未満を推奨外と警告しました(RFC 8725の3.5節)。
検証するサービスが2つ以上になったら、RS256かES256に切り替えます。共有鍵のままだと、検証だけしたいサービスにも発行できる鍵を配ることになり、どこか1か所から漏れれば全体のトークンを偽造されます。公開鍵署名なら、検証側には公開鍵しか渡りません。
Auth0やCognitoを使う場合にPyJWKClientで検証だけを書く構成
ログイン画面、パスワードの保管、多要素認証、パスワードの再設定までを自前で持つのは、JWTの発行より重い仕事です。会員数が数百を超える、社内のMicrosoft Entra IDでSSOしたい、多要素認証を求められている、のどれかに当てはまるなら、IdPに任せてFastAPIは検証だけを書きます。この場合/tokenもtokens.pyの発行部分も要りません。
検証は、PyJWTのPyJWKClientでIdPが公開するJWKSのURLから公開鍵を取り、decodeに渡す形になります(PyJWTのAPI Reference)。algorithms・audience・issuerを指定する規則は自前の場合と同じです。トークンを即時に失効させたい要件があるなら、署名の検証だけでは足りないので、トークンイントロスペクションとJWT方式の使い分けで問い合わせ型を検討します。
ブラウザでのトークン保存先とログアウトを即時に効かせたい場面
SPAからこのAPIを呼ぶとき、リフレッシュトークンをlocalStorageに置くのは避けます。XSSが1つでもあれば、14日有効なトークンがそのまま持ち出される危険があるためです。リフレッシュトークンはHttpOnly・Secure・SameSiteを付けたCookieで受け渡し、アクセストークンの保存先はメモリだけにする構成です。Cookieを使うとCSRF対策とCORSの設定が要るので、React×FastAPI連携のCORS設定と合わせて決めます。
「権限を外したら即座にAPIを止めたい」「管理画面から特定の端末だけをログアウトさせたい」という要件が出た時点で、署名だけで完結するJWTの利点は薄れます。サーバー側のセッションに戻すか、IdPに寄せる方が素直です。既存システムの認証をどちらに寄せるか、SSOや多要素認証をどう組み込むかから相談したい場合は、認証基盤・ID管理システム開発で受け付けています。
よくある質問
FastAPIでJWT認証を実装するときによく出る質問に、2026年11月時点の版で答えます。
python-joseで書いた既存のコードはすぐに移行すべきですか?
動いているなら急ぐ必要はありませんが、次に認証まわりを触るときに移します。python-joseは2025年5月の3.5.0以降リリースが無く、FastAPIの公式チュートリアルも0.111.1でPyJWTへ戻っています。移行はfrom jose import jwtをimport jwtに、例外のJWTErrorをjwt.InvalidTokenErrorに替えるのが中心です。decodeでaudienceを渡していなかった場合、PyJWTではaud入りのトークンが拒否されるので、検証の設定もあわせて見直します。
JWTの中身は暗号化されていますか?
暗号化されていません。署名は改ざんを検出するだけで、ペイロードはBase64URLで符号化されているだけなので誰でも読めます。メールアドレスや権限の一覧など、クライアントに見せたくない値は入れません。subには内部IDを入れ、詳しい情報はAPI側でDBから引きます。JWTの構造と署名の仕組みはJWTとは?構造・署名検証の仕組みの解説で説明しています。
WebSocketのエンドポイントでもJWTで認証できますか?
できます。ブラウザのWebSocket APIは任意のヘッダーを付けられないため、クエリパラメータか最初のメッセージでトークンを受け取り、accept()の前にtokens.decode_tokenで検証します。FastAPI 0.144.0では、セキュリティ用の依存がWebSocketにも対応しました。クエリに載せたトークンはアクセスログに残るので、寿命を短くするか、接続専用の短命トークンを別に発行します。
ロールや権限による認可はJWTにどう載せればよいですか?
小さなアプリならscopeクレームに"items:read items:write"のような空白区切りの文字列を入れ、FastAPIのSecurityとSecurityScopesで照合します。手順の参照先は公式のOAuth2 scopesです。権限を頻繁に変える業務システムでは、トークンにはsubだけを載せ、権限はリクエストごとにDBから引く方が、変更が15分待たずに効きます。
アクセストークンの有効期限は何分にすればよいですか?
5〜15分が目安です。署名の検証だけで通すJWTは期限まで止められないので、権限の剥奪から反映までの最大の遅れがこの長さになります。リフレッシュトークンと組み合わせれば、利用者が再ログインを求められるのはリフレッシュトークンの期限(この記事では14日)が切れたときだけです。公式チュートリアルは30分の例ですが、更新の仕組みを持つなら短くする方が安全です。
関連記事
- JWTとは?構造・署名検証の仕組みとセッション・OAuth/OIDCとの違いを実装視点で解説:この記事のコードが前提にしているJWTの仕組みです。
- リフレッシュトークンとは?トークンローテーションの仕組みと更新フロー・寿命設計を実装者向けに解説:ローテーションと寿命設計の考え方です。
- FastAPIのDependsの使い方|依存性注入・yield・テストの差し替えをコードで解説:get_current_userを組み立てる依存性注入の仕組みです。
- FastAPIとは?最短の起動手順とasync/defの使い分け、0.162の変更点:FastAPI単体の入門と起動手順です。
- Bearer Token(ベアラートークン)とは?読み方・Authorization: Bearerの書き方とJWTとの違い:Authorizationヘッダーの書式です。