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 の検証は、セキュリティの基本中の基本。しかし、これを徹底的に実装するだけで、リプレイ攻撃の成功率は劇的に下がる。今日紹介したコードは、今すぐプロジェクトに組み込めるはずだ。
セキュリティ対策に「終わり」はない。だが、こうして泥臭く実装を積み重ねることが、結果として我々のサービスとユーザーを守る唯一の道になる。また現場で会おう。
コメント