【実務・中級編】 JWTのクレーム検証における ‘iat’ と ‘nbf’ の活用 – 暗号理論・認証基盤 & エンドポイントセキュリティ防御ガイド

「expだけ検証して安心」が引き起こす致命的インシデント

開発現場のコードレビューで、私はよくこんなJWT(JSON Web Token)検証コードを目にします。

「署名が正しくて、exp(有効期限)を切れていないから、このトークンは安全です」

……本当にそうでしょうか?

現場で数々のセキュリティインシデントを対応してきた立場からハッキリ言わせてもらうと、exp だけの検証は、攻撃者に「どうぞ悪用してください」と言っているようなものです。

攻撃者は、盗み出した古いトークンを使い回したり、システム間の微小な「時刻のズレ(クロックスキュー)」を突いたり、あるいはパスワード変更前に発行された古いセッションを悪用してアクセスを継続します。

本稿では、共通鍵・公開鍵暗号の基盤上で動くJWTにおいて、見落とされがちなiat(Issued At: 発行時刻)とnbf(Not Before: 有効開始時刻)の概念と、攻撃者が狙う盲点、そしてそれらを完全に防ぎきる「本番環境レベルのセキュアな実装」を徹底解説します。

—

なぜ exp だけでは防げないのか?攻撃者が突く3つの盲点

まずは、JWTの基本クレーム(Payloadに含まれるデータ)の役割を再整理しましょう。

  • exp (Expiration Time): トークンの死亡時刻(これ以降は無効)
  • iat (Issued At): トークンの誕生時刻(いつ発行されたか)
  • nbf (Not Before): トークンの覚醒時刻(これ以前は無効)

暗号署名(RS256やHS256など)によってトークンの改ざんは防げます。しかし、「改ざんされていない正しいトークンだが、今使うべきではない」という状況を、exp 単体で見抜くことは不可能です。

攻撃者が狙う典型的な3つのシナリオを見てみましょう。

攻撃シナリオ1:パスワード変更・権限剥奪の「タイムトラベル回避」

ユーザーがアカウントの乗っ取りに気付き、14時00分に「パスワード変更」と「全デバイスからログアウト」を実行したとします。

もしサーバー側が exp(例: 23時59分まで有効)しかチェックしていなければどうなるでしょうか?
攻撃者が13時00分(パスワード変更前)に盗み出していたトークンは、14時00分以降も23時59分まで「署名も正しく、有効期限内」としてサーバーに受け入れ続けられてしまいます。

ここで iat の検証が必須になります。DBやキャッシュ(Redis等)に保存された「ユーザーの最終セキュリティイベント時刻(password_changed_at)」とJWTの iat を照合し、「iat < password_changed_at ならば即座に拒否する」というロジックがない限り、セッションの強制切断は実現できません。

攻撃シナリオ2:事前発行トークンのフライング実行

特定の時刻(例: 明日の 09:00)までアクセスを許可したくない限定APIや、マルチステップの決済ワークフローなどで、あらかじめトークンを発行しておくケースがあります。

この時、nbf(Not Before)が正しく検証されていないと、攻撃者は発行されたトークンを入手した瞬間にリクエストを送り、本来アクセスできない時間外に処理を成功させてしまいます。

攻撃シナリオ3:分散システムにおける「クロックスキュー(時刻ズレ)」の悪用

マイクロサービス構成において、トークンを発行する「認可サーバー」と、それを検証する「APIサーバー」の時刻が数秒〜数分ズレていることは日常茶飯事です。

APIサーバーの時計が遅れている場合、未来の iat や nbf を持つトークンが流れてくることになります。これを厳密すぎる(許容誤差ゼロの)コードで検証すると正常なユーザーが弾かれ、逆に甘すぎるロジックを組むと、クロックスキューの隙間を狙った「トークンのフライング再利用(リプレイ)」を許すことになります。

—

攻撃を完全に遮断するセキュア実装例(Python / PyJWT)

それでは、これらの脆弱性を完全に排除し、実務でそのまま使える堅牢なJWT検証コードを示します。

今回はプロダクション環境で広く使われているPythonの PyJWT ライブラリを例にします。単にライブラリの関数を呼ぶだけでなく、「必須クレームの強制」「クロックスキュー(Leeway)の設定」「iat による最大寿命の独自チェック」を組み込んだ防御型コードです。

import time
import jwt
from jwt.exceptions import (
    ExpiredSignatureError,
    ImmatureSignatureError,
    InvalidTokenError,
    MissingRequiredClaimError
)

# 本番環境では環境変数やKMSから取得する公開鍵(RSAの場合)または共通鍵
SECRET_KEY = "your-256-bit-extremely-secure-secret-key-here"
ALGORITHM = "HS256"

# クロックスキュー(サーバー間の時刻ズレ)許容セカンド
CLOCK_SKEW_LEEWAY_SECONDS = 10

# トークンの絶対的な最大生存期間(例: iatから最大12時間以上経過したものはexpが有効でも拒否)
MAX_TOKEN_AGE_SECONDS = 43200  # 12時間


