「JWTは署名されているから改ざんできない」——この一文は正しいのですが、なぜ改ざんできないのかを説明できる人は多くありません。説明できないと、何が安全性を支えているのかが分からないまま実装することになり、その支柱を自分で外していることに気づけません。alg:none 攻撃も、RS256→HS256 のアルゴリズム混同攻撃も、暗号が破られたのではなく、「支柱が何だったのか」を実装者が知らなかったために起きた事故です。
この記事は、JWTの署名検証を「使い方」ではなく「なぜ安全か」から解剖します。扱う層は3つです。
- 数学の層 — ハッシュ関数のどの性質が改ざん検知を成立させているのか。雪崩効果を実測し、計算量的安全性を「予算」に換算します。
- 設計の層 — なぜハッシュ単体では足りず、HMACという2段ネスト構造が必要だったのか。
- 実装の層 — 実在するライブラリはその理論をどうコードに落としたのか。PyJWT 2.13.0 のソースコード(
api_jws.py/algorithms.py/jwks_client.py)を読み、実際に攻撃トークンを投げ込んで挙動を確かめます。
なお本記事は理論と実装の解剖に集中します。攻撃手法そのもののカタログは JWT攻撃の完全攻略、HS256とRS256の選定・鍵ローテーション・無停止移行は HS256とRS256の違いを仕様と実測で決着させる、Cognito固有の検証実装は AWS CognitoのJWT(RS256)を正しく検証する にそれぞれ譲ります。
検証環境: PyJWT 2.13.0 / cryptography 50.0.0 / Python 3.13.7 / Apple Silicon。本記事の実測値はすべてこの環境で実行した結果です。掲載したスクリプトはそのまま追試できます。
0. 結論:安全性を支えている3本の柱
先に結論を置きます。JWTの署名が破られないのは、次の3つが同時に成立しているからです。どれか1つでも欠けると崩れます。
| 柱 | 何を保証するか | 破れるとどうなるか | 実装上の急所 |
|---|---|---|---|
| ① 第二原像困難性 | payloadを書き換えると署名が必ず一致しなくなる | 改ざんが検知できない | ハッシュ関数の選択(SHA-256以上) |
| ② 鍵の秘密性とエントロピー | 攻撃者は正しい署名を計算できない | 任意のトークンを正規に偽造される | 鍵の生成方法(長さではなくエントロピー) |
| ③ アルゴリズムの固定 | 攻撃者が検証方法を選べない | alg:none・アルゴリズム混同が成立する | algorithms 許可リストをアプリが決める |
多くの解説記事は①だけを説明して終わります。しかし実際に本番環境を壊しているのは、ほぼ常に②と③です。①は数学が守ってくれますが、②と③はあなたのコードが守るしかありません。この記事は3本とも扱い、最後に実測にもとづく優先順位を示します。
1. 何が署名されているのか — 「署名対象」の厳密な定義
結論:署名対象はpayloadだけではありません。ヘッダも含まれます。だからこそ alg の書き換えは「改ざん」として検知されるはずであり、それでも攻撃が成立するのは検証側の論理的な欠陥です。
RFC 7515 §2 は、署名の入力(JWS Signing Input)を次のように定義しています。
JWS Signing Input:
ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload))
つまり ヘッダ.ペイロード というドット区切りの文字列そのものが、バイト列として署名対象になります。JSONオブジェクトではなく、Base64urlエンコード後の文字列である点が重要です。
import base64
import json
def b64url(data: bytes) -> str:
"""RFC 7515 §2 のBASE64URL:パディング '=' を落とす。"""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64url(json.dumps({"sub": "1234567890", "role": "user"}, separators=(",", ":")).encode())
signing_input = f"{header}.{payload}".encode()
# -> b'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwicm9sZSI6InVzZXIifQ'
ここから2つの帰結が出ます。
帰結1:alg の改ざんも署名対象に含まれる。 ヘッダを書き換えれば signing input が変わり、署名は一致しなくなります。ならば alg:none 攻撃はなぜ成立するのか——答えは、攻撃者は署名を一致させようとしていないからです。検証側に「そもそも署名を検証しなくてよい」と信じ込ませるのが攻撃の本体であり、これは暗号の敗北ではなく制御フローの敗北です。§5 で実装レベルの防ぎ方を見ます。
帰結2:Base64urlは暗号化ではない。 payloadは誰でもデコードできます。JWTが提供するのは機密性ではなく、**完全性(改ざんされていない)と真正性(発行者が本物)**です。パスワードや個人情報をpayloadに載せてはいけません。
2. なぜ改ざんが検知できるのか — ハッシュ関数の3性質を実測する
結論:改ざん検知を成立させているのは「衝突耐性」ではなく「第二原像困難性」です。SHA-256でこれは256ビット、すなわち探索空間 2^256 であり、達成不能です。
2.1 3つの性質を混同しない
ハッシュ関数には3つの困難性が定義されており、JWTが依存するのはそのうち特定の1つです。
| 性質 | 定義 | JWT改ざんとの関係 |
|---|---|---|
| 衝突耐性 (Collision resistance) | hash(x) == hash(y) となる任意の組 (x, y) を見つけるのが困難 | 直接は使わない(発行者自身が悪意を持つ場合に関係) |
| 原像計算困難性 (Preimage resistance) | ハッシュ値から入力を逆算するのが困難=一方向性 | 署名値から鍵や入力を復元されない |
| 第二原像困難性 (Second preimage resistance) | 既知の x に対し hash(x) == hash(x') となる別の x' を見つけるのが困難 | ★これが改ざん検知の本体 |
攻撃者の状況を考えれば分かります。攻撃者は正規のトークン(既知の x)を持っており、role: "user" を role: "admin" に書き換えた x' で同じ署名になってほしい。これはまさに第二原像問題です。任意の2つを探せる衝突攻撃より、はるかに難しい問題を押し付けられています。
強度の内訳は次のとおりです(NISTの承認済みハッシュ関数のセキュリティ強度表より)。
| ハッシュ関数 | 衝突耐性 | 原像計算困難性 | 第二原像困難性 |
|---|---|---|---|
| SHA-1 | < 80 bit | 160 bit | 105–160 bit |
| SHA-256 | 128 bit | 256 bit | 201–256 bit |
| SHA-384 | 192 bit | 384 bit | 384 bit |
| SHA-512 | 256 bit | 512 bit | 394–512 bit |
一次情報の注意(多くの記事が更新できていない点): この表はながらく NIST SP 800-107 Rev.1 の Table 1 として引用されてきましたが、同文書は撤回されています。NISTは2022年6月に撤回を提案し、寄せられたコメントを検討したうえで撤回を決定、内容は NISTのハッシュ関数ページ および SP 800-57 Part 1 等の各文書へ移管されました(正式撤回はCMVPの新Implementation Guidance公開を条件とする段階的なものです)。数値自体は現行ページでも同一ですが、引用先としては撤回済み文書ではなく移管先を指すべきです。なお第二原像困難性が範囲値をとるのは、Kelsey–Schneierの攻撃により入力メッセージ長 M に依存して 256 − L(M) ビットまで低下するためで、JWTのような短いメッセージでは上限の256ビット側になります。
2.2 雪崩効果を実測する
第二原像を「探す」のがなぜ絶望的かは、**雪崩効果(avalanche effect)**を測ると体感できます。入力を1ビット変えると出力の約半分のビットが変化する性質です。実際のJWT signing input(83バイト=664ビット)に対し、全664通りの1ビット反転を試して出力の変化ビット数(ハミング距離)を測りました。
import hashlib
import statistics
def hamming(a: bytes, b: bytes) -> int:
return sum(bin(x ^ y).count("1") for x, y in zip(a, b))
base = hashlib.sha256(signing_input).digest()
distances = []
for bit in range(len(signing_input) * 8):
flipped = bytearray(signing_input)
flipped[bit // 8] ^= 1 << (bit % 8) # 1ビットだけ反転
distances.append(hamming(base, hashlib.sha256(bytes(flipped)).digest()))
print(statistics.mean(distances), min(distances), max(distances))
実行結果です。
signing input (83 bytes = 664 bits)
入力1bit反転の全 664 パターン、出力256bit中の変化ビット数:
平均 128.44 / 256 (50.17%)
最小 107 最大 152 標準偏差 7.82
理論値(ランダム関数): 平均 128.00, 標準偏差 8.00
role を "user"→"admin" に改ざんした場合: 135/256 bit が変化
平均 128.44 ビット(50.17%)、標準偏差 7.82 は、理論値である「完全にランダムな関数」の平均 128.00・標準偏差 8.00 とほぼ一致します。つまりSHA-256の出力は、入力との相関を統計的に検出できません。
これが意味するのは、攻撃者にとって「署名を一致させる方向」に近づく手がかりが一切ないということです。role を "user" から "admin" に変えた現実的な改ざんでも 135/256 ビットが変化しました。「少しだけ変えれば署名も少しだけ変わる」なら勾配を辿れますが、実際には毎回コイントスに戻されます。したがって全探索しかありません。
2.3 2^256 を「予算」に換算する
計算量的安全性は指数関数なので、桁の感覚を掴むには具体的な数字に落とすのが有効です。以下は仮定を明示した見積もりであり、確定した事実ではありません。
仮定:SHA-256を 10^10 回/秒(10 GH/s、現代GPU1台のオーダー)計算できるとします。
| 探索空間 | GPU 1台 | GPU 100万台(10^16 H/s) |
|---|---|---|
| 2^128(衝突耐性の壁) | 約 1.1 × 10^21 年 | 約 1.1 × 10^15 年 |
| 2^256(第二原像の壁) | 約 3.7 × 10^59 年 | 約 3.7 × 10^53 年 |
宇宙の年齢はおよそ 1.38 × 10^10 年です。GPUを100万台並べても 2^128 の壁ですら宇宙年齢の約8万倍かかります。これがJWTの改ざんが「実質的に不可能」である根拠です。
そして、ここが本記事で最も強調したい対比です。
| 攻撃対象 | 探索空間 | GPU 1台での全探索時間 |
|---|---|---|
| SHA-256の第二原像 | 2^256 | 約 3.7 × 10^59 年 |
| 人が覚えられるパスワードを鍵にした場合(推定40ビット) | 2^40 | 約110秒 |
数学は 10^59 年の壁を用意してくれます。しかし鍵を "password" にした瞬間、その壁は110秒に縮みます。攻撃者は数学を破りません。あなたが開けた勝手口から入ります。RFC 8725 §3.5 が「人間が記憶できるパスワードをHS256等の鍵として直接使ってはならない(MUST NOT)」と規定しているのは、この非対称性ゆえです。
3. なぜハッシュだけでは足りないのか — HMACという回答
結論:ハッシュ単体では「誰でも署名を作れる」ため認証になりません。かといって素朴に鍵を連結する H(key || msg) はlength extension攻撃で破れます。HMACの2段ネスト構造はこの両方への回答です。
3.1 一段目の失敗:鍵なしハッシュ
signature = SHA256(header.payload) としたらどうなるか。改ざんは検知できます(第二原像困難性は健在)。しかし攻撃者も同じ計算ができるため、payloadを書き換えて署名を計算し直せば通ってしまいます。完全性はあっても真正性がありません。だから鍵が要ります。
3.2 二段目の失敗:H(key || msg) とlength extension攻撃
では鍵を前に連結して SHA256(key || header.payload) としたら——これも破れます。SHA-256はMerkle–Damgård構造を採り、内部状態(チェイニング値)をそのまま出力するためです。
攻撃者は key を知らなくても、公開されたハッシュ値を内部状態として復元し、そこから計算を再開できます。結果として、key を知らないまま H(key || msg || padding || 任意の追加データ) の正しいハッシュを算出できてしまいます。これがlength extension攻撃です。JWTの文脈では、正規トークンの末尾にクレームを追記して署名を作り直せることを意味し、致命的です。
(余談として、SHA-512/256 や SHA-3 は出力を切り詰める/構造が異なるためこの攻撃を受けません。しかしJWSが規定するのはSHA-256系であり、対策はプロトコル側で行う必要があります。)
3.3 HMACの構造
RFC 2104 が定義するHMACは、ハッシュを2回、鍵を混ぜ方を変えて適用します。
HMAC(K, text) = H( (K ⊕ opad) ‖ H( (K ⊕ ipad) ‖ text ) )
ipad = バイト 0x36 を B 回繰り返したもの
opad = バイト 0x5C を B 回繰り返したもの
B = ハッシュ関数の入力ブロック長(SHA-256 なら 64 バイト)
上の図は、内側のハッシュで 鍵⊕ipad とメッセージを処理し、その出力をさらに 鍵⊕opad と連結して外側のハッシュに通す2段構成を表しています。
外側のハッシュが内側の出力を包み込むため、攻撃者が内部状態を復元しても外側の計算に鍵が再度必要になり、length extension攻撃は成立しません。RFC 2104 が述べるとおり、ネスト構造の要点は中間ブロックが攻撃者に完全には選べなくなることにあります。
3.4 HMACの安全性は衝突耐性に依存しない — 実務への含意
ここは誤解が多い箇所です。HMACの安全性証明は、ハッシュ関数の衝突耐性を必要としません。
Bellareが2006年に発表した New Proofs for NMAC and HMAC: Security Without Collision-Resistance は、要旨で次のように述べています。
HMAC was proved by Bellare, Canetti and Krawczyk to be a PRF assuming that (1) the underlying compression function is a PRF, and (2) the iterated hash function is weakly collision-resistant. However, recent attacks show that assumption (2) is false for MD5 and SHA-1 (中略). This paper proves that HMAC is a PRF under the sole assumption that the compression function is a PRF.
これは歴史的事実とも整合します。MD5とSHA-1は衝突が実用的に発見されましたが、HMAC-MD5・HMAC-SHA1は即座には破綻しませんでした。実務上の含意は次の2点です。
- HS256は、仮にSHA-256の衝突が発見されても直ちには破られない(もちろん発見されたら移行すべきです)。
- 一方RS256(RSASSA-PKCS1-v1_5)は依存の仕方が違う。 署名対象のハッシュに衝突があれば偽造につながり得ます。「SHA-256が危なくなったとき、HS256とRS256は同じ速度では危なくならない」——この非対称性は移行計画を立てるときに効いてきます。
3.5 鍵長:仕様が「MUST」で要求する下限
RFC 7518 §3.2 は明確です。
A key of the same size as the hash output (for instance, 256 bits for "HS256") or larger MUST be used with this algorithm.
RFC 2104 §3 も同趣旨で、「L バイト未満は強く非推奨、L バイトを超えても強度は大きくは上がらない」としています。この「MUST」がPyJWTのコードにそのまま現れている様子は §5.3 で見ます。
4. PyJWT 2.13.0 の検証フローを追う
結論:PyJWTは「トークンが名乗った alg」を決して信用せず、アプリが宣言した許可リストとの照合を、アルゴリズム実装を取得する前に行っています。この順序が防御の核です。
jwt.decode() を呼んでから署名が受理されるまでの実際の判断順序は、jwt/api_jws.py の decode_complete() と _verify_signature() に集約されています。
# jwt/api_jws.py — decode_complete()(抜粋)
verify_signature = merged_options["verify_signature"]
if verify_signature and not algorithms and not isinstance(key, PyJWK):
raise DecodeError(
'It is required that you pass in a value for the "algorithms" argument when calling decode().'
)
**最初の関門は「アプリが algorithms を明示したか」**です。省略は許されません。これは「安全な既定値」ではなく「安全でない書き方を構文的に不可能にする」設計で、RFC 8725 §3.1 の「ライブラリは呼び出し側がサポートするアルゴリズム集合を指定できるようにしなければならず(MUST)、それ以外のアルゴリズムを使ってはならない(MUST NOT)」への直接的な実装です。
続く _verify_signature() が本体です。
# jwt/api_jws.py — _verify_signature()(抜粋・順序が重要)
if algorithms is None and isinstance(key, PyJWK):
algorithms = [key.algorithm_name]
try:
alg = header["alg"]
except KeyError:
raise InvalidAlgorithmError("Algorithm not specified") from None
# ① トークンが名乗った alg を、アプリの許可リストと照合する
if not alg or (algorithms is not None and alg not in algorithms):
raise InvalidAlgorithmError("The specified alg value is not allowed")
if isinstance(key, PyJWK):
# ② 鍵にアルゴリズムが束縛されている場合、それとも一致しなければならない
if alg != key.algorithm_name:
raise InvalidAlgorithmError(
f"Token algorithm {alg!r} does not match the key's "
f"algorithm {key.algorithm_name!r}"
)
alg_obj = key.Algorithm
prepared_key = key.key
else:
# ③ ここで初めてアルゴリズム実装を取得する(許可リスト通過後)
alg_obj = self.get_algorithm_by_name(alg)
prepared_key = alg_obj.prepare_key(key) # ④ 鍵の形が妥当か検査する
# ⑤ 鍵長を検査する
key_length_msg = alg_obj.check_key_length(prepared_key)
if key_length_msg:
if effective_options.get("enforce_minimum_key_length", False):
raise InvalidKeyError(key_length_msg)
else:
warnings.warn(key_length_msg, InsecureKeyLengthWarning, stacklevel=4)
# ⑥ 最後に署名検証
if not alg_obj.verify(signing_input, prepared_key, signature):
raise InvalidSignatureError("Signature verification failed")
注目すべきは②に添えられたソース中のコメントです。
The PyJWK has a fixed algorithm bound at construction time. Verification must use that algorithm, not whatever the token header advertises, otherwise the caller's allow-list check above degenerates into a string compare with no behavioural effect on which algorithm actually verifies the signature.
「許可リストの照合が、実際に署名を検証するアルゴリズムに何の影響も与えない単なる文字列比較に堕してしまう」——これはRFC 8725 §3.1 の3番目の要求、**「各鍵はちょうど1つのアルゴリズムとともに使われなければならず、これは検査されなければならない」**を実装した箇所です。設計意図がコメントとして残っているライブラリは信頼できます。
5. 攻撃を実際に投げ込む — 4つの実験
理屈は分かりました。では実際に攻撃トークンを作って PyJWT 2.13.0 に飲ませたらどうなるか。推測せず、実行して確かめます。
5.1 alg:none — 4通りの経路すべてを塞ぐ
alg:none 攻撃は、ヘッダの alg を none にして署名部を空にしたトークンを送りつけ、「署名検証は不要」と信じ込ませる古典です。攻撃者に有利な呼び出し方を4通り用意して試しました。
header = b64url(json.dumps({"alg": "none", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64url(json.dumps({"sub": "1", "role": "admin"}, separators=(",", ":")).encode())
forged = f"{header}.{payload}." # 署名部は空
=== 実験2: alg:none 攻撃 ===
decode(token, secret, algorithms=["HS256"])
→ InvalidAlgorithmError: The specified alg value is not allowed
decode(token, secret, algorithms=["none"])
→ InvalidKeyError: When alg = "none", key value must be None.
decode(token, "", algorithms=["none"]) ← 攻撃者に最も有利
→ InvalidSignatureError: Signature verification failed
decode(token, secret) ← algorithms 省略
→ DecodeError: It is required that you pass in a value for the "algorithms" argument when calling decode().
4通りとも失敗しました。注目すべきは3番目です。開発者が誤って algorithms=["none"] と書き、かつ鍵を空文字列にするという最悪の組み合わせでも、なお InvalidSignatureError で止まります。なぜか。algorithms.py を見ると理由が分かります。
# jwt/algorithms.py
class NoneAlgorithm(Algorithm):
def prepare_key(self, key: str | None) -> None:
if key == "":
key = None
if key is not None:
raise InvalidKeyError('When alg = "none", key value must be None.')
return key
def sign(self, msg: bytes, key: None) -> bytes:
return b""
def verify(self, msg: bytes, key: None, sig: bytes) -> bool:
return False # ← 無条件に False
NoneAlgorithm.verify() は引数を一切見ずに False を返します。 許可リストという第1の防壁を運用ミスで突破されても、第2の防壁で必ず落ちる。これが多層防御(defense in depth)の教科書的な実装です。RFC 7518 §3.6 の「Unsecured JWS をサポートする実装は、アプリケーションがそれを許容すると明示しない限り有効として受理してはならない(MUST NOT)/既定では受理してはならない(MUST NOT)」を、既定どころか構造的に満たしています。
ただし注意: これは「PyJWTを正しく呼んでいれば」の話です。
options={"verify_signature": False}を渡す、get_unverified_header()の結果でユーザーを特定する、独自にヘッダをパースするといった逸脱をすれば保護は効きません。ライブラリは呼ばれ方までは守れません。
5.2 RS256 → HS256 アルゴリズム混同
より巧妙なのがアルゴリズム混同(鍵混同)攻撃です。サーバーがRS256を想定しているとき、攻撃者は公開鍵(誰でも入手できる)をHMACの共有鍵として使い alg:HS256 で署名します。サーバーが alg をトークンから読んで素直に従うなら、同じ公開鍵でHMAC検証が通ってしまいます。
# 攻撃者側:公開鍵PEMをHMACの鍵として使う
pub_pem = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
signing_input = f"{header}.{payload}".encode() # header は alg:HS256
sig = hmac.new(pub_pem, signing_input, hashlib.sha256).digest()
forged = f"{header}.{payload}.{b64url(sig)}"
=== 実験3: RS256→HS256 アルゴリズム混同攻撃 ===
生のHMAC-SHA256としての検証: True(構造上、偽造は成立している)
サーバがalgを固定: algorithms=["RS256"]
→ InvalidAlgorithmError: The specified alg value is not allowed
サーバが両方許可: algorithms=["RS256","HS256"] ← 脆弱な設定
→ InvalidKeyError: The specified key is an asymmetric key or x509 certificate and should not be used as an HMAC secret.
1行目が重要です。生のHMACとしては偽造が成立しています(True)。つまり攻撃の構造そのものは健在で、防いでいるのは検証側の判断だけです。
そして注目すべきは3番目。開発者が algorithms=["RS256", "HS256"] と両方許可してしまった脆弱な設定でも、PyJWTは止めました。 その根拠が HMACAlgorithm.prepare_key() です。
# jwt/algorithms.py — HMACAlgorithm.prepare_key()
def prepare_key(self, key: str | bytes) -> bytes:
key_bytes = force_bytes(key)
if len(key_bytes) == 0:
raise InvalidKeyError("HMAC key must not be empty.")
if is_pem_format(key_bytes) or is_ssh_key(key_bytes):
raise InvalidKeyError(
"The specified key is an asymmetric key or x509 certificate and"
" should not be used as an HMAC secret."
)
# Defense against algorithm-confusion attacks: an attacker with
# control over the token header can force this code path by setting
# alg=HS*, and HMACAlgorithm is the only algorithm that accepts
# arbitrary bytes as a valid secret. (中略)
stripped = key_bytes.lstrip()
if stripped.startswith(b"{"):
jwk_obj = json.loads(key_bytes) # 例外は握って None 扱い
if isinstance(jwk_obj, dict) and "kty" in jwk_obj:
raise InvalidKeyError(
"The specified key looks like a JWK and should not be "
"used directly as an HMAC secret. ..."
)
return key_bytes
ソースのコメントが攻撃モデルを正確に述べています。「HMACだけが任意のバイト列を正当な秘密として受け入れる唯一のアルゴリズムであり、他のアルゴリズムは鍵の形をしていない入力を自然に拒否する」——だからHMACのところにだけ明示的なガードが要る、というわけです。PEM形式・SSH鍵形式に加え、{ で始まりJWKらしき(kty を持つ)JSONも拒否されます。
ただしこれを第一防衛線と考えてはいけません。 このガードが効くのは鍵が「PEM等の見分けのつく形」をしている場合だけです。JWKSから取り出した公開鍵を生の数値バイト列としてHMACに渡すような経路では効きません。第一防衛線は常に algorithms の固定で、prepare_key は最後の保険です。
5.3 鍵長チェック — 仕様の「MUST」がコードになる
# jwt/algorithms.py — HMACAlgorithm.check_key_length()
def check_key_length(self, key: bytes) -> str | None:
min_length = self.hash_alg().digest_size
if len(key) < min_length:
return (
f"The HMAC key is {len(key)} bytes long, which is below "
f"the minimum recommended length of {min_length} bytes for "
f"{self.hash_alg().name.upper()}. "
f"See RFC 7518 Section 3.2."
)
return None
RFC 7518 §3.2 の条番号がエラーメッセージに直接埋め込まれています。実測結果です。
=== 実験4: 弱い鍵の検出(check_key_length)===
key='password' (8 bytes)
→ InsecureKeyLengthWarning: The HMAC key is 8 bytes long, which is below
the minimum recommended length of 32 bytes for SHA256. See RFC 7518 Section 3.2.
key=token_hex(8) (16 bytes)
→ InsecureKeyLengthWarning: ... 16 bytes ...
key=token_bytes(32).hex() (64 bytes)
→ 警告なし
enforce_minimum_key_length=True の場合:
→ InvalidKeyError: The HMAC key is 8 bytes long, ...
既定では警告のみ(InsecureKeyLengthWarning)で処理は続行し、options={"enforce_minimum_key_length": True} を渡すと InvalidKeyError で停止します。既存トークンを壊さないための後方互換的な設計ですが、新規プロジェクトは最初からこのオプションを有効にすべきです。なお encode() と decode() の双方で検査されるため、同一処理で警告が2回出ることがあります。
5.4 【重要な限界】鍵長チェックはエントロピーを見ていない
ここは本記事で最も注意を促したい点です。check_key_length が見ているのは len(key)、すなわちバイト長だけです。エントロピーは測っていません。実測しました。
key='aaaaaaaaaaaaaaaaaaaa...' len=32B -> 警告なし・検証成功
key='passwordpasswordpass...' len=32B -> 警告なし・検証成功
key='a80dffcee7a242fe8608...' len=32B -> 警告なし・検証成功
"a" * 32 も "password" * 4 も、enforce_minimum_key_length=True を有効にしてなお、警告ひとつ出さずに通過します。 実効エントロピーはそれぞれ数ビット程度で、§2.3 の表で見たとおり秒単位で全探索されます。
つまり PyJWTの鍵長チェックは「うっかり短い鍵」への防御であって、「弱い鍵」への防御ではありません。RFC 8725 §3.5 が要求しているのはエントロピーであり、長さではないのです。鍵は必ずCSPRNGから生成してください。
import secrets
# 正しい:256ビットの実エントロピー
SECRET_KEY = secrets.token_urlsafe(32) # 43文字 / 256bit
# 誤り:長さは足りるがエントロピーがない
SECRET_KEY = "my-super-secret-key-for-production"
5.5 hmac.compare_digest — 通説を実測で検証する
結論:定数時間比較は必ず使うべきです。ただし実測すると、JWTの32バイト比較において素朴な == が漏らす時間差は測定限界のはるか下でした。「compare_digestこそが命綱」という説明は、優先順位を誤らせます。
PyJWTのHMAC検証は1行です。
# jwt/algorithms.py — HMACAlgorithm.verify()
def verify(self, msg: bytes, key: bytes, sig: bytes) -> bool:
return hmac.compare_digest(sig, self.sign(msg, key))
理屈はこうです。素朴な == が先頭から1バイトずつ比較して不一致で打ち切る(short-circuit)実装なら、攻撃者は「何バイト目まで合っていたか」を実行時間から推定し、1バイトずつ正解を積み上げて署名を構築できます。RFC 7518 §3.2 も検証を定数時間で行うことを求めています。
では実際にどれだけ漏れるのか。先頭バイトが不一致の場合と末尾バイトが不一致の場合の実行時間を、比較対象のサイズを変えながら測りました。
長さ別: 先頭不一致 vs 末尾不一致の時間差(ns)
size | == head == tail diff | cd head cd tail diff
32 | 21.1 21.2 +0.1 | 29.6 29.2 -0.4
256 | 21.4 25.6 +4.2 | 98.4 100.0 +1.6
4096 | 22.1 103.6 +81.5 | 1128.9 1122.7 -6.2
65536 | 18.3 1184.7 +1166.4 | 18614.6 19053.3 +438.7
1048576 | 19.2 17971.0 +17951.9 | 300413.8 301636.2 +1222.5
読み取れることが2つあります。
① short-circuitの機序は実在する。 == は先頭不一致ならサイズによらず約20ns(即座に打ち切る)、末尾不一致ならサイズに比例して増加し、1MBでは +17.9μs も漏らします。一方 compare_digest は両方ともサイズに比例し、先頭/末尾の差が出ません。設計どおりの挙動です。
② しかしJWTが扱う32バイトでは、その漏洩は +0.1ns しかない。 これはCrosbyらが Opportunities and Limits of Remote Timing Attacks (ACM TISSEC, 2009) で示した測定精度——LAN上で約100ns、インターネット越しで15〜100μs——と比べると、LANの最良ケースですら1/1000です。ネットワーク越しの攻撃者にこの信号は観測できません。
したがって正しい結論は次のとおりです。
compare_digestは必ず使う。 RFC 7518 §3.2 が要求しており、コストは約10ns/回で実質無料。将来の実装変更・別ランタイム・より長い比較対象に対する保険にもなります。自分でMAC検証を書くときも必ず使ってください。- しかしこれを最優先の対策として語らない。 32バイトのHMAC比較において、
==は現実的な攻撃面ではありません。同じ労力をalgorithmsの固定と鍵のエントロピーに向けるほうが、実際のリスクをはるかに大きく下げます。
「セキュリティ対策」を列挙するだけの記事は多いのですが、列挙は優先順位を消します。実測は優先順位を復元してくれます。
6. JWKSのアーキテクチャ — PyJWKClient の実装から運用設計を読む
結論:JWKSの設計課題はキャッシュTTLではなく「未知の kid が来たときどうするか」です。PyJWKClientの実装はその答えを示すと同時に、公開APIでは自前の補強が要ることも示しています。
RS256のような非対称鍵では、検証者は公開鍵を入手する必要があります。RFC 7517 が定めるJWK Setをエンドポイントで配信し、トークンヘッダの kid(Key ID)で該当鍵を選ぶ、というのが標準的な構成です。実装の設計判断は jwt/jwks_client.py に凝縮されています。
6.1 二層キャッシュ
class PyJWKClient:
def __init__(
self,
uri: str,
cache_keys: bool = False, # 第2層:kid単位のLRU(既定OFF)
max_cached_keys: int = 16,
cache_jwk_set: bool = True, # 第1層:JWK Set全体(既定ON)
lifespan: float = 300, # TTL 5分
headers: dict[str, Any] | None = None,
timeout: float = 30,
ssl_context: SSLContext | None = None,
):
...
| 層 | 対象 | 既定 | 失効方式 |
|---|---|---|---|
| 第1層 | JWK Set レスポンス全体 | 有効 | TTL 300秒 |
| 第2層 | kid 単位の個別鍵 | 無効 | LRUのみ(時間失効なし) |
第2層が既定で無効なのは正しい判断です。lru_cache には時間失効がないため、有効にすると失効・ローテーションされた鍵が上限に達するまで残り続けます。ドキュメントも "no time-based expiration" と明記しています。パフォーマンスのために有効化する場合は、この意味を理解した上で行ってください。
6.2 未知の kid が来たら1回だけ再取得する
鍵ローテーションへの追従を担う中核がここです。
def get_signing_key(self, kid: str) -> PyJWK:
signing_keys = self.get_signing_keys()
signing_key = self.match_kid(signing_keys, kid)
if not signing_key:
# キャッシュに無ければ、JWK Set を再取得して1度だけ再試行する
signing_keys = self.get_signing_keys(refresh=True)
signing_key = self.match_kid(signing_keys, kid)
if not signing_key:
raise PyJWKClientError(f'Unable to find a signing key that matches: "{kid}"')
return signing_key
これにより、IdPが鍵をローテーションした直後でもTTLの満了(最大5分)を待たずに新しい鍵へ追従できます。JWKSベースのローテーションが無停止で行える理由がこの3行です。
しかし裏面があります。 未知の kid は必ずネットワーク取得を誘発します。攻撃者がランダムな kid を持つトークンを大量に送れば、リクエストごとにJWKSエンドポイントへのHTTPリクエストが発生します。自サービスがIdPを叩き潰す、あるいはIdP側のレート制限に達して正規の認証まで巻き添えで落ちる——DoSの増幅経路です。PyJWTにこの再取得のレート制限はありません。公開APIでは次のいずれかを自前で被せてください。
- ネガティブキャッシュ:一度「見つからない」と判定した
kidを短時間(例:30秒)記憶し、再取得をスキップする - 再取得のレート制限:
refresh=Trueの呼び出しを「N秒に1回まで」に制限する(トークンバケット等) kidの形式検証:IdPが発行するkidの形式(長さ・文字種)に合わないものは、取得前に弾く
6.3 障害時にキャッシュを消さない
地味ですが重要な設計判断です。
def fetch_data(self) -> Any:
try:
...
jwk_set = json.load(response)
except (URLError, TimeoutError) as e:
...
raise PyJWKClientConnectionError(...) from e
# Only update the cache on a successful fetch. Writing in a
# `finally` block with `jwk_set=None` on error clears any
# previously-cached JWKS, turning a transient outage into a cache
# wipe that breaks legitimate auth.
if self.jwk_set_cache is not None:
self.jwk_set_cache.put(jwk_set)
return jwk_set
キャッシュの更新は取得成功時のみ。 これを finally 節に書いてしまうと、IdPの一時的な障害がキャッシュの消去に化け、「IdPが数秒落ちただけで全ユーザーの認証が落ちる」という増幅事故になります。可用性の観点で、外部依存のキャッシュは失敗時に古い値を保持するのが原則です。
6.4 jku 由来のURLを想定した防御
コンストラクタの冒頭にスキーム検証があります。
# urllib's default OpenerDirector also handles file://, ftp://, and
# data: URIs. Reject anything that isn't http(s) eagerly so a caller
# passing an attacker-influenced URL (e.g. taken from a `jku` token
# header) can't read local files or reach other unintended schemes.
scheme = urlparse(uri).scheme.lower()
if scheme not in ("http", "https"):
raise PyJWKClientError(...)
urllib は既定で file:// も扱うため、jku ヘッダ由来のURLをそのまま渡すとローカルファイル読み出しに化けます。とはいえ、これも保険です。jku をそのまま信用してはいけないという原則は変わりません。JWKSのURIは設定値として固定するか、厳格な許可リストで検証してください。
6.5 検証をどこで行うか — API Gatewayへのオフロードと責務分界
JWKS検証はAPI Gateway(AWS API Gateway JWT Authorizer、Envoy/Istio、Kong等)にオフロードできます。判断軸を整理します。
| 観点 | Gatewayで検証 | アプリ内で検証 |
|---|---|---|
| JWKS取得・キャッシュ | 集約される(IdPへの負荷が下がる) | サービス数だけ分散する |
署名・exp・iss・aud | 得意 | 可能だが重複実装になりがち |
| きめ細かい認可(テナント・リソース所有権) | 不得意 | ここでしかできない |
| Gatewayを迂回された場合 | 無防備 | 守られる |
推奨は二層構成です。Gatewayで署名・exp・iss・aud という「全リクエスト共通の門番」を担わせ、アプリ側では**認可(このユーザーはこのリソースを操作してよいか)**に集中します。ただしアプリを直接叩ける経路(VPC内部・サービス間通信・デバッグ用ポート)が残るなら、アプリ側でも署名検証を省略してはいけません。「Gatewayが検証しているはず」という前提は、ネットワーク構成の変更ひとつで崩れます。
なおCognito固有の token_use 検証やクロックスキューの扱いなど、実装の詳細は AWS CognitoのJWT(RS256)を正しく検証する で扱っています。
7. 優先順位つきチェックリスト
「対策の列挙」は優先順位を消します。この記事の実測が示した順序で並べます。上から順に効きます。
第1優先:アルゴリズムを固定する(これを外すと他が全部無意味になる)
-
jwt.decode()にalgorithms=[...]を必ず明示する。PyJWTは省略をDecodeErrorで拒否するが、他言語のライブラリは黙って通すものがある - 許可リストは1要素にする。
["RS256", "HS256"]のような複数許可は混同攻撃の入り口(§5.2 で実証) - 1つの鍵は1つのアルゴリズムにのみ使う(RFC 8725 §3.1)。PyJWKを使えば構築時に束縛される
-
algをトークンから読んで分岐する自前コードを書かない
第2優先:鍵のエントロピーを確保する
- HS256の鍵は
secrets.token_urlsafe(32)等のCSPRNGで生成する。長さではなくエントロピー(§5.4 で実証:"a"*32は警告なしで通る) - 人が考えた文字列・パスワード・環境名を鍵にしない(RFC 8725 §3.5 の MUST NOT)
-
options={"enforce_minimum_key_length": True}を新規プロジェクトでは既定にする - 鍵はソースコードではなく環境変数/シークレットマネージャに置く
第3優先:検証を省略できない構造にする
-
verify_signature: Falseを本番コードに存在させない(テストでもfixture側に隔離する) -
get_unverified_header()の結果はkidの取得だけに使い、認可判断には絶対に使わない -
exp/iss/audを検証する(aud未検証は他システム向けトークンの流用を許す) - MAC比較には
hmac.compare_digestを使う(コスト約10ns、RFC 7518 §3.2 の要求) - 例外は
jwt.InvalidTokenErrorではなくjwt.PyJWTErrorで捕捉する。InvalidKeyErrorとPyJWKClientErrorはInvalidTokenErrorの階層外で、攻撃とIdP障害が500に化ける(次項で実証)
第4優先:JWKS運用を堅くする
- JWKSのURIは設定値で固定する。
jkuヘッダを信用しない - 未知
kidによる再取得にネガティブキャッシュかレート制限を被せる(§6.2 のDoS面) -
cache_keys=Trueを使うなら、時間失効がないことを理解した上で使う - JWKS取得失敗時に古いキャッシュを消さない
- Gateway検証に寄せる場合も、アプリ直叩き経路があるならアプリ側検証を残す
【実測で見つけた罠】except jwt.InvalidTokenError は混同攻撃を捕捉できない
エラーハンドリングの定番はこう書かれます。
try:
claims = jwt.decode(token, key, algorithms=["RS256"])
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
これは §5.2 のアルゴリズム混同攻撃を捕捉できません。 実際に投げ込んで確認しました。
=== 典型的な 401 ハンドラに混同攻撃トークンを投げる ===
!! InvalidTokenError で捕捉できず 500 になる:
InvalidKeyError -> The specified key is an asymmetric key or x509 certificate
and should not be used as an HMAC secret.
原因はPyJWTの例外階層です。実測した継承関係は次のとおりで、InvalidKeyError は InvalidTokenError を継承していません。
PyJWTError <- Exception
├─ InvalidTokenError <- PyJWTError
│ ├─ DecodeError
│ │ └─ InvalidSignatureError
│ ├─ InvalidAlgorithmError
│ ├─ ExpiredSignatureError
│ ├─ InvalidAudienceError / InvalidIssuerError / MissingRequiredClaimError ...
├─ InvalidKeyError <- PyJWTError ★ InvalidTokenError の下にない
├─ PyJWKClientError <- PyJWTError ★ 同上
│ └─ PyJWKClientConnectionError
└─ PyJWKError / PyJWKSetError <- PyJWTError ★ 同上
実害は2つあります。①攻撃が500になる——混同攻撃を受けたときスタックトレースが出て、401ではなくサーバーエラーとして記録されます。攻撃検知のアラートは「認証失敗の増加」を見ていることが多く、500に化けると監視をすり抜けます。②JWKS障害が500になる——PyJWKClientConnectionError も階層の外なので、IdP側の一時障害が未捕捉例外としてユーザーに露出します。
修正は単純で、基底の PyJWTError で捕捉し、必要なら種別で分岐することです。
try:
claims = jwt.decode(token, key, algorithms=["RS256"])
except jwt.PyJWKClientError as e:
# 鍵が取れない=こちらの都合。401ではなく503が正しい
logger.error("jwks_unavailable", exc_info=e)
raise HTTPException(status_code=503, detail="Auth temporarily unavailable")
except jwt.InvalidKeyError as e:
# 鍵の形が不正=設定ミスか攻撃。必ず警告として記録する
logger.warning("jwt_key_rejected", extra={"reason": str(e)})
raise HTTPException(status_code=401, detail="Invalid token")
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid token")
except jwt.PyJWTError を最後の砦に置くのが要点です。InvalidTokenError だけを見ている既存コードは、いま grep して確認する価値があります。
退行テストで固定する
チェックリストは腐ります。攻撃トークンをテストに焼き込んでください。
import base64
import hashlib
import hmac
import json
import jwt
import pytest
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def _segment(obj: dict[str, object]) -> str:
return b64url(json.dumps(obj, separators=(",", ":")).encode())
def test_alg_none_is_rejected() -> None:
"""alg:none のトークンが受理されないことを固定する。"""
forged = f"{_segment({'alg': 'none', 'typ': 'JWT'})}.{_segment({'role': 'admin'})}."
with pytest.raises(jwt.PyJWTError):
verify_token(forged) # アプリの検証関数
def test_algorithm_confusion_is_rejected(rsa_public_pem: bytes) -> None:
"""公開鍵をHMAC鍵に使った HS256 トークンが受理されないことを固定する。"""
header = _segment({"alg": "HS256", "typ": "JWT"})
payload = _segment({"role": "admin"})
signature = hmac.new(rsa_public_pem, f"{header}.{payload}".encode(), hashlib.sha256).digest()
with pytest.raises(jwt.PyJWTError):
verify_token(f"{header}.{payload}.{b64url(signature)}")
jwt.PyJWTError はPyJWTの例外階層の真の基底クラスなので、InvalidAlgorithmError でも InvalidKeyError でも InvalidSignatureError でも捕捉できます。「どう落ちるか」ではなく「必ず落ちること」を固定するのが、ライブラリ更新に強いテストです(PyJWTが将来どの例外を投げるか変えても、テストは壊れません)。
8. まとめ
JWTの署名検証を3つの層で解剖しました。
数学の層では、改ざん検知を支えているのが衝突耐性ではなく第二原像困難性(SHA-256で256ビット)であることを確認し、雪崩効果を実測しました(1ビット反転で平均 128.44/256ビット(50.17%) が変化、標準偏差7.82 — ランダム関数の理論値とほぼ一致)。攻撃者に勾配情報は一切残りません。
設計の層では、ハッシュ単体では認証にならず、素朴な H(key‖msg) はlength extension攻撃で破れるため、HMACが2段ネスト構造を採ったことを見ました。そしてHMACの安全性証明はハッシュの衝突耐性に依存しない(Bellare 2006)——この非対称性は、将来SHA-256に問題が生じたときHS256とRS256が同じ速度では危なくならないことを意味します。
実装の層では、PyJWT 2.13.0 に実際に攻撃トークンを投げ込みました。alg:none は4通りの経路すべてで失敗し、NoneAlgorithm.verify() が無条件に False を返す多層防御が確認できました。RS256→HS256 混同は、偽造そのものは成立している(生のHMACとしては True)にもかかわらず prepare_key のPEM検出が止めました。
実装の層では、想定していなかった罠も1つ見つかりました。 定番の except jwt.InvalidTokenError → 401 というハンドラは、アルゴリズム混同攻撃を捕捉できません。InvalidKeyError は InvalidTokenError の階層外(PyJWTError 直下)だからです。結果として、攻撃は401ではなく500として記録され、「認証失敗の増加」を見ている監視をすり抜けます。同じ理由でJWKS障害(PyJWKClientError)も未捕捉例外になります。捕捉は jwt.PyJWTError で行ってください。
そして最も実務的な発見は、優先順位に関する通説の修正です。compare_digest は32バイト比較で == との時間差が +0.1ns しかなく、Crosbyらが示したLAN上の測定限界100nsの1/1000でした。一方、鍵長チェックはエントロピーを見ていないため "a" * 32 が警告なしで通ります。使うべき対策は全部使うべきですが、**限られた注意を配分する順序は「①algの固定 → ②鍵のエントロピー → ③定数時間比較」**です。
JWTを破るのは数学の進歩ではありません。「支柱が何だったのか」を知らないまま、自分でそれを外してしまう実装です。この記事がその支柱の在り処を示せていれば幸いです。
追試について
本記事の実測はすべて再現可能です。pip install "pyjwt[crypto]==2.13.0" の上で、§2.2(雪崩効果)・§5.1〜5.5(攻撃実験とタイミング測定)に掲載したスクリプトをそのまま実行してください。数値は環境(CPU・Pythonビルド・OpenSSLバージョン)に依存しますが、傾向——雪崩効果が理論値に一致すること、攻撃が全て失敗すること、== の時間差が32バイトでは測定限界以下で1MBでは顕在化すること——は再現するはずです。数字を信じず、自分の環境で測ってください。それがこの記事の主題でもあります。