「JWT の署名は HS256 と RS256、どっちがいいんですか?」
この質問に「RS256 のほうが安全です」と答えるのは、半分正しくて、半分は間違いです。両者は強度が違うのではなく、信頼の置き方が違うからです。RFC 7518 は HS256 を Required(実装必須)、RS256 を Recommended と規定しています。仕様上、HS256 は「弱い方式」として扱われてはいません。
にもかかわらず現場で事故が起きるのは、ほぼ例外なく 「その鍵を誰に配ったか」を設計しなかったときです。HS256 の共有鍵を検証側マイクロサービス5つに配った瞬間、あなたのシステムにはトークン発行者が6人に増えます。これは実装バグではなく、選定の帰結です。
この記事は、その選定を「なんとなく」ではなく仕様の条文と自環境の実測値で決着させることを目的にします。扱う範囲は次の通りです。
- 両者の本質的な違い(信頼境界の数)を1枚の表で確定させる
- RFC 7518 / RFC 8725 / RFC 9068 が課す MUST 要件を条文で確認する
- 署名・検証速度とトークン長を自分の環境で実測し、数字の桁を知る
- アルゴリズム混同攻撃が今も生きていることを一次資料(NDSS 2026)で確認する
josev6 による本番運用に耐える検証層を型安全に実装する- 鍵ローテーションと HS256 → RS256 の無停止移行を手順化する
- 攻撃を退行テストで機械的に固定する
この記事のルール:仕様の出典は RFC(IETF)・NIST SP・OpenID Foundation の一次情報です。ベンチマークは私の手元環境の実測値で、スクリプトを本文に載せているので追試できます。数字は環境に強く依存するため、そのまま引用せず自分の環境で測ってください。ライブラリの API は
jose@6.2.8の型定義で確認しています。
0. 結論:3つの質問で決まる
長い記事ですが、結論はここです。順番に答えてください。
Q1. そのトークンを検証する主体は、発行する権限を持ってよいか?
├─ NO → RS256 / ES256(非対称) ← ここで決着することが多い
└─ YES → Q2 へ
Q2. 検証者は将来にわたって1つの信頼ドメインに閉じるか?
(マイクロサービス分割・BFF追加・別チームへの提供予定はないか)
├─ NO → RS256 / ES256
└─ YES → Q3 へ
Q3. 32バイトの暗号論的乱数鍵を、シークレットマネージャで
安全に配布・ローテーションできる運用があるか?
├─ NO → RS256(公開鍵配布のほうが運用事故に強い)
└─ YES → HS256 で十分。速く、短く、鍵管理も単純。
| HS256 | RS256 | |
|---|---|---|
| 分類 | 対称鍵 MAC(HMAC-SHA256) | 非対称鍵署名(RSASSA-PKCS1-v1_5 + SHA-256) |
| 検証に必要な鍵 | 署名と同じ秘密鍵 | 公開鍵(秘密不要) |
| 「検証できる者」は | 偽造もできる | 偽造できない |
| 信頼境界 | 発行者=検証者の1ドメインに閉じる | 発行と検証を分離できる |
| RFC 7518 の実装要件 | Required | Recommended |
| 鍵長の MUST | ハッシュ出力長以上(256bit 以上) | 2048bit 以上 |
| 鍵配布 | 秘密の共有(配布先が増えるほど危険) | 公開鍵を JWKS で公開するだけ |
| ローテーション | 全検証者に同時配布が必要 | JWKS に新 kid を足すだけ |
| 典型的な用途 | モノリスのセッショントークン、内部の短命トークン、Webhook 署名 | OIDC の ID トークン、OAuth 2.0 アクセストークン、マルチサービス/マルチテナント |
「速いから HS256」は選定理由になりません。(後述の実測で、検証コストの差は1リクエストあたり0.014ミリ秒です。)「鍵を配りたくないから RS256」が選定理由です。
1. メンタルモデル:「読むのに鍵が要るか」と「書き換えるのに鍵が要るか」
JOSE(JSON Object Signing and Encryption)を扱ううえで、私が最も有用だと考えているのは Tom Tervoort が Black Hat USA 2023 のホワイトペーパーで示した整理です。JWT の各形態を、**「読むのに秘密が要るか」「書き換えるのに秘密が要るか」**の2軸で並べます。
alg:none JWS | 対称 JWS(HS256) | 非対称 JWS(RS256) | 対称 JWE | 非対称 JWE | |
|---|---|---|---|---|---|
| 読むのに秘密が要る | no | no | no | yes | yes |
| 書き換えるのに秘密が要る | no | yes | yes | yes | no |
同ホワイトペーパーは HS256 と RS256 の違いをこう述べています。「HS256 のような対称 JWS アルゴリズムを使う場合、JWT は同じ共有シークレットで作成も検証もできる。一方 RS256 のような非対称アルゴリズムを使う場合、JWT は秘密鍵の所有者だけが作成でき、検証は誰でもできる」。
ここから、実務上の一行結論が出ます。
HS256 における「検証鍵を渡す」は、「発行権限を渡す」と同義です。
Base64url は暗号化ではないので、どちらの方式でもペイロードは誰でも読めます(表の1行目が全部 no であることに注意)。JWT に個人情報や内部 ID を入れてよいかという問題は、HS256/RS256 の選択とは独立です。混同しないでください。
1.1 図で見る信頼境界
【HS256】共有鍵モデル — 鍵の配布先ぶんだけ「発行者」が増える
┌──────────────┐
┌─────────────►│ 注文サービス │ K を持つ → 偽造可能 ⚠
│ └──────────────┘
┌────┴─────┐ ┌──────────────┐
│ 認証サーバ │───────►│ 在庫サービス │ K を持つ → 偽造可能 ⚠
│ 鍵 K │ └──────────────┘
└────┬─────┘ ┌──────────────┐
└─────────────►│ 通知サービス │ K を持つ → 偽造可能 ⚠
└──────────────┘
※ 1サービスの侵害 = 認証基盤全体の侵害
【RS256】公開鍵モデル — 発行者はひとりだけ
┌──────────────┐
┌─────────────►│ 注文サービス │ 公開鍵のみ → 検証だけ ✅
│ JWKS(公開鍵) └──────────────┘
┌────┴─────┐ ┌──────────────┐
│ 認証サーバ │───────►│ 在庫サービス │ 公開鍵のみ → 検証だけ ✅
│ 秘密鍵 sk │ └──────────────┘
└────┬─────┘ ┌──────────────┐
└─────────────►│ 通知サービス │ 公開鍵のみ → 検証だけ ✅
└──────────────┘
※ 1サービスが侵害されても、トークンは偽造できない
この差は、**インシデント時の被害半径(blast radius)**の差として現れます。HS256 でサービスAのログに鍵が漏れたら、認証基盤全体を止めて全サービスの鍵を同時に入れ替える必要があります。RS256 なら、漏れたのは公開鍵なので何も起きません。
2. 仕様が何を要求しているか(RFC 7518 / RFC 8725)
「なんとなく安全」で設計しないために、条文を確認します。
2.1 RFC 7518 3.1節:アルゴリズムの実装要件
RFC 7518(JSON Web Algorithms)3.1節は、JWS の alg パラメータ値と実装要件を次のように定めています(主要なもの)。
alg | 内容 | 実装要件 |
|---|---|---|
| HS256 | HMAC using SHA-256 | Required |
| HS384 / HS512 | HMAC using SHA-384 / SHA-512 | Optional |
| RS256 | RSASSA-PKCS1-v1_5 using SHA-256 | Recommended |
| RS384 / RS512 | RSASSA-PKCS1-v1_5 using SHA-384 / SHA-512 | Optional |
| ES256 | ECDSA using P-256 and SHA-256 | Recommended+ |
| ES384 / ES512 | ECDSA using P-384 / P-521 | Optional |
| PS256 / PS384 / PS512 | RSASSA-PSS + MGF1 | Optional |
| none | No digital signature or MAC performed | Optional |
読み取るべきポイントは3つです。
- HS256 は「Required」。つまり全ての適合実装が持つ、最も相互運用性の高いアルゴリズムです。「弱いから避けろ」という位置づけではありません。
- ES256 だけが「Recommended+」。RFC 7518 の用語で
+は「実装要件が将来引き上げられる可能性が高い」ことを示します。長期の新規設計では ES256 が仕様の推す方向です。 noneも Optional として仕様に存在する。これが後述するすべての事故の源です。
2.2 鍵長は「SHOULD」ではなく「MUST」
ここは妥協できない箇所です。条文を引用します。
- HS256(3.2節):「ハッシュ出力と同じサイズ(例えば "HS256" なら256ビット)以上の鍵を使わなければならない(MUST)」。さらに「計算した HMAC 値と JWS Signature 値の比較は、タイミング攻撃を防ぐため定数時間で行わなければならない(MUST)」。
- RS256(3.3節):「これらのアルゴリズムでは2048ビット以上のサイズの鍵を使わなければならない(MUST)」。
- PS256(3.5節):同じく2048ビット以上が MUST。
「256ビット以上」を "my-secret-key"(13バイト=104ビット)で満たしたつもりになっている実装を、私は何度も見ています。文字列の見た目の長さと、鍵のエントロピーは別物です。
2.3 エントロピー:HS256 の本当の強度を決めるもの
NIST SP 800-57 Part 1 Rev.5 の Table 3(ハッシュおよびハッシュベース関数の最大セキュリティ強度)には、見落とされがちな注記があります。
「HMAC や KMAC のように鍵を必要とする場合、推定セキュリティ強度は、鍵の生成に使われた長さとエントロピーが少なくともそのセキュリティ強度と同等であることを前提とする」
つまり、HMAC-SHA256 が持つ強度は「SHA-256 だから256ビット」ではなく、鍵のエントロピーで決まるということです。ここを踏まえて RFC 8725 の該当条文を読むと、その厳しさが理解できます。
- RFC 8725 2.2節(弱い対称鍵):「一部のアプリケーションは "HS256" のような鍵付き MAC アルゴリズムでトークンに署名するが、(人間が記憶できるパスワードのような)エントロピーの不十分な弱い対称鍵を与えている。そのような鍵は、攻撃者がトークンを1つ入手した時点で、オフラインのブルートフォース攻撃や辞書攻撃に対して脆弱である」
- RFC 8725 3.5節:「特に、人間が記憶可能なパスワードを "HS256" のような鍵付き MAC アルゴリズムの鍵として直接使ってはならない(MUST NOT)」
「オフラインで」というのが厄介なところです。攻撃者は正規のトークンを1つ手に入れれば、あとはサーバに一切アクセスせず、手元の GPU で好きなだけ鍵を試せます。レートリミットも WAF も効きません。
正しい HS256 鍵の生成:
# 32バイト(256ビット)の暗号論的乱数。これが最低ライン。
openssl rand -base64 32
# 出力例は44文字の base64。例示値をそのまま使い回さないこと
# Node.js なら
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
// ❌ 絶対にやってはいけない
const secret = new TextEncoder().encode("supersecret2026");
// ❌ これも同罪(env 経由でも、値が人間由来なら意味は同じ)
const secret = new TextEncoder().encode(process.env.JWT_SECRET!); // 値が "changeme" だったら?
// ✅ 起動時にエントロピー要件を検証して fail-fast させる
import { z } from "zod";
/**
* JWT 署名鍵は base64url エンコードされた 32 バイト以上の乱数であることを起動時に強制する。
* RFC 7518 3.2 は「ハッシュ出力長以上」を MUST とするが、実際の強度は鍵のエントロピーで
* 決まる(NIST SP 800-57 Pt.1 Rev.5 Table 3 注記)。長さだけでなく由来も規約で縛る。
*/
const HmacSecretSchema = z
.string()
.min(43, "JWT_SIGNING_KEY must be >= 32 bytes (base64url). Generate with: openssl rand -base64 32")
.transform((raw, ctx) => {
// Node の base64 デコーダはアルファベット外の文字を黙って捨てる。素朴に復号するだけだと
// "MyVeryLongButTotallyMemorablePassword..." のような人間由来の文字列も34バイトに
// 「復号」できてしまうので、再エンコードして正準形であることを確かめる
const normalized = raw.replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, "");
const bytes = Buffer.from(normalized, "base64");
if (!/^[A-Za-z0-9+/]+$/.test(normalized) ||
Buffer.from(bytes).toString("base64").replace(/=+$/, "") !== normalized) {
ctx.addIssue({ code: "custom", message: "key must be canonical base64/base64url from a CSPRNG" });
return z.NEVER;
}
if (bytes.byteLength < 32) {
ctx.addIssue({ code: "custom", message: "decoded key must be >= 256 bits" });
return z.NEVER;
}
return new Uint8Array(bytes);
});
// モジュール読み込み時に評価 → 弱い鍵ではプロセスが起動しない
export const HMAC_SECRET = HmacSecretSchema.parse(process.env.JWT_SIGNING_KEY);
z.NEVER を返すことで、transform の失敗が型レベルでも正しく伝播します。
ただし、このスキーマが弾けるのは「短い鍵」と「乱数出力の形をしていない文字列」までです。 エントロピーは文字列からは検証できません。base64 のアルファベットだけでできた44文字なら、aaaa… のような明らかに弱い値でもこのゲートは通過します。鍵の由来を担保するのはスキーマではなく運用です。生成を openssl rand かシークレットマネージャに限定し、人間が値を決める経路を残さないでください。スキーマの役割は「事故を起動時に落とす」ことであって、「強い鍵であることの証明」ではありません。
3. 実測:速度とトークン長の「桁」を知る
選定の議論が空中戦になるのは、誰も数字を持っていないからです。手元で測りました。この数値は私の環境のものです。判断の前に必ず自分の環境で測ってください。
測定環境:Node.js v26.0.0 / darwin arm64(Apple Silicon)/ node:crypto(OpenSSL)を直接呼び出し、各2000回。JWT ライブラリのオーバーヘッドは含みません。
3.1 署名・検証コスト
| アルゴリズム | 署名(µs/回) | 署名(回/秒) | 検証(µs/回) | 検証(回/秒) |
|---|---|---|---|---|
| HS256 | 1.8 | 541,272 | 1.5 | 652,227 |
| RS256(RSA-2048) | 410 | 2,439 | 15.9 | 63,091 |
| RS256(RSA-4096) | 2,989.7 | 334 | 57.6 | 17,362 |
| ES256(P-256) | 27.6 | 36,188 | 52.8 | 18,945 |
| EdDSA(Ed25519) | 24.8 | 40,391 | 83.6 | 11,958 |
ここから読み取れることは、一般に語られる「RS256 は遅い」という単純な話ではありません。
- 検証(毎リクエストで走る側)の差は 15.9µs 対 1.5µs。比では約10倍ですが、差の絶対量は1リクエストあたり0.014ミリ秒です。DB クエリ1本が数ミリ秒であることを考えると、ボトルネックになるのは相当な高スループット環境に限られます。
- 署名の差は約222倍と桁違い(410µs 対 1.8µs)。RSA の秘密鍵演算は本質的に重い処理です。毎リクエストでトークンを再発行する設計は RS256 と相性が悪い——これは実際に選定に効く情報です。
- ECDSA/EdDSA は「検証のほうが署名より重い」。RSA は公開指数が小さいため検証が非常に軽く、実測でも RS256-2048 の検証(15.9µs)は ES256 の検証(52.8µs)より速いという逆転が起きています。「楕円曲線のほうが常に速い」は誤りです。
- RSA-4096 は署名が RSA-2048 の約7.3倍重い。鍵長を上げるなら、素直に ES256 に移るほうが費用対効果は良好です。
3.2 トークン長
同一クレーム(iss / sub / aud / exp / iat / jti / scope、typ: "at+jwt" と kid 付き)で測った compact serialization のバイト数です。
ヘッダとペイロードが同一なら、差は署名部の長さだけで決まります。base64url 後の署名長は HMAC-256 が43文字、P-256/Ed25519 が86文字、RSA-2048 が342文字、RSA-4096 が683文字なので、HS256 の332バイトを基準に各行は算術的に再現できます。
| アルゴリズム | トークン長(bytes) | HS256 比 |
|---|---|---|
| HS256 | 332 | 1.00× |
| ES256(P-256) | 375 | 1.13× |
| EdDSA(Ed25519) | 375 | 1.13× |
| RS256(RSA-2048) | 631 | 1.90× |
| RS256(RSA-4096) | 972 | 2.93× |
RS256 のトークンは HS256 の約2倍になります。これはレイテンシよりも実務的な制約として効きます。
- Cookie に載せる場合、ブラウザの1 Cookie あたり上限(一般に4KB)に対する余裕
Authorizationヘッダに載せる場合、リバースプロキシのヘッダ長上限(nginx のlarge_client_header_buffers既定は 8KB/1行あたり)- 全 API リクエストに乗るため、299バイト × リクエスト数の egress 転送量
秒間1万リクエストなら 299B × 10,000 = 約3.0MB/秒の差です。無視できる規模のことが多いですが、「無視できる」と測ってから言うべき数字です。
3.3 追試用スクリプト
そのままコピーして node bench.mjs で走ります。
// bench.mjs — HS256 / RS256 / ES256 / EdDSA の署名・検証コストを測る
import {
createSign, createVerify, createHmac, generateKeyPairSync,
timingSafeEqual, sign as nodeSign, verify as nodeVerify,
} from "node:crypto";
const N = 2000;
const data = Buffer.from("eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1XzAxIn0");
/** ウォームアップ1回のあと N 回計測し、1回あたりのマイクロ秒とスループットを返す */
const bench = (label, fn) => {
fn();
const start = process.hrtime.bigint();
for (let i = 0; i < N; i++) fn();
const ms = Number(process.hrtime.bigint() - start) / 1e6;
return { label, opsPerSec: Math.round(N / (ms / 1000)), usPerOp: +((ms * 1000) / N).toFixed(1) };
};
const rows = [];
const secret = Buffer.alloc(32, 7);
const mac = createHmac("sha256", secret).update(data).digest();
rows.push(bench("HS256 sign", () => createHmac("sha256", secret).update(data).digest()));
rows.push(bench("HS256 verify", () =>
timingSafeEqual(createHmac("sha256", secret).update(data).digest(), mac)));
for (const bits of [2048, 4096]) {
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: bits });
const sig = createSign("sha256").update(data).sign(privateKey);
rows.push(bench(`RS256 sign (RSA-${bits})`, () => createSign("sha256").update(data).sign(privateKey)));
rows.push(bench(`RS256 verify (RSA-${bits})`, () => createVerify("sha256").update(data).verify(publicKey, sig)));
}
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
const ecSig = createSign("sha256").update(data).sign(ec.privateKey);
rows.push(bench("ES256 sign (P-256)", () => createSign("sha256").update(data).sign(ec.privateKey)));
rows.push(bench("ES256 verify (P-256)", () => createVerify("sha256").update(data).verify(ec.publicKey, ecSig)));
const ed = generateKeyPairSync("ed25519");
const edSig = nodeSign(null, data, ed.privateKey);
rows.push(bench("EdDSA sign (Ed25519)", () => nodeSign(null, data, ed.privateKey)));
rows.push(bench("EdDSA verify (Ed25519)", () => nodeVerify(null, data, ed.publicKey, edSig)));
console.log(process.version, process.arch, process.platform);
console.table(rows);
4. 危険地帯:アルゴリズム混同は「まだ生きている」
HS256 と RS256 を語るとき避けて通れないのが、両者を混ぜられるという構造的な弱点です。
4.1 攻撃の仕組み
RFC 8725 2.1節の記述がそのまま説明になっています。
「"RS256"(RSA, 2048ビット)というパラメータ値が "HS256"(HMAC, SHA-256)に変更されうる。すると一部のライブラリは、RSA 公開鍵を HMAC の共有シークレットとして使い、HMAC-SHA256 で署名を検証しようとする」
RS256 の公開鍵は、定義上公開されています(JWKS エンドポイントから誰でも取得できます)。したがって攻撃者は次の手順で任意のトークンを偽造できます。
1. GET https://auth.example.com/.well-known/jwks.json → 公開鍵 pub を入手
2. 正規ログインでトークンを1つ入手し、payload の "role":"user" を "role":"admin" に書き換え
3. header の "alg":"RS256" を "alg":"HS256" に書き換え
4. HMAC-SHA256(key = pub のバイト列, header.payload) で署名を計算
5. サーバは alg を見て HMAC 検証を選び、鍵として pub を使う → 検証成功 → 権限昇格
根本原因は**「検証に使うアルゴリズムを、攻撃者が書き換えられるトークン自身から読み取っている」ことです。Tervoort はこれを JOSE 設計への批判としてこう書いています。「どの暗号アルゴリズムを使うかを示す alg パラメータがトークン自身の一部**になっている。トークンは攻撃者に偽造されている可能性があるのだから、これは本質的に、検証者にどの暗号を使うかを指示しているのが攻撃者だということを意味する」。
4.2 「もう対策済み」ではない — NDSS 2026 の実測
2015年に知られた古い攻撃だから対策済みだろう、と思いたくなります。しかし NDSS Symposium 2026 で発表された 『Token Time Bomb: Evaluating JWT Implementations for Vulnerability Discovery』(清華大学・国防科技大学)は、そうではないことを示しました。
同論文は JWTeemo というファジングツールを開発し、10のプログラミング言語にまたがる43のJWTライブラリを体系的に評価しています。結果は次の通りです。
| 項目 | 数値 |
|---|---|
| 評価対象 | 43ライブラリ / 10言語 |
| 発見された未知の脆弱性 | 31件 |
| CVE 採番済み | 20件 |
| Algorithm Confusion | 2件 |
| Sign/Encryption Confusion | 2件 |
| JWT Format Confusion | 4実装 |
| Billion Hashes Attack(DoS) | 10件 |
| Compression DoS | 13件 |
論文が挙げる Algorithm Confusion の該当例は CVE-2024-57453(libjwt / C) と CVE-2024-57454(cpp-jwt / C++) です。2024年以降に採番された CVE であり、歴史の話ではありません(ただし両IDは2026年8月時点で NVD/MITRE には未公開で、出典は論文 Table I です)。論文はこの攻撃シナリオを次のように記述しています。
「攻撃者はまず RSA 署名の検証に使われる公開鍵を入手し、通常どおりログインして自分のロールを含む JWS を得る。次に JWT ヘッダの
algを HS256 に変更し、ペイロードのロールを admin に書き換える。最後に、入手した公開鍵を HMAC のシークレットとして JWT に署名し、偽造 JWT を作成する」
なお同論文は Apache / Kubernetes / Let's Encrypt / RedHat / Connect2id から謝辞やバグバウンティを受けており、緩和策について IETF と議論したと述べています。JWT の実装品質は、いまも動いている問題です。
4.3 対策:alg は「トークンから読む」のではなく「アプリが決める」
対策は単純で、例外を作らないことです。
RFC 8725 3.1節(アルゴリズム検証の実施):
「ライブラリは、サポートするアルゴリズムの集合を呼び出し側が指定できるようにしなければならず(MUST)、暗号操作を行う際にそれ以外のアルゴリズムを使ってはならない(MUST NOT)」
「ライブラリは、
algまたはencヘッダが、実際に行う暗号操作と同じアルゴリズムを指定していることを保証しなければならない(MUST)」「各鍵はちょうど1つのアルゴリズムとともに使わなければならず(MUST)、暗号操作を行う際にこれをチェックしなければならない(MUST)」
3つ目の「1鍵1アルゴリズム」が、アルゴリズム混同を構造的に殺す条文です。RSA 公開鍵を HMAC のシークレットとして使うことがそもそも不可能になるからです。
**RFC 8725 3.2節(適切なアルゴリズムの使用)**は none について、TLS などで別途暗号的保護がある場合に限り許容される、としたうえで次のように述べます。
「JWT ライブラリは、呼び出し側が明示的に要求しない限り、
noneを使って JWT を生成するべきではない(SHOULD NOT)。同様に、明示的に要求されない限りnoneの JWT を消費するべきではない(SHOULD NOT)」
実装としては、次を守れば十分です。
// ❌ ライブラリのデフォルトに任せる(alg を検証していない)
await jwtVerify(token, key);
// ✅ アプリが alg を決める。トークンの申告は判断材料にしない。
await jwtVerify(token, key, { algorithms: ["RS256"] });
jose はこの点で堅牢に設計されており、型定義にも「Unsecured JWTs({"alg":"none"})はこの API では決して受理されない」と明記されています。それでも algorithms は明示してください。将来 key の型が変わったときに守ってくれるのは、この1行だけです。
5. 本番実装:型安全で観測可能な検証層をつくる
ここからは実装です。方針を先に述べます。
- 検証は1か所に集約する(SRP/DRY)。各ルートで
jwtVerifyを呼ばない。 - 例外は境界で
Result型に閉じ込める。認証失敗は「例外的事態」ではなく「通常の分岐」。 - 失敗理由を分類してメトリクス化する。ただしトークンや PII はログに出さない。
- JWKS の取得失敗時は fail-closed。「取れなかったから通す」は絶対にやらない。
- クレームの形は Zod で検証する。署名が正しいことと、中身が期待通りであることは別問題。
5.1 共通の型定義(発行側・検証側の SSoT)
// src/auth/claims.ts
import { z } from "zod";
/**
* アプリが受理するアクセストークンのクレーム契約。
* jose は iss/aud/exp/nbf/iat の「存在と一致」を検証するが、
* アプリ固有クレーム(scope / tenant)の形までは知らない。ここが最後の砦。
*/
export const AccessTokenClaimsSchema = z.object({
iss: z.url(),
sub: z.string().min(1),
aud: z.union([z.string(), z.array(z.string()).nonempty()]),
exp: z.number().int().positive(),
iat: z.number().int().positive(),
jti: z.string().min(1),
/** RFC 6749 のスペース区切り scope。配列へ正規化して扱う */
scope: z
.string()
.transform((s) => s.split(" ").filter(Boolean))
.pipe(z.array(z.string()).nonempty()),
/** マルチテナントの所属。RLS などデータ層の認可キーになるため必須 */
tenant_id: z.uuid(),
});
export type AccessTokenClaims = z.infer<typeof AccessTokenClaimsSchema>;
/** 認証失敗の分類。メトリクスの次元になるので、増やすときは意味を1つに保つ */
export type AuthFailureReason =
| "malformed" // JWT の形をしていない
| "alg_not_allowed" // 許可していない alg(=混同攻撃の疑い)
| "signature" // 署名不一致
| "expired" // exp 超過
| "claim" // iss/aud/typ/sub などの不一致
| "schema" // 署名は正しいがクレームの形が契約違反
| "unknown_kid" // kid に対応する鍵がない(kid は攻撃者が任意に指定できる入力)
| "key_unavailable"; // JWKS の取得自体に失敗=こちらの障害(≠ トークンが不正)
export type AuthResult =
| { readonly ok: true; readonly claims: AccessTokenClaims }
| { readonly ok: false; readonly reason: AuthFailureReason };
AuthResult を判別可能ユニオンにしておくと、呼び出し側で if (!result.ok) を書き忘れた時点でコンパイルが落ちます。認証チェック漏れを型で防ぐのが狙いです。
5.2 RS256 の検証(JWKS + fail-closed)
// src/auth/verify-rs256.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
// jose v6 はエラークラスをルートから export しない。サブパス "jose/errors" から取る
import {
JOSEAlgNotAllowed,
JWKSNoMatchingKey,
JWKSTimeout,
JWSSignatureVerificationFailed,
JWTExpired,
JWTClaimValidationFailed,
JWTInvalid,
} from "jose/errors";
import { AccessTokenClaimsSchema, type AuthFailureReason, type AuthResult } from "./claims";
/**
* JWKS リゾルバはモジュールスコープで1つだけ持つ。
* - cacheMaxAge: 鍵をキャッシュする上限時間(既定10分)
* - cooldownDuration: 連続再取得の抑制(既定30秒)。未知の kid が来ても
* この間隔より頻繁には再取得しないため、JWKS エンドポイントへの DoS 踏み台化を防ぐ
* - timeoutDuration: 取得のタイムアウト(jose の既定は5秒。ここでは3秒に短縮している)。長すぎるとリクエストを道連れにする
*/
export const jwks = createRemoteJWKSet(new URL(process.env.JWKS_URI!), {
cacheMaxAge: 10 * 60_000,
cooldownDuration: 30_000,
timeoutDuration: 3_000,
});
const ISSUER = process.env.TOKEN_ISSUER!;
const AUDIENCE = process.env.TOKEN_AUDIENCE!;
export async function verifyAccessToken(
token: string,
// 鍵リゾルバは差し替え可能にする。既定は本番の JWKS、テストからはローカル JWKS を注入できる
keys: Parameters<typeof jwtVerify>[1] = jwks,
): Promise<AuthResult> {
try {
const { payload } = await jwtVerify(token, keys, {
// ① アプリが alg を決める。トークンの申告は信用しない(RFC 8725 §3.1)
algorithms: ["RS256"],
// ② トークン種別を固定してクロス JWT 混同を防ぐ(RFC 9068 §4 / RFC 8725 §3.11)
typ: "at+jwt",
// ③ 発行者と対象を固定(RFC 8725 §3.8 / §3.9)
issuer: ISSUER,
audience: AUDIENCE,
// ④ 時計ずれの許容は「秒」単位に留める。分単位はトークン失効を無意味にする
clockTolerance: 5,
// ⑤ 発行から時間が経ちすぎたトークンを弾く(exp とは独立の防御)
maxTokenAge: "1 hour",
// ⑥ 欠けていたら即失敗させるクレーム。既定では検証されないものを明示する
requiredClaims: ["exp", "sub", "jti", "tenant_id", "scope"],
});
// ⑦ 署名が正しいことと、中身が契約通りであることは別。ここで形を確定させる
const parsed = AccessTokenClaimsSchema.safeParse(payload);
if (!parsed.success) return { ok: false, reason: "schema" };
return { ok: true, claims: parsed.data };
} catch (error) {
return { ok: false, reason: classify(error) };
}
}
/** jose のエラークラスを、観測可能な有限の理由コードに落とす */
function classify(error: unknown): AuthFailureReason {
if (error instanceof JWTExpired) return "expired";
if (error instanceof JOSEAlgNotAllowed) return "alg_not_allowed";
if (error instanceof JWSSignatureVerificationFailed) return "signature";
if (error instanceof JWTClaimValidationFailed) return "claim";
if (error instanceof JWTInvalid) return "malformed";
// kid は攻撃者が任意に指定できる入力。「その kid の鍵が無い」はトークン側の問題として 401 に落とす
if (error instanceof JWKSNoMatchingKey) return "unknown_kid";
// 取得そのものの失敗=サーバ側の障害。トークンが不正だとは限らないので別扱いにする
if (error instanceof JWKSTimeout) return "key_unavailable";
return "signature"; // 未知の失敗は最も保守的な扱い(fail-closed)
}
設計上の要点を4つ。
algorithms/typ/issuer/audienceは全部書く。 どれか1つでも省くと、そこが攻撃面になります。特にtyp: "at+jwt"は RFC 9068 が「typの値がat+jwtまたはapplication/at+jwtであることを検証し、それ以外の値を持つトークンは拒否する」と定めている検証手順そのものです。ID トークンをアクセストークンとして使い回される事故(RFC 8725 2.8節「Cross-JWT Confusion」)を型レベルで塞ぎます。clockToleranceは秒単位に留める。 5分などにすると、失効させたはずのトークンが5分間生き続けます。NTP が効いている環境なら数秒で十分です。unknown_kidとkey_unavailableを分ける。 JWKS の取得自体が失敗したのはこちらの障害であり、攻撃ではありません。これをsignatureと混ぜると、IdP 障害時にアラートが「大量の署名検証失敗=攻撃」に見えてしまい、判断を誤ります。一方kidは攻撃者が任意に指定できる入力なので、「そのkidの鍵が無い」は障害ではなくトークン側の問題です。両者を同じ理由コードに畳むと、偽造トークンを投げるだけで 503 を返させられるうえ、攻撃の波形が「IdP 障害」に見えてしまいます。key_unavailableでも通さない(fail-closed)。joseのcreateRemoteJWKSetは取得成功後cacheMaxAgeの間キャッシュを保持するので、瞬断は自然に吸収されます。それでも引けないなら 503 を返すのが正しく、通してはいけません。
5.3 HS256 の検証(対称鍵の場合)
HS256 を選んだ場合も、algorithms の固定は同様に必須です。
// src/auth/verify-hs256.ts
import { jwtVerify } from "jose";
import { HMAC_SECRET } from "./env";
import { AccessTokenClaimsSchema, type AuthResult } from "./claims";
export async function verifySessionToken(token: string): Promise<AuthResult> {
try {
const { payload } = await jwtVerify(token, HMAC_SECRET, {
// 対称鍵しか渡していなくても明示する。将来 key が JWKS に変わったとき、
// この1行がアルゴリズム混同を防ぐ最後の防壁になる
algorithms: ["HS256"],
typ: "at+jwt",
issuer: process.env.TOKEN_ISSUER!,
audience: process.env.TOKEN_AUDIENCE!,
clockTolerance: 5,
requiredClaims: ["exp", "sub", "jti", "tenant_id", "scope"],
});
const parsed = AccessTokenClaimsSchema.safeParse(payload);
return parsed.success ? { ok: true, claims: parsed.data } : { ok: false, reason: "schema" };
} catch {
return { ok: false, reason: "signature" };
}
}
なお、HMAC の比較を定数時間で行う責務(RFC 7518 3.2節の MUST)は jose が内部で担っています。自前で HMAC を計算して === で比較する実装だけは絶対に書かないでください。
5.4 発行側(kid を必ず載せる)
// src/auth/issue.ts
import { SignJWT, importPKCS8 } from "jose";
import { randomUUID } from "node:crypto";
/** 秘密鍵は起動時に1度だけ import する。毎回パースするとRSA署名より重くなる */
const signingKey = await importPKCS8(process.env.SIGNING_PRIVATE_KEY_PEM!, "RS256");
const SIGNING_KID = process.env.SIGNING_KEY_ID!; // 例: "2026-08-a"
export async function issueAccessToken(input: {
subject: string;
tenantId: string;
scope: readonly string[];
}): Promise<string> {
return new SignJWT({ tenant_id: input.tenantId, scope: input.scope.join(" ") })
// kid はローテーションの生命線。これが無いと鍵を2本並行運用できない(RFC 7515 §4.1.4)
.setProtectedHeader({ alg: "RS256", kid: SIGNING_KID, typ: "at+jwt" })
.setIssuer(process.env.TOKEN_ISSUER!)
.setAudience(process.env.TOKEN_AUDIENCE!)
.setSubject(input.subject)
.setJti(randomUUID()) // 失効リスト/再生検知のキー
.setIssuedAt()
.setExpirationTime("15m") // アクセストークンは短命に。失効はリフレッシュ側で担う
.sign(signingKey);
}
5.5 可観測性:何を記録し、何を記録しないか
認証層の可観測性は、攻撃の兆候と自分の障害を切り分けられることが目的です。
// src/auth/middleware.ts
import { verifyAccessToken } from "./verify-rs256";
/**
* 記録してよいもの: 失敗理由コード、alg(ヘッダから読める)、kid、経過時間
* 記録してはいけないもの: トークン本体、payload、sub、メールアドレス等の PII
* → トークンをログに書くと、ログ閲覧権限がそのまま成りすまし権限になる
*/
export async function authenticate(req: Request): Promise<Response | AuthContext> {
const header = req.headers.get("authorization");
if (!header?.startsWith("Bearer ")) {
metrics.increment("auth.failure", { reason: "malformed" });
return unauthorized();
}
const result = await verifyAccessToken(header.slice(7));
if (!result.ok) {
metrics.increment("auth.failure", { reason: result.reason });
// 混同攻撃の試行は「珍しいイベント」なので、必ず単独でアラートを張る。
// 平常時ゼロのはずの系列が立ち上がったら、それは攻撃かクライアントの重大なバグ。
if (result.reason === "alg_not_allowed") {
logger.warn("jwt.alg_rejected", { path: new URL(req.url).pathname });
}
// 鍵が引けないのは IdP 側の障害。攻撃と混ぜないよう 503 で返す
if (result.reason === "key_unavailable") return serviceUnavailable();
return unauthorized();
}
metrics.increment("auth.success");
return { userId: result.claims.sub, tenantId: result.claims.tenant_id, scope: result.claims.scope };
}
平常時ゼロであるべき系列を明示的に持つのが要点です。auth.failure{reason="alg_not_allowed"} は、正常なクライアントからは絶対に発生しません。ここに1件でも立てば、それは攻撃試行か、クライアント実装の重大な事故です。この1本のアラートが、アルゴリズム混同攻撃に対する事後検知になります。
6. 鍵ローテーション:ここで HS256 と RS256 の差が最も開く
平常時の性能差は0.014ミリ秒でした。しかし運用では、両者の差は比較になりません。
6.1 NIST が示す更新周期
NIST SP 800-57 Part 1 Rev.5 の 5.3.6 節(暗号期間の推奨)は、鍵の種類ごとに次を推奨しています。
| 鍵の種類 | 推奨される originator-usage period | recipient-usage period |
|---|---|---|
| 対称認証鍵(HS256 の共有鍵に相当) | 2年以内 | originator 終了後3年を超えない |
| 秘密認証鍵(RS256 の秘密鍵に相当) | 1〜2年以内(利用環境と機微性に依存) | — |
| 公開認証鍵(RS256 の公開鍵に相当) | 1〜2年以内 | — |
同節は対称認証鍵について「MAC 鍵が危殆化した場合、攻撃者はデータを改変したうえで MAC を再計算できる可能性がある」と注意を促しています。HS256 の共有鍵が漏れることは、単に読まれることではなく、任意のトークンを発行されることです。
6.2 RS256 のローテーション(無停止でできる)
kid があれば、鍵の入れ替えは検証側を一切止めずに行えます。
Phase 1 (T+0) 新鍵ペア B を生成。JWKS に B の公開鍵を「追加」する(A も残す)
→ 検証側は kid で A/B どちらも引ける。署名はまだ A
★ cacheMaxAge(既定10分)ぶん待ち、全検証者が B を認識するまで進まない
Phase 2 (T+1h) 発行側の署名鍵を B に切り替える(SIGNING_KEY_ID = "2026-08-b")
→ 新規トークンは kid=B。既存の kid=A トークンも有効期限まで通る
Phase 3 (T+1h + アクセストークンTTL + 余裕)
A で署名されたトークンが全て期限切れになったことをメトリクスで確認
(auth.success{kid="2026-08-a"} が 0 になる)
Phase 4 JWKS から A の公開鍵を削除。秘密鍵 A を破棄
// .well-known/jwks.json — Phase 1〜3 の状態(RFC 7517)
{
"keys": [
{ "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "2026-08-a", "n": "...", "e": "AQAB" },
{ "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "2026-08-b", "n": "...", "e": "AQAB" }
]
}
各 JWK に alg を明記するのを忘れないでください。RFC 8725 3.1節の「1鍵1アルゴリズム」を JWKS 側からも宣言することになり、jose の createRemoteJWKSet は鍵選択時に alg・kid・use・key_ops を尊重します。
Phase 1 と Phase 2 の間に待機を入れるのが実務上の勘所です。ここを飛ばすと、JWKS をまだ再取得していない検証者が kid=B を引けず JWKSNoMatchingKey になります。jose のように未知の kid で再取得する実装なら失敗は cooldownDuration の間に限られますが、再取得しない実装では cacheMaxAge いっぱい失敗し続けます。
6.3 HS256 のローテーション(難易度が跳ね上がる)
対称鍵では「公開して待つ」ができません。全検証者に新しい秘密鍵を、無停止で、同時に配る必要があります。
Phase 1 新鍵 K2 を生成し、全検証者に「検証用の追加鍵」として配布
→ 検証側は K1 と K2 の両方を試す(同時受理ウィンドウの開始)
★ 全検証者への配布完了を確認するまで Phase 2 に進めない
Phase 2 発行側の署名鍵を K2 に切替
Phase 3 K1 署名のトークンが全て失効するまで待つ
Phase 4 全検証者から K1 を削除
検証側は「複数鍵を順に試す」実装が必要になります。
// HS256 のローテーション対応。kid で鍵を引けるようにしておくと総当たりを避けられる
import { decodeProtectedHeader } from "jose";
// KEY_A / KEY_B は 2.3 の HmacSecretSchema で検証済みの Uint8Array
const HMAC_KEYS: ReadonlyMap<string, Uint8Array> = new Map([
["2026-08-a", KEY_A],
["2026-08-b", KEY_B],
]);
export async function verifyWithRotation(token: string): Promise<AuthResult> {
// kid を見て鍵を1本に絞る。総当たりは検証コストが鍵数に比例し、
// かつ「どの鍵で通ったか」の観測を難しくする
const kid = decodeProtectedHeader(token).kid;
const key = kid ? HMAC_KEYS.get(kid) : undefined;
// kid はトークン側の申告。解決できないのは障害ではなく、トークンの問題として扱う
if (!key) return { ok: false, reason: "unknown_kid" };
// 以下は 5.3 と同じ:algorithms: ["HS256"] を必ず指定して jwtVerify する
return verifyHs256(token, key);
}
HS256 でも kid を使ってください。 対称鍵だから不要、ではありません。kid が無いとローテーション時に全鍵で総当たり検証することになり、検証コストが鍵数に比例して増えるうえ、「どの鍵が使われているか」が観測できなくなります。
6.4 差のまとめ
| HS256 | RS256 | |
|---|---|---|
| 新鍵の配布先 | 全検証者(秘密を配る) | JWKS に追記するだけ(公開) |
| 配布チャネル | シークレットマネージャ/再デプロイが必要 | HTTP(既存の JWKS 取得経路) |
| 検証者追加時の作業 | 秘密鍵の配布=発行権限の付与 | JWKS URL を教えるだけ |
| 鍵漏洩時の影響 | 全面的な再発行 + 全検証者の同時更新 | 公開鍵なら影響なし。秘密鍵漏洩なら発行側のみ差し替え |
| 緊急失効(kill switch) | 全検証者を同時更新するまで攻撃が続く | JWKS から該当 kid を削除 → cacheMaxAge 以内に全検証者へ波及 |
最後の行が決定的です。RS256 では、JWKS から kid を1つ削除するだけで、その鍵で署名された全トークンを数分以内に無効化できます。 HS256 にはこのレバーがありません。
7. HS256 から RS256 への無停止移行
「最初は HS256 で作った。サービスが増えたので RS256 に移りたい」——最も多い相談です。手順化します。
7.1 前提の整理
移行が難しいのは、署名方式を変えた瞬間に既存トークンが全部無効になるからです。素朴にやると全ユーザーが強制ログアウトされます。そこで「検証側だけ先に両対応にする」という原則で段階を切ります。
発行 alg 検証で受理する alg ユーザー影響
Phase 0 HS256 HS256 —(現状)
Phase 1 HS256 HS256 + RS256 なし ★全検証者のデプロイ完了を待つ
Phase 2 RS256 HS256 + RS256 なし(既存トークンは期限まで有効)
Phase 3 RS256 RS256 なし(HS256 トークンは既に全失効)
Phase 1 で全検証者のデプロイが完了するまで Phase 2 に進まないのが唯一かつ最重要のルールです。ここを守れば強制ログアウトはゼロになります。
鍵が漏洩した場合は、この手順を使ってはいけません。 上の段階移行は「計画的な移行」のための手順です。共有鍵が漏れた(その疑いがある)なら、HS256 を受理し続ける期間は、そのまま攻撃者がトークンを偽造できる期間になります。後述の「リフレッシュトークンTTL(例:30日)だけ待つ」は、漏洩時には30日間の偽造を許すのと同義です。漏洩時は段階を飛ばして HS256 の受理を即時停止し、全ユーザーの再ログインを受け入れてください。強制ログアウトのコストは、偽造トークンのコストより安いです。
7.2 移行期の検証実装
両対応にするとき、素朴に algorithms: ["HS256", "RS256"] と書いて両方の鍵を渡してはいけません。それはアルゴリズム混同攻撃を自ら実装することになります。
// ❌ 危険:RS256 の公開鍵を HS256 の鍵として使われる余地を作ってしまう
await jwtVerify(token, someKeyResolver, { algorithms: ["HS256", "RS256"] });
// ✅ 正しい:ヘッダの alg で「鍵とアルゴリズムのペア」を排他的に選び、
// 選んだ後は 1 アルゴリズムに固定して検証する(RFC 8725 §3.1「各鍵はちょうど1つのアルゴリズム」)
import { decodeProtectedHeader, jwtVerify, type JWTVerifyOptions } from "jose";
import { jwks } from "./verify-rs256"; // 5.2 の createRemoteJWKSet
import { HMAC_SECRET } from "./env"; // 2.3 の Zod で検証済みの対称鍵
import { AccessTokenClaimsSchema, type AuthResult } from "./claims";
/** alg 以外の検証条件は 1 か所に集約する(DRY)。経路ごとに差があるのは alg と鍵だけ */
const COMMON: JWTVerifyOptions = {
typ: "at+jwt",
issuer: process.env.TOKEN_ISSUER!,
audience: process.env.TOKEN_AUDIENCE!,
clockTolerance: 5,
requiredClaims: ["exp", "sub", "jti", "tenant_id", "scope"],
};
/** 鍵と検証オプションの組を受け取り、クレーム契約の検証まで通して Result を返す */
async function verifyWith(
token: string,
key: Parameters<typeof jwtVerify>[1],
options: JWTVerifyOptions,
): Promise<AuthResult> {
try {
const { payload } = await jwtVerify(token, key, options);
const parsed = AccessTokenClaimsSchema.safeParse(payload);
return parsed.success ? { ok: true, claims: parsed.data } : { ok: false, reason: "schema" };
} catch (error) {
return { ok: false, reason: classify(error) }; // 5.2 の classify を再利用
}
}
export async function verifyDuringMigration(token: string): Promise<AuthResult> {
let alg: string | undefined;
try {
// ヘッダの読み取りは「どの検証経路を選ぶか」の分岐にのみ使う。
// ここで読んだ値をそのまま検証パラメータに流さないことが肝。
alg = decodeProtectedHeader(token).alg;
} catch {
return { ok: false, reason: "malformed" };
}
switch (alg) {
case "RS256":
// RS256 経路には JWKS しか渡らない → HMAC 鍵として使われようがない
return verifyWith(token, jwks, { ...COMMON, algorithms: ["RS256"] });
case "HS256":
// HS256 経路には対称鍵しか渡らない → 公開鍵が紛れ込む経路が存在しない
return verifyWith(token, HMAC_SECRET, { ...COMMON, algorithms: ["HS256"] });
default:
// none も、未知の alg も、ここで死ぬ。既定を「拒否」にするのが fail-closed
return { ok: false, reason: "alg_not_allowed" };
}
}
switch の各分岐に、その分岐でしか使えない鍵しか渡らない——これが「1鍵1アルゴリズム」の実装形です。ヘッダの alg は経路選択にしか使っておらず、選ばれた経路の中では algorithms がアプリ側の定数で固定されている点を確認してください。
なお Phase 3 は待っていれば勝手に来るものではありません。HS256 の分岐を実際に削除してデプロイする作業が要ります。
// Phase 3:HS256 経路を削除する。この差分を出して初めて Phase 3 に入ったと言える
switch (alg) {
case "RS256":
return verifyWith(token, jwks, { ...COMMON, algorithms: ["RS256"] });
default:
// HS256 もここに落ちる。戻したいときは Phase 2 のビルドを再デプロイする
return { ok: false, reason: "alg_not_allowed" };
}
7.1 の表の Phase 3 は、この差分がデプロイされた状態を指します。case "HS256" を残したまま auth.success{alg="HS256"} がゼロになっただけの状態は、まだ Phase 2 です。
7.3 移行の監視指標とロールバック条件
| フェーズ | 監視する指標 | 次に進む条件 | ロールバック条件 |
|---|---|---|---|
| Phase 1 | 全検証サービスのデプロイバージョン | 100%が両対応版 | — |
| Phase 2 | auth.success{alg="RS256"} の立ち上がり | RS256 成功が想定レートに到達 | auth.failure が baseline+0.1% 超 |
| Phase 2→3 | auth.success{alg="HS256"} の減衰 | 24時間ゼロ(リフレッシュTTL以上待つ) | HS256 が残存 → 待機継続 |
| Phase 3 | auth.failure{reason="alg_not_allowed"} | 平常時ゼロを維持 | 立ち上がったら Phase 2 のビルドを再デプロイする(待機では戻らない) |
Phase 2→3 で待つべき時間は「アクセストークンTTL」ではなく「リフレッシュトークンTTL」であることに注意してください。リフレッシュトークンが30日有効なら、HS256 経路を落とすのは最短でも30日後です。焦って落とすと、その日ログインしていなかったユーザーが強制ログアウトされます。
8. RS256 の先:ES256・PS256・そして 2031年問題
RS256 で終わりではありません。新規設計では次も検討に値します。
8.1 NIST の移行スケジュール
NIST SP 800-131A Rev.3 の初期公開ドラフト(2024年10月)は、こう述べています。
「古典的な電子署名および鍵確立方式について、112ビットのセキュリティ強度の使用を 2030年12月31日以降 deprecate する(128ビットセキュリティ強度への移行を要求するのではなく)」
「現時点で、古典的な電子署名および鍵確立アルゴリズムの112ビットセキュリティ強度が、近い将来に危殆化する差し迫った危険にあるようには見えないため、このアプローチは暗号コミュニティにとって不必要な労力なしに耐量子アルゴリズムへの秩序ある移行を可能にするはずである」
NIST SP 800-57 Part 1 Rev.5 の Table 2 は、鍵長とセキュリティ強度の対応をこう定めています。
| セキュリティ強度 | 対称鍵 | RSA(IFC)の k | 楕円曲線(ECC)の f |
|---|---|---|---|
| 112 | 3TDEA | k = 2048 | f = 224〜255 |
| 128 | AES-128 | k = 3072 | f = 256〜383(P-256) |
| 192 | AES-192 | k = 7680 | f = 384〜511 |
| 256 | AES-256 | k = 15360 | f = 512+ |
つまり、
- RS256 + RSA-2048 = 112ビット強度 → 2030年末以降 deprecated
- RS256 + RSA-3072 = 128ビット強度 → 継続可能。ただし署名コストはさらに重い
- ES256(P-256)= 128ビット強度 → 鍵もトークンも小さいまま128ビット
私の実測では RSA-4096 の署名は RSA-2048 の約7.3倍(2,989.7µs 対 410µs)でした。RSA-3072 はその中間ですが、鍵長を伸ばして延命するより ES256 に移るほうが、性能・トークン長・寿命のすべてで有利というのが素直な結論です。
なお、この期限に慌てる必要はありません。NIST 自身が「差し迫った危険にあるようには見えない」と明言しています。**重要なのは「移行できる設計になっているか」**であり、それは kid を使い、algorithms をアプリ側の定数として持ち、7.2節のような排他的な検証経路を用意しておくことで達成されます。
8.2 選択肢の比較(実測値を含む)
| RS256 (2048) | RS256 (3072) | ES256 | EdDSA (Ed25519) | |
|---|---|---|---|---|
| NIST セキュリティ強度 | 112(2030年末以降 deprecated) | 128 | 128 | — |
| RFC 7518 実装要件 | Recommended | Recommended | Recommended+ | RFC 8037 で定義 |
| 署名コスト(実測) | 410µs | (2048と4096の中間) | 27.6µs | 24.8µs |
| 検証コスト(実測) | 15.9µs | — | 52.8µs | 83.6µs |
| トークン長(実測) | 631B | — | 375B | 375B |
| 相互運用性 | 最高(RFC 9068 が対応必須と規定) | 高 | 高 | 中(対応しない実装あり) |
RFC 9068 は「本仕様に適合する認可サーバとリソースサーバは、サポートする署名アルゴリズムに RS256 を含めなければならない(MUST)」と規定しています。 相互運用性が要件なら RS256 は今後も必要です。ES256 を主軸にする場合も、RS256 を並行提供するのが実務解になります(JWKS には両方の鍵を置けます)。
8.3 PS256 は必要か
RS256 は RSASSA-PKCS1-v1_5、PS256 は RSASSA-PSS です。PSS は証明可能安全性を持つ現代的なパディングで、RFC 8017(PKCS #1 v2.2)も新規アプリケーションには PSS を推奨しています。ただし RFC 7518 3.1節での実装要件は PS256 が Optional、RS256 が Recommended です。相互運用性では RS256 が優位で、PKCS1-v1_5 の署名検証に既知の実用的攻撃はありません。
**判断:既存の RS256 を PS256 に置き換える緊急性はありません。**鍵長・寿命・トークン長の観点で動くなら、移行先は ES256 です。
9. テスト:攻撃を退行テストで固定する
ここまでの防御を、レビュー頼みにせず機械で守ります。攻撃者の視点で書いたテストだけが、リファクタリングに耐えます。
// src/auth/verify.test.ts — vitest
import { describe, expect, it, beforeAll } from "vitest";
import { SignJWT, createLocalJWKSet, exportJWK, exportSPKI, generateKeyPair } from "jose";
import { createHmac } from "node:crypto";
import { verifyAccessToken } from "./verify-rs256";
let privateKey: CryptoKey;
let publicKeyPem: string;
let localJwks: ReturnType<typeof createLocalJWKSet>;
beforeAll(async () => {
const pair = await generateKeyPair("RS256", { extractable: true });
privateKey = pair.privateKey;
publicKeyPem = await exportSPKI(pair.publicKey);
// 本番の createRemoteJWKSet を差し替える。ネットワークにも JWKS_URI にも依存させない
localJwks = createLocalJWKSet({
keys: [{ ...(await exportJWK(pair.publicKey)), kid: "2026-08-a", alg: "RS256" }],
});
});
const base = () =>
new SignJWT({ tenant_id: "3f1b7c8e-0000-4000-8000-000000000000", scope: "orders:read" })
.setIssuer("https://auth.example.com")
.setAudience("https://api.example.com")
.setSubject("u_01")
.setJti("t1")
.setIssuedAt()
.setExpirationTime("15m");
describe("verifyAccessToken", () => {
it("正規の RS256 トークンを受理する", async () => {
const token = await base()
.setProtectedHeader({ alg: "RS256", kid: "2026-08-a", typ: "at+jwt" })
.sign(privateKey);
await expect(verifyAccessToken(token, localJwks)).resolves.toMatchObject({ ok: true });
});
it("【混同攻撃】公開鍵を HMAC 鍵に使った HS256 トークンを拒否する", async () => {
// RFC 8725 §2.1 の攻撃を実際に組み立てる。公開鍵は誰でも入手できる前提
const header = Buffer.from(
JSON.stringify({ alg: "HS256", kid: "2026-08-a", typ: "at+jwt" }),
).toString("base64url");
const payload = Buffer.from(
JSON.stringify({
iss: "https://auth.example.com", aud: "https://api.example.com",
sub: "u_01", jti: "t1", tenant_id: "3f1b7c8e-0000-4000-8000-000000000000", scope: "admin:all",
iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 900,
}),
).toString("base64url");
const forged = `${header}.${payload}.${createHmac("sha256", publicKeyPem)
.update(`${header}.${payload}`)
.digest("base64url")}`;
await expect(verifyAccessToken(forged, localJwks)).resolves.toEqual({
ok: false,
reason: "alg_not_allowed",
});
});
it("【alg:none】署名なしトークンを拒否する", async () => {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "at+jwt" })).toString("base64url");
const payload = Buffer.from(JSON.stringify({ sub: "admin" })).toString("base64url");
await expect(verifyAccessToken(`${header}.${payload}.`, localJwks)).resolves.toEqual({
ok: false,
reason: "alg_not_allowed",
});
});
it("【種別混同】typ が at+jwt でないトークンを拒否する(RFC 9068 §4)", async () => {
const idToken = await base()
.setProtectedHeader({ alg: "RS256", kid: "2026-08-a", typ: "JWT" })
.sign(privateKey);
await expect(verifyAccessToken(idToken, localJwks)).resolves.toEqual({ ok: false, reason: "claim" });
});
it("【別テナント】aud が異なるトークンを拒否する(RFC 8725 §3.9)", async () => {
const token = await base()
.setAudience("https://other-api.example.com")
.setProtectedHeader({ alg: "RS256", kid: "2026-08-a", typ: "at+jwt" })
.sign(privateKey);
await expect(verifyAccessToken(token, localJwks)).resolves.toEqual({ ok: false, reason: "claim" });
});
it("【期限切れ】exp を過ぎたトークンを拒否する", async () => {
const token = await base()
.setExpirationTime(Math.floor(Date.now() / 1000) - 60)
.setProtectedHeader({ alg: "RS256", kid: "2026-08-a", typ: "at+jwt" })
.sign(privateKey);
await expect(verifyAccessToken(token, localJwks)).resolves.toEqual({ ok: false, reason: "expired" });
});
it("【契約違反】署名は正しいが tenant_id を欠くトークンを拒否する", async () => {
const token = await new SignJWT({ scope: "orders:read" })
.setProtectedHeader({ alg: "RS256", kid: "2026-08-a", typ: "at+jwt" })
.setIssuer("https://auth.example.com").setAudience("https://api.example.com")
.setSubject("u_01").setJti("t1").setIssuedAt().setExpirationTime("15m")
.sign(privateKey);
await expect(verifyAccessToken(token, localJwks)).resolves.toEqual({ ok: false, reason: "claim" });
});
});
2番目のテストが最も重要です。「公開鍵を HMAC 鍵にして署名したトークン」を実際に組み立てて、拒否されることを確認する。このテストが1本あれば、将来誰かが algorithms を消したり ["HS256", "RS256"] に広げたりした瞬間に CI が落ちます。
10. 本番投入前チェックリスト
| # | 項目 | 根拠 |
|---|---|---|
| 1 | 検証時に algorithms をアプリ側の定数で明示している | RFC 8725 §3.1 |
| 2 | 1つの鍵が1つのアルゴリズムにのみ束ねられている | RFC 8725 §3.1 |
| 3 | alg: "none" が全経路で拒否される(既定が「拒否」) | RFC 8725 §3.2 / RFC 9068 §4 |
| 4 | HS256 の鍵が暗号論的乱数由来の32バイト以上(パスワード由来でない) | RFC 7518 §3.2 / RFC 8725 §3.5 |
| 5 | RSA 鍵が2048ビット以上 | RFC 7518 §3.3 |
| 6 | iss / aud を検証している | RFC 8725 §3.8 / §3.9 |
| 7 | typ を検証してトークン種別の混同を防いでいる | RFC 8725 §3.11 / RFC 9068 §2.1 |
| 8 | 発行トークンに kid が載っている | RFC 7515 §4.1.4 |
| 9 | kid を使ったローテーション手順が文書化・訓練済み | NIST SP 800-57 Pt.1 Rev.5 §5.3.6 |
| 10 | JWKS 取得失敗時に fail-closed(通さない)である | — |
| 11 | JWKS のキャッシュ・クールダウン・タイムアウトを明示設定している | jose RemoteJWKSetOptions |
| 12 | clockTolerance が秒単位(分単位でない) | — |
| 13 | 失敗理由がメトリクス化され、alg_not_allowed に単独アラートがある | — |
| 14 | トークン本体・payload・PII をログに出していない | — |
| 15 | アルゴリズム混同・alg:none の退行テストが CI にある | — |
| 16 | JWT ライブラリが Dependabot 等で追跡されている | NDSS 2026「Token Time Bomb」 |
チェック16は軽視されがちですが、NDSS 2026 の調査が示す通り JWT ライブラリ自体の脆弱性は2024年以降も継続的に発見されています。自分のコードが正しくても、ライブラリが古ければ守れません。
11. まとめ
- HS256 と RS256 の違いは強度ではなく信頼境界の数。 HS256 では「検証できる者は偽造もできる」。鍵を配る=発行権限を配ることだと理解したうえで選ぶ。
- 仕様の MUST は守る。 HS256 は256ビット以上かつ高エントロピー、RS256 は2048ビット以上。人間が覚えられるパスワードの直接利用は MUST NOT。
- 性能差は選定理由になりにくい。 検証コストの差は実測で1リクエストあたり0.014ミリ秒。効いてくるのは署名コスト(約222倍差)とトークン長(約2倍差)のほう。
- アルゴリズム混同は現役の脅威。 NDSS 2026 が43ライブラリから31件の新規脆弱性・20件のCVEを報告している。防御は
algorithmsの固定と「1鍵1アルゴリズム」。 - 運用で差がつく。 RS256 は JWKS に
kidを足すだけで無停止ローテーションでき、kidの削除が緊急失効のレバーになる。HS256 にはこのレバーがない。 - 移行はできる。 「検証側だけ先に両対応」の原則を守れば、HS256 → RS256 は強制ログアウトゼロで移行できる。ただし待機時間はリフレッシュトークンTTLで測る。
- 2031年に向けて。 RSA-2048 は112ビット強度で2030年末以降 deprecated。慌てる必要はないが、
kidとalgorithms定数化によって「移行できる設計」にしておくこと。
私が 経済産業大臣賞を受賞した木材流通DXのB2B SaaS で Cognito の RS256 + JWKS 検証を全221エンドポイントに適用したとき、最終的に効いたのは暗号の知識ではなく、**「検証経路を1本に集約し、失敗理由を観測可能にし、攻撃を退行テストで固定する」**という地味な設計でした。第三者ペネトレーションテストで認証欠落0件という結果は、その積み重ねの帰結です。
アルゴリズムの選定は、その設計の入口にすぎません。しかし入口を間違えると、あとの全部が難しくなります。