【実務・中級編】 JWTの有効期限(exp)と発行時刻(iat)の検証によるリプレイ攻撃対策 – 暗号理論・認証基盤 & エンドポイントセキュリティ防御ガイド

JWTの「寿命」を軽視するな:リプレイ攻撃を防ぐための実装論

現場でインシデントレスポンスを担当していると、いまだに「JWTの署名さえ検証していれば安全だ」と信じ込んでいる設計に遭遇する。ハッキリ言おう。それは大きな勘違いだ。

JWT(JSON Web Token)はステートレスであることが最大の強みだが、その裏返しとして「一度発行したトークンを、期限内であれば誰が使ってもシステム側は正当と見なしてしまう」という致命的な弱点がある。攻撃者が何らかの手段で盗み取ったトークンを何度も悪用する「リプレイ攻撃」に対し、exp(有効期限)と iat(発行時刻)の検証をサボることは、家の鍵を道端に落として「誰も拾わないだろう」と祈るに等しい。

今日は、この「鍵の寿命」を適切に管理し、攻撃の芽を摘むための実務的な実装アプローチを共有する。

—

1. なぜ exp と iat だけでは不十分なのか

JWTにおいて、exp は「いつまで使えるか」、iat は「いつ発行されたか」を示す。これらを検証することは大前提だが、現実の攻撃者は以下のような手口を狙っている。

  • トークンの盗聴・横流し: TLS終端後の平文トラフィック、あるいはログファイルやブラウザのローカルストレージからトークンを抜き取る。
  • 短命化の欠如: 有効期限を「24時間」や「1週間」と長く設定しているシステムは、一度漏洩したトークンがその期間中ずっと攻撃者の武器として機能してしまう。

我々が目指すべきは、「万が一漏洩しても被害を最小限に抑える」ための多層防御だ。

—

2. 実装の鉄則:Python (PyJWT) による厳格な検証

多くのフレームワークが自動で検証してくれるが、設定を間違えれば意味がない。以下は、PyJWTライブラリを用いて「期限切れ」および「異常な発行時刻」を弾くセキュアな実装例だ。

import jwt
import datetime

# 秘密鍵(環境変数から読み込むこと。直書きは厳禁)
SECRET_KEY = "super-secret-key-change-me"
ALGORITHM = "HS256"

def verify_token(token):
    try:
        # decodeメソッド内で自動的に exp, iat を検証する
        # leewayはサーバー間のクロックズレを許容する秒数。通常は数秒で十分
        payload = jwt.decode(
            token, 
            SECRET_KEY, 
            algorithms=[ALGORITHM],
            options={
                "require": ["exp", "iat"],  # expとiatの存在を強制
                "verify_exp": True,         # 有効期限を検証
                "verify_iat": True          # 発行時刻を検証
            },
            leeway=5 
        )
        return payload
    except jwt.ExpiredSignatureError:
        # トークンの期限が切れている場合
        print("警告: 期限切れトークンの使用を検知しました")
        return None
    except jwt.InvalidTokenError as e:
        # 不正なトークン、または改ざんされた場合
        print(f"エラー: 無効なトークン: {e}")
        return None

ポイント:

  • require オプションで iat を必須にせよ。これにより、発行時刻が記録されていない怪しいトークンを即座に拒絶できる。
  • leeway を過剰に大きくするな。NTPで時刻同期が適切に行われていれば、5秒以内で十分だ。

—

3. さらに一歩先へ:攻撃者のスキを突く運用設計

コードの検証だけでは不安だというベテランエンジニアには、以下の運用ルールを推奨する。

A. トークンの寿命を極限まで短くする

アクセス用トークン(Access Token)の寿命は 5分〜15分 に設定せよ。ユーザー体験を損なう場合は、リフレッシュトークンを使用して、バックグラウンドで安全にトークンを再発行する「OAuth 2.0 フロー」を採用すべきだ。

B. 発行時刻(iat)のチェックによる「古いトークンの無効化」

もしユーザーがパスワードを変更した場合、iat より前に発行されたすべてのトークンを無効にするロジックをバックエンドに仕込む必要がある。

# 疑似コード:パスワード変更時の処理
def on_password_change(user_id):
    # ユーザーのパスワード最終変更時刻をDBに保存
    db.users.update_one({"id": user_id}, {"$set": {"last_password_change": now()}})

# 認証ロジック内で追記
if payload['iat'] < user['last_password_change']:
    raise Exception("このトークンはパスワード変更後に発行されたものであり無効です")

—

4. インフラ側(Nginx)での対策

アプリケーション層に届く前の「不審なトラフィック」を、あらかじめNginxで弾くことも重要だ。トークンが巨大すぎる場合や、明らかに不正なフォーマットであれば、処理する前に遮断する。

# Nginx設定例:Authorizationヘッダーのサイズ制限と簡易的なバリデーション
location /api/ {
    # 巨大なヘッダー(攻撃用)を拒否
    large_client_header_buffers 4 8k;
    
    # 必要に応じて、Authorizationヘッダーの正規表現チェックを入れることも可能
    # if ($http_authorization !~* "^Bearer [a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$") {
    #     return 401;
    # }
    
    proxy_pass http://backend_app;
}

—

最後に:セキュリティは「疑うこと」から始まる

エンジニア諸君、JWTの仕様を読み解くと「利便性」ばかりに目が向きがちだが、我々の仕事は「攻撃者がどうやってその利便性を悪用するか」を想像することだ。

exp や iat の検証は、セキュリティの基本中の基本。しかし、これを徹底的に実装するだけで、リプレイ攻撃の成功率は劇的に下がる。今日紹介したコードは、今すぐプロジェクトに組み込めるはずだ。

セキュリティ対策に「終わり」はない。だが、こうして泥臭く実装を積み重ねることが、結果として我々のサービスとユーザーを守る唯一の道になる。また現場で会おう。

コメント

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