JWT HS256 を手で作る:alg none・弱い秘密鍵・exp/aud
標準ライブラリだけで HS256 の JWT を作って検証し、alg: none による admin 偽造、弱い秘密鍵の総当たり(0.14ミリ秒)、exp/aud の検査、トークンの保存場所までを実行結果で確認する。
シリーズ:セキュリティ基礎講座 4/6(中級)。 API、スマホアプリのバックエンド、SPA で JSON Web Token を発行・検証しているけれど、ライブラリの中身は見たことがない、という開発者向けの記事です。読み終えると、標準ライブラリだけで HS256 の JWT を作って検証でき、JWT を「開けっぱなしの扉」にする3つの間違いを説明できるようになります。
JWT の事故は地味な原因で起きます。秘密鍵が弱い、受け入れてはいけないトークンを受け入れる、誰でも書き換えられる role クレームを信じる。社内向けの管理画面でも、ここを誤ると他人のアカウントに入られ、個人データが見られる事態になりえます(そうなれば個人情報保護法上の漏えい等の報告を検討する場面にもなりえます。一般論です)。ここでは説明するのではなく、小さな検証器を書き、わざと壊し、壊れ方を計測し、直します。実行環境は Python 3.9.6、PHP 8.5.6、OpenSSL 4.1.0、Apple Silicon Mac(M1 Pro)。JWT ライブラリは入れていません。それがこの記事の狙いです。攻撃はすべて、手元で作ったトークンと鍵だけに対して行いました。
JWT は base64url の文字列3つと HMAC 1つ
HS256 の JWT はドットで区切られた3つの部分からなります。
- ヘッダー:
{"alg":"HS256","typ":"JWT"}のような JSON。 - ペイロード:
sub(サブジェクト=ユーザー)、exp(有効期限、Unix 秒)、aud(オーディエンス=誰に向けたトークンか)などの「クレーム」を入れた JSON。 - 署名:
base64url(ヘッダー) + "." + base64url(ペイロード)という ASCII バイト列に対する、共有の秘密鍵を使った HMAC-SHA256。
base64url は、base64 の + と / を - と _ に替え、末尾の = パディングを取ったものです。全体が15行で書けます。
# jwt_min.py - 標準ライブラリだけで HS256 JWT を作る
import base64, hashlib, hmac, json
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def b64url_decode(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def sign_hs256(payload: dict, key: bytes) -> str:
header = {"alg": "HS256", "typ": "JWT"}
h = b64url(json.dumps(header, separators=(",", ":")).encode())
p = b64url(json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode())
sig = hmac.new(key, f"{h}.{p}".encode("ascii"), hashlib.sha256).digest()
return f"{h}.{p}.{b64url(sig)}"
if __name__ == "__main__":
KEY = b"demo-key-for-this-post-only-0001" # 32バイト。本物の秘密鍵ではない
tok = sign_hs256({"sub": "user-42", "name": "佐藤", "role": "user",
"iat": 1794614400, "exp": 1794618000}, KEY)
print(tok)
for part in tok.split(".")[:2]:
print(b64url_decode(part).decode())
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQyIiwibmFtZSI6IuS9kOiXpCIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzk0NjE0NDAwLCJleHAiOjE3OTQ2MTgwMDB9.qCayJU9QS_a-tGzjXDLNhzYZJBjW3BXdEQNkN7f-wGA
{"alg":"HS256","typ":"JWT"}
{"sub":"user-42","name":"佐藤","role":"user","iat":1794614400,"exp":1794618000}
いま起きたことに注目してください。2つ目のループは鍵なしでペイロードを表示しました。「佐藤」も UTF-8 のまま読めます。署名付き JWT は暗号化されていません。トークンを見られる人(ブラウザの開発者ツール、プロキシのログ、クラッシュレポート)は全クレームを読めます。氏名やメールアドレスを入れるなら、それを見られてよいかを先に考えてください。
形式が標準どおりであることを確かめるため、同じ方式で作ったトークンを PHP と OpenSSL CLI でも検証しました。どちらも同じ署名になります(下は name が Alex の英語版トークンでの実行例)。
<?php
function b64url_decode(string $s): string { return base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4)); }
[$h, $p, $s] = explode('.', $argv[1]);
$key = 'demo-key-for-this-post-only-0001';
$hdr = json_decode(b64url_decode($h), true, 8, JSON_THROW_ON_ERROR);
if (($hdr['alg'] ?? null) !== 'HS256') exit("alg not allowed\n");
$good = hash_hmac('sha256', "$h.$p", $key, true);
echo hash_equals($good, b64url_decode($s)) ? "PHP: signature OK\n" : "PHP: bad signature\n";
$ php verify.php "$TOKEN"
PHP: signature OK
$ printf %s "$(echo $TOKEN | cut -d. -f1-2)" | openssl dgst -sha256 -hmac demo-key-for-this-post-only-0001 -binary | openssl base64 -A | tr '+/' '-_' | tr -d '='
tYECXfS5xKO3rsp1e3ArWetCHPiygT8spAmzNzjhBfc
ここに大事な規則が1つ隠れています。受け取ったバイト列そのもので検証すること。ペイロードをパースして JSON に戻してから検証すると、キーの順番や空白(", " と ",")の違いでバイト列が変わり、署名が全部合わなくなります。日本語の場合は ensure_ascii の違いで "佐藤" と "佐藤" に分かれる点にも注意してください。
よくある間違い:alg をトークンに決めさせる → admin を偽造 → 対策
初期の JWT ライブラリの多くは、ヘッダーに書かれたアルゴリズムで検証していました。しかも RFC 7518 は「保護なし」JWT のための "alg": "none" まで定義しています。この2つを組み合わせると、次のような検証器になります。わざと悪く書きました。
# jwt_naive.py - わざと悪い検証器(手元だけのデモ)。jwt_min.py と同じフォルダに保存
import hashlib, hmac, json, time
from jwt_min import b64url, b64url_decode, sign_hs256
KEY = b"demo-key-for-this-post-only-0001"
def verify_naive(token: str, key: bytes) -> dict:
h, p, s = token.split(".")
header = json.loads(b64url_decode(h))
if header["alg"] == "HS256":
good = b64url(hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest())
if s != good:
raise ValueError("bad signature")
elif header["alg"].lower() == "none":
pass # 「署名なしだから検査することはない」
else:
raise ValueError("unsupported alg")
return json.loads(b64url_decode(p))
T = 1794614400
good = sign_hs256({"sub": "user-42", "name": "佐藤", "role": "user",
"aud": "api.example.com", "iat": T, "exp": T + 900}, KEY)
print("naive, real token :", verify_naive(good, KEY)["role"])
h, p, s = good.split(".")
claims = json.loads(b64url_decode(p)); claims["role"] = "admin"
p2 = b64url(json.dumps(claims, separators=(",", ":"), ensure_ascii=False).encode())
try:
verify_naive(f"{h}.{p2}.{s}", KEY)
except ValueError as e:
print("naive, edited role:", e)
forged = b64url(b'{"alg":"none","typ":"JWT"}') + "." + p2 + "." # 署名は空
print("naive, alg=none :", verify_naive(forged, KEY)["role"])
naive, real token : user
naive, edited role: bad signature
naive, alg=none : admin
ペイロードだけ書き換えた場合は HMAC が合わず、ちゃんと弾かれました。ところがヘッダーを none にして署名を外すと、検証器は role: admin を返します。鍵は一切使っていません。.lower() のせいで None、NONE、nOnE も通ります。
対策: アルゴリズムは、ほかの何よりも先に、サーバーが自分の設定で決めます。署名は定数時間で比較し、それからクレームを検査します。下の図の順番です。
def verify(token: str, key: bytes, *, audience: str, leeway: int = 30, now=None) -> dict:
try:
h, p, s = token.split(".")
except ValueError:
raise ValueError("malformed token")
header = json.loads(b64url_decode(h))
if header.get("alg") != "HS256": # 許可リスト。決めるのはサーバー
raise ValueError(f"alg not allowed: {header.get('alg')!r}")
good = hmac.new(key, f"{h}.{p}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(b64url_decode(s), good):
raise ValueError("bad signature")
claims = json.loads(b64url_decode(p))
now = time.time() if now is None else now
if "exp" not in claims or now > claims["exp"] + leeway:
raise ValueError("expired")
if "nbf" in claims and now < claims["nbf"] - leeway:
raise ValueError("not yet valid")
aud = claims.get("aud")
if audience not in (aud if isinstance(aud, list) else [aud]):
raise ValueError(f"wrong audience: {aud!r}")
return claims
# jwt_naive.py の末尾に追加。上と同じトークンで確かめる
for t, label in ((forged, "alg=none"), (f"{h}.{p2}.{s}", "edited role"), (good, "real token")):
try: print("fixed,", label.ljust(11), ":", verify(t, KEY, audience="api.example.com", now=T + 60)["role"])
except ValueError as e: print("fixed,", label.ljust(11), ":", e)
for when, aud in ((T + 2000, "api.example.com"), (T + 60, "admin.example.com")):
try: verify(good, KEY, audience=aud, now=when)
except ValueError as e: print("fixed, now=T+%-5d aud=%s: %s" % (when - T, aud, e))
fixed, alg=none : alg not allowed: 'none'
fixed, edited role : bad signature
fixed, real token : user
fixed, now=T+2000 aud=api.example.com: expired
fixed, now=T+60 aud=admin.example.com: wrong audience: 'api.example.com'
なぜ != ではなく compare_digest なのか。単純な比較は最初に違うバイトで止まることがあり、時間差から情報が漏れうるからです。それが Python で本当に測れるのかは第6回で実験します。いずれにせよ定数時間の関数を使ってください。コストはありません。
ルール: 検証の方法をトークンに選ばせない。実際のライブラリでは、毎回 algorithms=["HS256"](またはそれに当たる引数)を明示します。
弱い秘密鍵は0.14ミリ秒で見つかる(実測)
HS256 の強さは鍵の強さそのものです。鍵が単語なら、有効なトークンを1つ持っている人は、レート制限もこちらのログもないオフラインで推測を試せます。候補ごとに HMAC を計算し、署名と比べるだけです。手元で作ったトークンに対し、自作の20語のリストを5通りに変形(そのまま、先頭大文字、+123、+2026、先頭大文字+!)した計100候補で試しました。日本の現場でありがちな「サービス名や季節の単語+年」を想定し、鍵は sakura2026 にしています。
# jwt_crack.py - 手元だけのデモ:弱い HS256 鍵 vs 自作の小さなリスト
import hashlib, hmac, secrets, time
from jwt_min import sign_hs256, b64url_decode
WORDS = ["secret", "password", "changeme", "jwtsecret", "mysecret", "admin", "letmein",
"qwerty", "dragon", "sunshine", "himitsu", "sakura", "saranghae", "hangang",
"secreto", "contrasena", "futbol", "tequila", "dev", "test"] # 自作の20語
def candidates():
for w in WORDS:
for v in (w, w.capitalize(), w + "123", w + "2026", w.capitalize() + "!"):
yield v.encode()
token = sign_hs256({"sub": "user-42", "role": "user", "exp": 1794618000}, b"sakura2026")
h, p, s = token.split(".")
msg, target = f"{h}.{p}".encode(), b64url_decode(s)
t0 = time.perf_counter_ns(); tried = 0
for key in candidates():
tried += 1
if hmac.compare_digest(hmac.new(key, msg, hashlib.sha256).digest(), target):
print(f"found {key.decode()!r} after {tried} of 100 candidates "
f"in {(time.perf_counter_ns() - t0) / 1e6:.3f} ms")
break
rates = [] # HMAC の生の速度:ランダム鍵20万個×5回
keys = [secrets.token_bytes(12) for _ in range(200_000)]
for _ in range(5):
t0 = time.perf_counter_ns()
for k in keys: hmac.new(k, msg, hashlib.sha256).digest()
rates.append(len(keys) / ((time.perf_counter_ns() - t0) / 1e9))
rates.sort()
print(f"HMAC-SHA256 guesses/s (median of 5): {rates[2]:,.0f} runs={[round(x) for x in rates]}")
found 'sakura2026' after 59 of 100 candidates in 0.139 ms
HMAC-SHA256 guesses/s (median of 5): 546,600 runs=[543470, 545786, 546600, 547443, 548862]
シングルスレッドの素の Python で、毎秒約 54.7万回 試せました。GPU を使う専用の解析ツールははるかに速いのですが、ここでは実行していないので、表のその列は毎秒10¹⁰回という仮定の値です。
| 秘密鍵 | 候補の数 | 実測 546,600回/秒での平均時間 | 仮定 10¹⁰回/秒 |
|---|---|---|---|
| 辞書の単語(+変形) | 約100〜数百万 | ミリ秒〜秒 | 一瞬 |
| ランダムな小文字8文字 | 26⁸ | 2.2日 | 10秒 |
| ランダムな英数字12文字 | 62¹² | 約9,400万年 | 約5,100年 |
secrets.token_bytes(32) |
2²⁵⁶ | 約3.4×10⁶³年 | 約1.8×10⁵⁹年 |
対策: 鍵は CSPRNG で1回だけ作り、コードの外に置きます。
$ python3 -c "import secrets; print(secrets.token_urlsafe(32))"
$ openssl rand -base64 32
保存先は環境変数かシークレット管理サービスで、Git には入れません。鍵を入れ替えられるよう、ヘッダーに kid(鍵 ID)を入れておきます。検証時は kid が指す鍵を使いますが、それは自分で決めた固定の鍵の一覧の中からだけです。
ルール: HS256 の秘密鍵はパスワードではなく鍵。ランダム32バイト、単語は使わない。
exp・aud・nbf と時計のずれ
署名が証明するのは「鍵を持つ側が作った」ことだけです。「いま・ここで」受け入れてよいかはクレームが決めます。
exp(有効期限):過ぎたら拒否。expがないトークンも拒否しています。期限のないトークンは永久パスワードと同じです。アクセストークンは短く(5〜15分)。nbf(これより前は無効)とiat(発行時刻):未来から来たトークンの検出に使えます。aud(オーディエンス):api.example.com用のトークンでadmin.example.comに入れてはいけません。鍵を共有していてもです。上の出力で拒否されているのが確認できます。iss(発行者):トークンを発行するサービスが複数あるなら検査します。- 猶予(leeway):サーバー同士の時計は数秒ずれます。ここでは30秒。大きくすると、すべてのトークンの寿命がこっそり延びます。
トークンをどこに保存するか(説明のみ・未実行)
現実の事故の多くは保存場所で起きます。この記事ではブラウザは動かしていません。
| 保存場所 | XSS で読める? | 自動送信される(CSRF の危険)? | メモ |
|---|---|---|---|
localStorage |
読める | されない | XSS が1つあれば全トークンが漏れる |
HttpOnly; Secure; SameSite=Lax の Cookie |
読めない | される(SameSite で制限) | CSRF 対策とセットで。Cookie とセッションを参照 |
| メモリだけ(JS の変数) | ページ実行中のみ | されない | 再読み込みで消える。リフレッシュトークンを HttpOnly Cookie に |
| スマホ:iOS Keychain / Android Keystore | 対象外 | されない | SharedPreferences や平文ファイルは避ける |
そして最初に考えるべき問いは「そもそも JWT が必要か」です。JWT は exp の前に取り消すには、サーバー側の一覧が要ります。それはまたセッションストアです。1つの Web アプリが自分のバックエンドと話すだけなら、HttpOnly Cookie に入れたランダムなセッション ID(第3回)のほうが単純で、取り消しもできます。JWT が役に立つのは、別々のサービスが中央のストアに問い合わせずにトークンを確かめたいときです。セッション側の話は PHP のセッション対策をどうぞ。
ほかに知っておきたい落とし穴(説明のみ):アルゴリズム混同(RS256 と HS256 の両方を受け入れるサーバーが、RSA の公開鍵を HMAC の秘密鍵として HS256 トークンを検証してしまう。鍵ごとにアルゴリズムを1つに固定する)、kid インジェクション(kid をファイルパスや SQL の値に使う。PDO と SQL インジェクションを参照)、攻撃者のサーバーの鍵を指す jku/x5u ヘッダー、ライブラリの decode と verify の取り違え(decode は署名を見ない)。
正直な限界とバージョンの注意
- JWT は RFC 7519(2015年)、署名付きの形式は JWS(RFC 7515)、
"none"は RFC 7518 で定義されています。RFC 8725(2020年)がアルゴリズムの許可リストを含むベストプラクティスをまとめています。JWT ライブラリ(PyJWT、firebase/php-jwt、jose など)は実行していないので、特定バージョンの既定動作については何も主張しません。アルゴリズムを固定する引数は、使うライブラリの文書で確認してください。 - ここのコードは HS256 とコンパクト形式だけに対応し、
iss・typ・critは検査していません。教材であり、本番に出すものではありません。 - 毎秒546,600回はシングルスレッドの CPython 3.9.6 の値です。10¹⁰回/秒の列は仮定です。
練習問題
- 同僚の検証器は、まずペイロードをデコードして
expを確認し、そのあとで署名を確認する。正しいトークンでは結果は同じ。何か問題はあるか。 sign_hs256のヘッダーに"kid": "2026-11"を加える1行の変更を書け。kidを悪用されないために、検証器のどこをどう変えるか。
解答
- 動きはしますが、本物かどうか分からないうちに、攻撃者が自由に作れる JSON をパースしてロジックを走らせています。その部分のバグ(巨大な数でのクラッシュ、クレームをログに書くなど)は鍵なしで突けます。先に署名を検証し、それからクレームを信用して検査します。
header = {"alg": "HS256", "typ": "JWT", "kid": "2026-11"}。検証器はKEYS = {"2026-11": ..., "2026-05": ...}のような固定の辞書から鍵を引き、知らないkidは拒否します。kidをパス・URL・SQL の値として使ってはいけません。
次へ
- 前回:セキュリティ基礎講座 3/6:secrets で安全なトークンを作る
- 次回:セキュリティ基礎講座 5/6:AES-GCM による共通鍵暗号を正しく使う(ペイロードを本当に秘密にしたいときに)
- プログラミング学習ロードマップに戻る。JWT は必ず HTTPS で:HTTPS・TLS・証明書
よくある質問
JWT のペイロードは暗号化されている?
されていません。署名付き JWT(JWS)は base64url にした JSON と署名だけで、トークンを持っている人なら誰でも中身を読めます。記事では鍵なしで base64.urlsafe_b64decode するだけで「佐藤」を含むペイロードが表示されました。署名が証明するのは「誰が作ったか」と「改ざんされていないか」だけです。
JWT の alg: none 脆弱性とは?
トークン自身のヘッダーからアルゴリズムを読む検証器に alg: none(署名なし)を指定させる攻撃です。記事の素朴な検証器は、署名を空にして role を admin に書き換えた偽トークンを受け入れました。対策は、サーバー側でアルゴリズムを HS256 だけに固定し、それ以外は署名を見る前に拒否することです。
HS256 の秘密鍵はどれくらいの長さが必要?
CSPRNG から32バイト(secrets.token_bytes(32) など)を使ってください。辞書の単語ベースの鍵は100候補のリストから0.14ミリ秒で見つかりました。実測の毎秒約55万回でも、ランダム32バイトなら平均約3.4×10^63年かかります。