カテゴリー
アーカイブ

10.20
2025

FastAPI の認証機能を試してみる(その3 ~リフレッシュトークン導入編~)

  • LINE

はじめに

前回までの記事 では、JWT アクセストークンを発行し、 API リクエストが認可されているかをチェックする処理を実装してきました。

簡単におさらいすると、次のようなフローになっています。


前回までの記事の実装では、
・アクセストークンの有効期限が切れるたびに再ログインしなければいけないのは不便
・アクセストークンの有効期限を長くすると不正アクセスの危険性が高まる
といった問題点を抱えていました。 そこで、今回の記事ではリフレッシュトークンを導入し、これらの問題を解消していきます。

FastAPI 自体の認証機能の話からは少しずれますが、API 開発をする上でユーザー体験の向上、セキュリティ強化の観点で重要な内容となります。

 

※本記事は FastAPI の学習・理解を目的としたものであり、そのまま本番利用することを前提としていない点にご留意ください。

 

リフレッシュトークンとは?

リフレッシュトークンを一言で表現すると、「新しいアクセストークンを発行するための、長期間有効な特別なトークン」です。アクセストークンが期限切れになったときに、パスワードを再入力せずに新しいアクセストークンを取得することを可能にします。

AI による例え話を紹介します。

・アクセストークン = 30分有効の「入館証」
・リフレッシュトークン = 7日間有効の「入館証再発行チケット」

入館証が期限切れになっても、再発行チケットがあれば受付で新しい入館証をもらえる。わざわざ本人確認(パスワード入力)をする必要はない。

有効期間の短いアクセストークンだけでは不便(→何度も本人確認をする必要がある)、有効期間の長いアクセストークンだけでは危険(→入館証は何度も出し入れする分紛失しやすく、紛失した時に不正利用される可能性が高い)という課題を、リフレッシュトークンで解決するイメージがなんとなくついたのではないでしょうか。

 

実装と動作確認

以下の手順で、リフレッシュトークンを導入していきます。

1. リフレッシュトークンストレージの追加
2. リフレッシュトークン生成・検証ロジックの実装
3. ログイン時のレスポンスにリフレッシュトークンを追加
4. アクセストークン再取得エンドポイントの追加

ステップ1. リフレッシュトークンストレージの追加

今回は仮実装のため、メモリ上にリフレッシュトークンのストレージを用意します。また、期限が切れたリフレッシュトークンを削除する処理は一旦考えません。

# database.py
# リフレッシュトークンストレージ(トークン -> ユーザー名のマッピング)
refresh_tokens_db: dict[str, str] = {}

def store_refresh_token(refresh_token: str, username: str) -> None:
    """リフレッシュトークンを保存する"""
    refresh_tokens_db[refresh_token] = username

def get_username_from_refresh_token(refresh_token: str) -> str | None:
    """リフレッシュトークンからユーザー名を取得する"""
    return refresh_tokens_db.get(refresh_token)

 

ステップ2. リフレッシュトークン生成・検証ロジックの実装

これまではアクセストークンだけを生成していましたが、同タイミングでリフレッシュトークンも生成するようにします。

# auth.py
REFRESH_TOKEN_EXPIRE_DAYS = 7
REFRESH_TOKEN_EXPIRE_SECONDS = (
    REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)

# アクセストークンだけを返していた create_access_token から変更
def create_tokens(username: str, password: str) -> tuple[str, str] | None:
    """ユーザー認証してアクセストークンとリフレッシュトークンを作成する

    Returns:
        tuple[str, str] | None: (access_token, refresh_token) または認証失敗時はNone
    """
    user = authenticate_user(username, password)
    if not user:
        return None

    # アクセストークンの生成
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_jwt_token(
        data={"sub": user.username, "type": "access"},
        expires_delta=access_token_expires,
    )

    # リフレッシュトークンの生成
    refresh_token_expires = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
    refresh_token = create_jwt_token(
        data={"sub": user.username, "type": "refresh"},
        expires_delta=refresh_token_expires,
    )

    # リフレッシュトークンを保存
    from database import store_refresh_token

    store_refresh_token(refresh_token, user.username)

    return (access_token, refresh_token)
