JWTリフレッシュトークン、「使用済みフラグ」だけでは攻撃を止められない理由

  • #認証認可
  • #JWT
  • #セキュリティ

リフレッシュトークンのローテーションは「実装した」だけでは穴がある

リフレッシュトークンをDBに保存して、使用済みにフラグを立てる——これだけで再利用攻撃を防げると思っていた時期が自分にもあった。実際にはこのアプローチだけでは防げないシナリオが複数あって、本番で踏んで初めて気づくことになる。


「使用済みフラグ」だけでは防げない攻撃シナリオ

まず具体的な攻撃の流れを整理する。

  1. 正規ユーザーがリフレッシュトークン RT-A を保持している
  2. 攻撃者が何らかの手段で RT-A を盗む(XSS、ログ漏洩、中間者など)
  3. 攻撃者が先に RT-A を使ってローテーションし、新しい RT-B を取得する
  4. DBの RT-A は使用済みになる
  5. 正規ユーザーが RT-A を使おうとすると「無効」と弾かれる

このとき、攻撃者は RT-B を持ったまま継続してアクセスできる。正規ユーザーはセッションが切れて「ログインしてください」という画面に戻るだけで、攻撃が成功していることには気づかない。

ここで重要なのは「使用済みトークンが来たら、そのファミリーごと全部失効させる」というロジックだ。Auth0が提唱するRTR(Refresh Token Rotation)の本質はこの点にある。


ファミリー管理を実装する

CREATE TABLE refresh_tokens (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    family_id   UUID NOT NULL,          -- ローテーション系譜を束ねるID
    token_hash  TEXT NOT NULL UNIQUE,
    user_id     UUID NOT NULL,
    device_id   TEXT,
    used_at     TIMESTAMPTZ,            -- NULLなら未使用
    revoked_at  TIMESTAMPTZ,
    expires_at  TIMESTAMPTZ NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

ローテーション時のロジックはこうなる。

def rotate_refresh_token(incoming_token: str, db: Session) -> tuple[str, str]:
    token_hash = sha256(incoming_token)
    record = db.query(RefreshToken).filter_by(token_hash=token_hash).first()

    if record is None:
        raise InvalidTokenError("token not found")

    # 使用済みトークンが来た = 再利用の疑い
    if record.used_at is not None:
        revoke_family(record.family_id, db)  # ファミリー全滅
        raise TokenReuseDetectedError("possible token theft detected")

    if record.revoked_at is not None or record.expires_at < now():
        raise InvalidTokenError("token revoked or expired")

    # 正常系: 旧トークンを使用済みにして新トークン発行
    record.used_at = now()
    new_token = generate_secure_token()
    db.add(RefreshToken(
        family_id=record.family_id,  # 同じファミリーを引き継ぐ
        token_hash=sha256(new_token),
        user_id=record.user_id,
        device_id=record.device_id,
        expires_at=now() + REFRESH_TOKEN_TTL,
    ))
    db.commit()

    new_access_token = issue_access_token(record.user_id)
    return new_access_token, new_token

revoke_family はそのファミリーに属する全レコードの revoked_at を更新するだけでいい。


競合状態という現実の罠

上のコードには実はまだ穴がある。モバイルアプリが背景で通信リトライするケース、もしくはタブを複数開いている状態でほぼ同時にリクエストが飛んだケースを想定してほしい。

used_at チェックと used_at 更新の間にもう1リクエストが割り込むと、どちらも「未使用」と判定してしまう。これは攻撃ではないのに TokenReuseDetectedError を誤って発火させてしまうか、逆に二重発行を許してしまうかのどちらかになる。

対策はDBレベルのロックで書く。

SELECT * FROM refresh_tokens
WHERE token_hash = $1
FOR UPDATE NOWAIT;

NOWAIT にしておくと、先にロックを取ったリクエストが処理中の場合、後発のリクエストは即座にエラーで返ってくる。アプリ側はこのエラーを受けたら少しだけ待ってリトライする。リトライ時には旧トークンで再度試みると今度は「使用済み」になっているので、クライアントはそのレスポンスを正常な「新トークンが発行された別の並行リクエストがある」状態として解釈し、その新トークンを使えばいい。

ただし「クライアントが旧トークンでリトライしたら失効済みとして弾く」が正しい動作なので、クライアント実装側との契約として「ローテーション成功時に返ってきた新トークンを必ず保存してから次のリクエストを投げる」を徹底する必要がある。ここはサーバーだけで完結できない部分だ。


複数デバイス運用で踏む失効ロジックの落とし穴

device_id を保持しているのは、デバイスごとにセッションを独立させるためだ。しかしファミリー全滅ロジックを入れた状態で「全デバイスのセッションを強制ログアウト」をユーザーに提供しようとすると、family_id ではなく user_id で全件 revoke しないといけない。ここを family_id 単位で実装していると、他デバイスのセッションが生き残る。

逆に「このデバイスだけログアウト」の場合は device_iduser_id の組み合わせで失効させる。ファミリーは概念としてはデバイスと1対1に近いが、厳密には「1回のログインセッション」と対応させておくほうが後々扱いやすい。ユーザーが同一デバイスで再ログインするたびに新しいファミリーを払い出す設計にしておくと、古いファミリーを残さずに済む。


運用で考慮しておくべきこと

再利用検知で TokenReuseDetectedError が発火したとき、ユーザーへの通知をどうするかは設計判断が必要だ。Slackや Eメールで「不審なアクセスがありました」と通知する実装を入れようとすると、正規ユーザーの並行リクエスト由来の誤検知でノイズが大量に出ることがある。

実務では「検知したらまずログに記録して、一定期間内の検知頻度が閾値を超えたら通知する」という段階的な対応にしている。単純な再利用検知をそのままアラートに繋ぐのは避けたほうがいい。

ファミリーテーブルはローテーションのたびにレコードが増えるので、TTLを過ぎた使用済みレコードの定期削除は必須だ。バッチで expires_at < NOW() - interval '7 days' を条件に削除するだけで十分で、これを忘れると静かにテーブルが膨らむ。