def verify_access_token(token: str, user_last_security_event_time: int = None) -> dict:
    """
    JWTの署名、exp、iat、nbfを厳格に検証するセキュリティ関数
    
    :param token: クライアントから送られてきたBearerトークン文字列
    :param user_last_security_event_time: ユーザーの最終パスワード変更時刻等のエポック秒(省略可)
    :return: 解読されたペイロード (dict)
    :raises ValueError: 検証失敗時のエラー
    """
    try:
        # PyJWT の decode メソッドで自動検証を行う
        # options で必須クレームを明示的に強制するのが最高峰の安全対策
        payload = jwt.decode(
            token,
            SECRET_KEY,
            algorithms=[ALGORITHM], # アルゴリズムの固定('none'認証の回避)
            leeway=CLOCK_SKEW_LEEWAY_SECONDS, # クロックスキューの許容(iat, nbf, exp 全体に適用)
            options={
                "verify_signature": True,
                "verify_exp": True,
                "verify_iat": True,
                "verify_nbf": True,
                "require": ["exp", "iat", "nbf", "sub"], # 必須クレームの存在を強制
            }
        )

        current_time = int(time.time())
        token_iat = payload.get("iat")

        # ------------------------------------------------------------------
        # 固有ロジックチェック 1: iat が未来過ぎないかの確認(Leewayを超えた異常な未来)
        # ------------------------------------------------------------------
        if token_iat > current_time + CLOCK_SKEW_LEEWAY_SECONDS:
            raise ValueError("Invalid iat: トークンの発行時刻が未来に設定されています。")

        # ------------------------------------------------------------------
        # 固有ロジックチェック 2: iat に基づく絶対寿命(Max Token Age)の強制
        # expがたとえ数日後に設定されていても、iatから一定時間経過していれば無効化する
        # ------------------------------------------------------------------
        if current_time - token_iat > MAX_TOKEN_AGE_SECONDS:
            raise ValueError("Token expired by max age: トークンの絶対有効期限(iat基準)を超過しています。")

        # ------------------------------------------------------------------
        # 固有ロジックチェック 3: パスワード変更等のセキュリティイベントとの照合
        # リプレイ攻撃や、古いセッションの使い回しを完全にシャットアウトする
        # ------------------------------------------------------------------
        if user_last_security_event_time:
            # パスワード変更時刻よりも前に発行されたトークンは拒否
            # (許容誤差を考慮し、微小な差を調整することもある)
            if token_iat < user_last_security_event_time:
                raise ValueError("Revoked token: このトークンはパスワード変更前の古いセッションです。")

        return payload

    except ExpiredSignatureError:
        # exp切れの明示的キャッチ
        raise ValueError("Token has expired: トークンの有効期限(exp)が切れています。")
    except ImmatureSignatureError:
        # nbf未達(まだ有効になっていない)のキャッチ
        raise ValueError("Token not active yet: トークンはまだ有効化されていません(nbf)。")
    except MissingRequiredClaimError as e:
        # 必須クレーム(iat, nbfなど)が含まれていないトークンを弾く
        raise ValueError(f"Missing claim: 必要なクレームが含まれていません: {e}")
    except InvalidTokenError as e:
        # 署名不一致やフォーマット不正など全般
        raise ValueError(f"Invalid token: トークンが無効です ({str(e)})")


# --- 動作検証(PoCテスト) ---
if __name__ == "__main__":
    now = int(time.time())
    
    # 正常なトークン生成
    valid_payload = {
        "sub": "user_12345",
        "iat": now,
        "nbf": now,
        "exp": now + 3600  # 1時間有効
    }
    valid_token = jwt.encode(valid_payload, SECRET_KEY, algorithm=ALGORITHM)
    
    print("--- 1. 正常系テスト ---")
    decoded = verify_access_token(valid_token)
    print(f"検証成功! ユーザーID: {decoded['sub']}\n")

    print("--- 2. パスワード変更による古いトークンの無効化テスト ---")
    # トークン発行後にパスワード変更が発生したと仮定 (10秒後)
    password_changed_at = now + 10
    try:
        verify_access_token(valid_token, user_last_security_event_time=password_changed_at)
    except ValueError as e:
        print(f"セキュリティ機能が正常に作動: {e}\n")

—

インフラ・運用レベルで絶対守るべき2つの鉄則

アプリ層でいくら iat や nbf を正しくチェックしていても、インフラ層の設計が甘ければ破綻します。現場のインフラ担当・DevOpsエンジニアと必ず共有すべき項目です。

1. NTP(Network Time Protocol)同期の徹底

APIサーバーや認可サーバーの時計が30秒ズレているだけで、nbf 違反で大量の不検知・誤検知が発生します。

  • 全サーバー(EC2、ECSタスク、Kubernetesノード等)で chrony や ntpd を稼働させること。
  • クラウド環境(AWSなら Amazon Time Sync Service など)の信頼できるタイムサーバーと常時同期させること。

2. Leeway(許容時間)は最大でも「10秒以下」に抑える

ネットワーク遅延や微小な時刻ズレを吸収するために leeway を設定しますが、これを「とりあえず5分(300秒)」などと適当に設定する開発者がいます。

leeway を大きすぎる値に設定すると、「未来のトークン発行によるリプレイ攻撃のウィンドウ」 を自ら広げることになります。実務上、leeway は 5 〜 10 秒程度で十分です。

—

チーフエンジニアからのチェックリスト(まとめ)

今日のレビュー内容を振り返り、自社システムのJWT実装を今すぐ確認してください。

  • [ ] JWTの検証ロジックで exp だけでなく、iat と nbf の存在を強制(require)しているか?
  • [ ] 発行時刻(iat)が未来を指していないか(リーウェイ範囲内か)を検証しているか?
  • [ ] パスワード変更やアカウント凍結などのイベント発生時に、iat を参照して旧トークンを弾く仕組み(Revocation check)があるか?
  • [ ] トークンの暗号アルゴリズム(alg)をコード側で固定し、none 認証攻撃を対策しているか?
  • [ ] サーバー間の時刻同期(NTP)が正常に機能しているか?

「暗号理論上、署名が正しいから安全」というのは、過去の鍵と現在の文脈が一致していることを証明して初めて言えることです。

iat と nbf を泥臭く、しかし愚直に検証すること。これが、一級のエンジニアが構築する「真に堅牢なWebアプリケーション」の境界線です。次のコードレビューから、必ず導入していきましょう。

コメント

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