# auth.py
def verify_refresh_token(refresh_token: str) -> str:
    """リフレッシュトークンを検証してユーザー名を返す(検証失敗時は例外発生)"""
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Invalid refresh token",
        headers={"WWW-Authenticate": "Bearer"},
    )

    try:
        # JWTトークンのデコード
        payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
        username = payload.get("sub")
        token_type = payload.get("type")

        # トークンタイプの検証
        if username is None or token_type != "refresh":
            raise credentials_exception

        # リフレッシュトークンがストレージに存在するか確認
        from database import get_username_from_refresh_token

        stored_username = get_username_from_refresh_token(refresh_token)

        if stored_username is None or stored_username != username:
            raise credentials_exception

        return username

    except jwt.InvalidTokenError:
        raise credentials_exception

 

ステップ3. ログイン時のレスポンスにリフレッシュトークンを追加

リフレッシュトークンは長期間有効であるため、盗まれると致命的です。XSSから守るためにセキュアな HttpOnly Cookie で保管することが重要です。

# routers/auth.py
@router.post("/token")
async def login(
    response: Response, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
) -> Token:
    """ログインしてトークンを取得する"""
    tokens = create_tokens(form_data.username, form_data.password)
    if not tokens:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    access_token, refresh_token = tokens

    # リフレッシュトークンをHttpOnly Cookieに保存
    response.set_cookie(
        key="refresh_token",
        value=refresh_token,
        httponly=True,  # JavaScriptからアクセス不可
        secure=False,  # 本番環境ではTrue(HTTPS必須)
        samesite="lax",  # CSRF対策
        max_age=REFRESH_TOKEN_EXPIRE_SECONDS,
    )

    # アクセストークンのみをレスポンスボディで返す
    return Token(access_token=access_token, token_type="bearer")

 

ステップ4. リフレッシュエンドポイントの追加

リフレッシュトークンを受け取って検証し、アクセストークンを生成するエンドポイントを追加します。

ここで、リフレッシュトークンは Cookie から取得していることに注意してください。

# routers/auth.py
@router.post("/refresh")
async def refresh_access_token(
    refresh_token: Annotated[str | None, Cookie()] = None,
) -> Token:
    """Cookieのリフレッシュトークンで新しいアクセストークンを取得する"""
    if not refresh_token:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Refresh token not found",
            headers={"WWW-Authenticate": "Bearer"},
        )

    try:
        access_token = create_new_access_token(refresh_token)
        return Token(access_token=access_token, token_type="bearer")
    except HTTPException:
        raise
    except Exception:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid refresh token",
            headers={"WWW-Authenticate": "Bearer"},
        )
# auth.py
def create_new_access_token(refresh_token: str) -> str:
    """リフレッシュトークンから新しいアクセストークンを生成する

    Returns:
        str: 新しいアクセストークン
    """
    # リフレッシュトークンの検証
    username = verify_refresh_token(refresh_token)

    # 新しいアクセストークンの生成
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_jwt_token(
        data={"sub": username, "type": "access"}, expires_delta=access_token_expires
    )

    return access_token

 

ここまででリフレッシュトークンの導入が完了しました。リフレッシュトークンの生成や検証を含めて再度シーケンス図にまとめると、以下のようになります。

※「トークンリフレッシュ(自動)」と記載していますが、実際はアクセストークンの有効期限が切れている場合にトークンリフレッシュ処理を自動で行うような処理をフロントエンド側で実装することになります。

 

では動作を見てみましょう。今回は CLI で確認します。

その前に、アクセストークンの有効期限が切れた時の動きを見るために有効期間を1分にしておきます。

