【実務・中級編】 JWTのjti(JWT ID)クレームを用いたリプレイ攻撃の完全防御 – 暗号理論・認証基盤 & エンドポイントセキュリティ防御ガイド

おい、少し手を止めてこっちを向いてくれ。

今朝、ウチの監視基盤からアラートが上がった。とある顧客向けのAPIエンドポイントに対して、認証済みのリクエストをそのままコピーして何度も叩き込む、典型的なリプレイ攻撃の痕跡だ。幸い、今回のターゲットになったエンドポイントは冪等性(Idempotency)が担保されていたり、ステートレスなデータ参照だけだったりで実害は免れたが……もしこれが「決済トランザクション」や「パスワードリセットの完了」だったらどうなっていたと思う?冷や汗ものだな。

多くの開発者は、「JWT(JSON Web Token)を使っているから安全だ」と勘違いしている。署名(Signature)が検証されていれば、中身が改ざんされていないことは保証される。それはその通りだ。だがな、「一度正規に発行されたトークンを、悪意ある第三者が盗聴し、有効期限内(exp内)に何回も使い回す攻撃」に対して、署名だけでは1バイトも身を守ることはできない。

今回は、このJWTのリプレイ攻撃を完全に粉砕するためのキーストーン、jti(JWT ID)クレームを用いたステートフルな無効化メカニズムについて、現場の泥臭い実装ノウハウを含めて徹底的に解説しよう。教科書的な「JWTとは何か」なんて話はスキップだ。さっそく中身に入ろうか。

—

なぜJWTの署名検証だけではリプレイ攻撃を防げないのか?

まず、敵の視点に立ってみよう。攻撃者はログイン時のレスポンスや、API通信のAuthorizationヘッダーからあなたのアプリケーションが発行したJWTをインターセプトする。

通常、JWTの構造はこうだ。
Header . Payload . Signature

Payloadには以下のような標準クレームが含まれている。

{
  "sub": "user_12345",
  "name": "Taro Security",
  "iat": 1717161600,
  "exp": 1717165200
}

ここで、攻撃者は exp(有効期限)が切れるまでの間、このトークンを何度でもリクエストヘッダーに付与して送りつけることができる。サーバー側のミドルウェアが HS256 や RS256 で署名を検証したとき、秘密鍵や公開鍵のペアが一致し、現在時刻が exp を超えていなければ、サーバーは「はい、お戻りなさいませ。正規のユーザーですね」と処理を続行してしまう。

これが、ステートレスなJWTの最大の弱点、すなわち「発行されたトークン自体が持つ有効性(Validity)と、それが今まさに消費されたかどうかの状態(State)が切り離されている」という矛盾だ。

この矛盾を断ち切るために用意されたのが、RFC 7519で定義されている jti(JWT ID)クレームである。

—

jti(JWT ID)クレームのメカニズム

jti とは、そのJWT一粒一粒に割り当てられる「世界で一意な識別子(UUIDなど)」のことだ。

防御のアーキテクチャは極めてシンプルかつ堅牢だ。

1. 発行時: サーバーはJWTを生成する際、ランダムかつユニークな jti(例: UUID v4)をPayloadに埋め込む。
2. 検証時: サーバーは通常の署名検証および exp のチェックに加え、「この jti が過去に Redis などの高速なストレージですでに使われた形跡がないか」をルックアップする。
3. 消費と記録: 未使用であれば、その jti をストレージに書き込み(同時に、元のJWTの exp と同期したTTLを設定する)、リクエストの処理を許可する。
4. 拒否: すでにストレージに jti が存在していれば、それは「使い回し(リプレイ攻撃)」とみなし、即座に 401 Unauthorized または 403 Forbidden で弾き返す。

「おいおい、JWTなのにステートレス性を捨てるのか?」という声が聞こえてきそうだな。その通り。厳密なリプレイ防止を行うためには、消費済みの jti を記録するためのステート(状態)がサーバー側に必要になる。だが、ユーザーのセッション情報をすべてデータベースに保持する「完全なステートフル」とは違う。必要なのは「一度使われたトークンのIDの寿命付きブラックリスト / 使用済みキャッシュ」だけだ。これなら Redis などのインメモリデータストアを使えば、ミリ秒以下のレイテンシで処理できる。

—

実装ハンズオン:Python (FastAPI + Redis) による完全防御コード

口で言うだけなら誰でもできる。実際のプロダクションコードでどう実装するのか、Python(FastAPI)と Redis を使ったサンプルを見せよう。

まずは、必要なライブラリが入っている環境を前提とする(pip install fastapi uvicorn pyjwt redis)。

以下のコードは、JWTの検証と jti の一意性チェックを同時に行うセキュリティミドルウェア(依存性注入)の実装だ。

import time
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
import redis

app = FastAPI()

# Redisクライアントの初期化(本番環境では接続プールやクラスター構成を考慮すること)
# ここでは簡易的にローカルのRedisに接続
redis_client = redis.Redis(host='localhost', port=6379, db=0)

# 秘密鍵(本番では環境変数やAWS Secrets Manager等から安全に取得すること)
SECRET_KEY = "super-secret-key-that-should-never-be-hardcoded"
ALGORITHM = "HS256"

security = HTTPBearer()

