開発

FastAPIのJWT認証|PyJWTで発行・検証・リフレッシュトークンをコードで実装

FastAPIのJWT認証|PyJWTで発行・検証・リフレッシュトークンをコードで実装

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分の例ですが、更新の仕組みを持つなら短くする方が安全です。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.13 テックブログ 宝くじ公式サイト不正アクセス:4,436名の対象確認とパスワード再設定、変更された仕組み
  2. 2026.11.13 テックブログ KARTEの不正アクセス:全機能停止で利用企業がタグ・配信・データ連携を確かめる手順
  3. 2026.11.14 テックブログ ヤマダデンキの不正アクセス:リフォーム管理システム最大350万件と、住所・見積を持つ営業記録の守り方
  4. 2026.10.21 テックブログ マネックス証券の不正アクセス:メールアドレス最大約113万件と本人確認画像1,592件の漏えいのおそれ、口座開設停止とPHP脆弱性との関係
  5. 2026.11.11 テックブログ Microsoft 2026年11月の月例更新:Outlookの悪用済みCVE-2026-124194と修正版の確認方法

RELATED POSTS 関連記事

目次