# auth.py
ACCESS_TOKEN_EXPIRE_MINUTES = 1
# ログイン
$ curl -s -v -X POST "http://127.0.0.1:8000/auth/token" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "username=johndoe&password=secret"
*   Trying 127.0.0.1:8000...
* Connected to 127.0.0.1 (127.0.0.1) port 8000
> POST /auth/token HTTP/1.1
> Host: 127.0.0.1:8000
> User-Agent: curl/8.7.1
> Accept: */*
> Content-Type: application/x-www-form-urlencoded
> Content-Length: 32
> 
* upload completely sent off: 32 bytes
< HTTP/1.1 200 OK
< date: Fri, 17 Oct 2025 09:47:42 GMT
< server: uvicorn
< content-length: 189
< content-type: application/json
< set-cookie: refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2huZG9lIiwidHlwZSI6InJlZnJlc2giLCJleHAiOjE3NjEyOTkyNjJ9.JxetWRm2b3apLVXLqs9fOo8FLqJOty7ZHEeL2w-Kn1A; HttpOnly; Max-Age=604800; Path=/; SameSite=lax
< 
* Connection #0 to host 127.0.0.1 left intact
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2huZG9lIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc2MDY5NDUyMn0.WMeJZMDPYtwQE5HGvYMYRsDdvS_JC2jOeNrwPdb3jLw","token_type":"bearer"}

レスポンスボディ内のアクセストークンに加え、レスポンスヘッダーでリフレッシュトークンが返されていることを確認できました。

今回も jwt.io でリフレッシュトークンの中身を見てみます。

ペイロード部分にユーザー名(sub)、種別(type)=refresh、有効期限(exp)の情報が含まれていることが分かります。

操作の都合上、それぞれのトークンを変数に設定しておきます。

$ ACCESS_TOKEN="..."
$ REFRESH_TOKEN="..."

/auth/me を叩いて、ユーザー情報を取得できることを確認します。

# GET /auth/me
$ curl -s -X GET "http://127.0.0.1:8000/auth/me" \
    -H "Authorization: Bearer $ACCESS_TOKEN" | jq
{
  "detail": "Could not validate credentials"
}

記事を書いている間に有効期限が切れてしまったようです。リフレッシュのエンドポイントを叩いて、アクセストークンを再発行しましょう。

# POST /auth/refresh
curl -s -X POST "http://127.0.0.1:8000/auth/refresh" \
    -b "refresh_token=$REFRESH_TOKEN"
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJqb2huZG9lIiwidHlwZSI6ImFjY2VzcyIsImV4cCI6MTc2MDY5NTAyMX0.CRpaAmUz6dFye-K6okyKfEGUwO5yUXMbzgFKnHa3kmY","token_type":"bearer"}

リフレッシュトークンは Cookie として送っていることに注意してください。アクセストークンを再取得できたので、再度ユーザー情報を取得してみます。

$ ACCESS_TOKEN="..."
# GET /auth/me
$ curl -s -X GET "http://127.0.0.1:8000/auth/me" \
    -H "Authorization: Bearer $ACCESS_TOKEN" | jq
{
  "username": "johndoe",
  "email": "johndoe@example.com",
  "full_name": "John Doe",
  "disabled": false
}

再取得したアクセストークンでユーザー情報を取得することができました!

 

まとめ

今回は FastAPI の認証機能からは少し離れて、トークンリフレッシュの仕組みの話が中心でした。ここまで対応すると本番運用で使える形に少しずつ近づいてきた感じがしますが、実はまだ改善点があります。
「リフレッシュトークンストレージの追加」の部分で気になった方もいるかもしれませんが、「リフレッシュトークンが正規のものであることを検証できており、かつ、その中にユーザーIDが含まれているのに、なぜサーバー側でリフレッシュトークンとユーザーIDのマッピングを保持しておく必要があったのか?」という疑問が湧いてきます。その答えが「トークンの無効化(サーバー側での強制ログアウト)」です。次回の記事では、この辺りを深掘りできればと思います。

 

"全員が技術者" ベイストリームは横浜発のPythonのプロフェッショナル集団です。
積極採用中!尖ったPythonエンジニアへの第一歩はこちらから