def verify_and_consume_jwt(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
    token = credentials.credentials
    
    try:
        # 1. 署名と有効期限(exp)の標準的な検証
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.ExpiredSignatureError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="トークの有効期限が切れています。"
        )
    except jwt.InvalidTokenError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="無効なトークンです。"
        )
    
    # 2. jtiクレームの存在確認(発行規約としてjtiを必須にする)
    jti = payload.get("jti")
    if not jti:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="セキュリティポリシー違反: jtiクレームが含まれていません。"
        )
    
    # 3. Redisを使った原子的な(Atomic)使い回しチェック
    # SETNX (Set if Not Exists) を利用して、キーが存在しない場合のみセットする
    # 有効期限(TTL)はJWT自体の残り有効期限に合わせるか、少し余裕を持たせる
    now = int(time.time())
    exp = payload.get("exp", now)
    ttl = exp - now
    
    if ttl <= 0:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="トークンの有効期限が切れています。"
        )
    
    # Redisのキー設計: "jwt:used:<jti>"
    redis_key = f"jwt:used:{jti}"
    
    # setnxはキーが既に存在していればFalseを返す
    # 同時にEXオプションでトークンの有効期限切れとともに自動消滅させる(メモリリーク防止)
    is_new = redis_client.set(redis_key, "1", ex=ttl, nx=True)
    
    if not is_new:
        # 4. すでに使われている場合はリプレイ攻撃と判定し、アラートログを仕込む
        # ※実運用ではここでSIEMやセキュリティ監視基盤にログを飛ばすこと
        print(f"[SECURITY ALERT] リプレイ攻撃の検知! 該当jti: {jti}")
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="このトークンはすでに使用されています(リプレイ攻撃の検出)。"
        )
        
    return payload

# 保護されたエンドポイントの例
@app.post("/api/v1/transfer")
def secure_transfer(payload: dict = Depends(verify_and_consume_jwt)):
    # ここにクリティカルなビジネスロジック(送金処理など)を記述
    user_id = payload.get("sub")
    return {
        "status": "success",
        "message": f"ユーザー {user_id} のトランザクションが正常に処理されました。"
    }

このコードのポイントをエンジニアの視点でいくつか解説しておこう。

1. set(..., ex=ttl, nx=True) の原子性:
マルチスレッドやマルチプロセス、あるいはロードバランサー配下で複数のアプリサーバーが同時に動いている環境で、「存在確認してから書き込む」を別々に行うと競合状態(Race Condition)が発生し、すり抜けが起きてしまう。Redisの SETNX(Pythonの redis-py では set(..., nx=True))を使うことで、「確認と書き込みをアトミック(不可分)に行う」ことができる。これによって分散環境でも絶対に二重通しを許さない。
2. TTLによる自動クレンジング:
消費された jti を永久に保持していたら、RedisのメモリがパンクしてDoS状態に陥る。JWTの exp(有効期限)までの秒数を算出して ex パラメータに渡し、トークンが自然消滅するタイミングと同時にRedis上のキーも自動消滅(TTL)させる設計が必須だ。

—

現場でありがちな「落とし穴」とセキュリティ上の注意点

この実装を導入するにあたって、現場のエンジニアがハマりがちな罠がいくつかある。私の失敗談や、他のチームのインシデントレビューから得た知見をシェアしよう。

1. すべてのAPIエンドポイントに適用すべきか?

答えは「NO」だ。
例えば、単なるユーザーのプロフィール画像や設定情報の「参照(GETリクエスト)」に対して、毎回 jti の消費(書き込み)を行うと、Redisへの書き込み負荷が跳ね上がり、インフラストラクチャのボトルネックになる。
jti によるリプレイ防御を厳密に強制すべきなのは、「データの改変、送金、決済、状態遷移(ステータス変更)」を伴うPOST、PUT、DELETEメソッドのクリティカルなエンドポイントだ。 参照系の冪等なリクエストには従来の署名検証と exp チェックのみで十分なケースが多い。リスクアセスメントを間違えないように。

2. クライアント側(SPAやモバイルアプリ)のリトライ挙動

ネットワークの瞬断などにより、クライアント側が「リクエストを送ったがレスポンスを受け取る前にタイムアウトした」と勘違いして、全く同じリクエストを自動リトライすることがある。
もしフロントエンド側が同じJWT(同じ jti)のまま自動リトライを実装していると、2回目のリトライはサーバー側で「リプレイ攻撃」と判定され、エラーになってしまう。
これを防ぐためには、「リトライ時は必ず新しいJWTを再発行(リフレッシュ)してリトライする」か、あるいはAPIゲートウェイ層でネットワークエラー時の冪等性キー(Idempotency-Keyヘッダーなど)を別途ハンドリングする設計にする必要がある。このあたりはフロントエンドのアーキテクトともしっかりすり合わせておくこと。

—

まとめ

セキュリティは「銀の弾丸」を探すゲームではない。JWTという極めて便利で強力な技術であっても、その特性を正しく理解し、足りないピース(今回の場合はステートフルな消費管理)を適切なレイヤー(Redis等を用いた jti の一意性担保)で補うことによって初めて、本番環境に耐えうる堅牢なシステムが完成する。

「動けばいいや」で作られたコードは、いつか必ず悪意ある攻撃者に踏み破られる。今日紹介した実装パターンと設計思想を、君たちのチームの次期システム設計やコードレビューにぜひ取り入れてみてほしい。

さて、コーヒーブレイクはここまでだ。手を動かして、コードをセキュアにアップデートしようか。何か不明点があればいつでも私のところに来るといい。

コメント

タイトルとURLをコピーしました