メインコンテンツへスキップ
友田 陽大
認証・認可
JWT
認証・認可
セキュリティ
TypeScript
アーキテクチャ設計

HS256 と RS256 の違いを、仕様と実測で決着させる — JWT署名方式の選定・鍵ローテーション・無停止移行

JWTのHS256とRS256はどちらを選ぶべきか。RFC 7518/8725・RFC 9068・NIST SP 800-57/800-131A の一次情報に忠実に、両者の信頼境界の違い、鍵長要件、アルゴリズム混同攻撃、実測ベンチマーク(署名/検証/トークン長)、jose v6 による本番実装、鍵ローテーション、HS256→RS256 の無停止移行手順、退行テストまでを解説します。

公開日
読了時間
45分
著者
友田 陽大
シェア
目次

「JWT の署名は HS256 と RS256、どっちがいいんですか?」

この質問に「RS256 のほうが安全です」と答えるのは、半分正しくて、半分は間違いです。両者は強度が違うのではなく、信頼の置き方が違うからです。RFC 7518 は HS256 を Required(実装必須)、RS256 を Recommended と規定しています。仕様上、HS256 は「弱い方式」として扱われてはいません。

にもかかわらず現場で事故が起きるのは、ほぼ例外なく 「その鍵を誰に配ったか」を設計しなかったときです。HS256 の共有鍵を検証側マイクロサービス5つに配った瞬間、あなたのシステムにはトークン発行者が6人に増えます。これは実装バグではなく、選定の帰結です。

この記事は、その選定を「なんとなく」ではなく仕様の条文と自環境の実測値で決着させることを目的にします。扱う範囲は次の通りです。

  • 両者の本質的な違い(信頼境界の数)を1枚の表で確定させる
  • RFC 7518 / RFC 8725 / RFC 9068 が課す MUST 要件を条文で確認する
  • 署名・検証速度とトークン長を自分の環境で実測し、数字の桁を知る
  • アルゴリズム混同攻撃が今も生きていることを一次資料(NDSS 2026)で確認する
  • jose v6 による本番運用に耐える検証層を型安全に実装する
  • 鍵ローテーション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 で十分。速く、短く、鍵管理も単純。
HS256RS256
分類対称鍵 MAC(HMAC-SHA256)非対称鍵署名(RSASSA-PKCS1-v1_5 + SHA-256)
検証に必要な鍵署名と同じ秘密鍵公開鍵(秘密不要)
「検証できる者」は偽造もできる偽造できない
信頼境界発行者=検証者の1ドメインに閉じる発行と検証を分離できる
RFC 7518 の実装要件RequiredRecommended
鍵長の 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
読むのに秘密が要るnononoyesyes
書き換えるのに秘密が要るnoyesyesyesno

同ホワイトペーパーは 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内容実装要件
HS256HMAC using SHA-256Required
HS384 / HS512HMAC using SHA-384 / SHA-512Optional
RS256RSASSA-PKCS1-v1_5 using SHA-256Recommended
RS384 / RS512RSASSA-PKCS1-v1_5 using SHA-384 / SHA-512Optional
ES256ECDSA using P-256 and SHA-256Recommended+
ES384 / ES512ECDSA using P-384 / P-521Optional
PS256 / PS384 / PS512RSASSA-PSS + MGF1Optional
noneNo digital signature or MAC performedOptional

読み取るべきポイントは3つです。

  1. HS256 は「Required」。つまり全ての適合実装が持つ、最も相互運用性の高いアルゴリズムです。「弱いから避けろ」という位置づけではありません。
  2. ES256 だけが「Recommended+」。RFC 7518 の用語で + は「実装要件が将来引き上げられる可能性が高い」ことを示します。長期の新規設計では ES256 が仕様の推す方向です。
  3. 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/回)検証(回/秒)
HS2561.8541,2721.5652,227
RS256(RSA-2048)4102,43915.963,091
RS256(RSA-4096)2,989.733457.617,362
ES256(P-256)27.636,18852.818,945
EdDSA(Ed25519)24.840,39183.611,958

ここから読み取れることは、一般に語られる「RS256 は遅い」という単純な話ではありません。

  1. 検証(毎リクエストで走る側)の差は 15.9µs 対 1.5µs。比では約10倍ですが、差の絶対量は1リクエストあたり0.014ミリ秒です。DB クエリ1本が数ミリ秒であることを考えると、ボトルネックになるのは相当な高スループット環境に限られます。
  2. 署名の差は約222倍と桁違い(410µs 対 1.8µs)。RSA の秘密鍵演算は本質的に重い処理です。毎リクエストでトークンを再発行する設計は RS256 と相性が悪い——これは実際に選定に効く情報です。
  3. ECDSA/EdDSA は「検証のほうが署名より重い」。RSA は公開指数が小さいため検証が非常に軽く、実測でも RS256-2048 の検証(15.9µs)は ES256 の検証(52.8µs)より速いという逆転が起きています。「楕円曲線のほうが常に速い」は誤りです。
  4. RSA-4096 は署名が RSA-2048 の約7.3倍重い。鍵長を上げるなら、素直に ES256 に移るほうが費用対効果は良好です。

3.2 トークン長

同一クレーム(iss / sub / aud / exp / iat / jti / scopetyp: "at+jwt"kid 付き)で測った compact serialization のバイト数です。

ヘッダとペイロードが同一なら、差は署名部の長さだけで決まります。base64url 後の署名長は HMAC-256 が43文字、P-256/Ed25519 が86文字、RSA-2048 が342文字、RSA-4096 が683文字なので、HS256 の332バイトを基準に各行は算術的に再現できます。

アルゴリズムトークン長(bytes)HS256 比
HS2563321.00×
ES256(P-256)3751.13×
EdDSA(Ed25519)3751.13×
RS256(RSA-2048)6311.90×
RS256(RSA-4096)9722.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 Confusion2件
Sign/Encryption Confusion2件
JWT Format Confusion4実装
Billion Hashes Attack(DoS)10件
Compression DoS13件

論文が挙げる 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つ。

  1. algorithms / typ / issuer / audience は全部書く。 どれか1つでも省くと、そこが攻撃面になります。特に typ: "at+jwt" は RFC 9068 が「typ の値が at+jwt または application/at+jwt であることを検証し、それ以外の値を持つトークンは拒否する」と定めている検証手順そのものです。ID トークンをアクセストークンとして使い回される事故(RFC 8725 2.8節「Cross-JWT Confusion」)を型レベルで塞ぎます。
  2. clockTolerance は秒単位に留める。 5分などにすると、失効させたはずのトークンが5分間生き続けます。NTP が効いている環境なら数秒で十分です。
  3. unknown_kidkey_unavailable を分ける。 JWKS の取得自体が失敗したのはこちらの障害であり、攻撃ではありません。これを signature と混ぜると、IdP 障害時にアラートが「大量の署名検証失敗=攻撃」に見えてしまい、判断を誤ります。一方 kid は攻撃者が任意に指定できる入力なので、「その kid の鍵が無い」は障害ではなくトークン側の問題です。両者を同じ理由コードに畳むと、偽造トークンを投げるだけで 503 を返させられるうえ、攻撃の波形が「IdP 障害」に見えてしまいます。
  4. key_unavailable でも通さない(fail-closed)。 josecreateRemoteJWKSet は取得成功後 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 periodrecipient-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 側からも宣言することになり、josecreateRemoteJWKSet は鍵選択時に algkidusekey_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 差のまとめ

HS256RS256
新鍵の配布先全検証者(秘密を配る)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 2auth.success{alg="RS256"} の立ち上がりRS256 成功が想定レートに到達auth.failure が baseline+0.1% 超
Phase 2→3auth.success{alg="HS256"} の減衰24時間ゼロ(リフレッシュTTL以上待つ)HS256 が残存 → 待機継続
Phase 3auth.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
1123TDEAk = 2048f = 224〜255
128AES-128k = 3072f = 256〜383(P-256)
192AES-192k = 7680f = 384〜511
256AES-256k = 15360f = 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)ES256EdDSA (Ed25519)
NIST セキュリティ強度112(2030年末以降 deprecated)128128
RFC 7518 実装要件RecommendedRecommendedRecommended+RFC 8037 で定義
署名コスト(実測)410µs(2048と4096の中間)27.6µs24.8µs
検証コスト(実測)15.9µs52.8µs83.6µs
トークン長(実測)631B375B375B
相互運用性最高(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
21つの鍵が1つのアルゴリズムにのみ束ねられているRFC 8725 §3.1
3alg: "none" が全経路で拒否される(既定が「拒否」)RFC 8725 §3.2 / RFC 9068 §4
4HS256 の鍵が暗号論的乱数由来の32バイト以上(パスワード由来でない)RFC 7518 §3.2 / RFC 8725 §3.5
5RSA 鍵が2048ビット以上RFC 7518 §3.3
6iss / aud を検証しているRFC 8725 §3.8 / §3.9
7typ を検証してトークン種別の混同を防いでいるRFC 8725 §3.11 / RFC 9068 §2.1
8発行トークンに kid が載っているRFC 7515 §4.1.4
9kid を使ったローテーション手順が文書化・訓練済みNIST SP 800-57 Pt.1 Rev.5 §5.3.6
10JWKS 取得失敗時に fail-closed(通さない)である
11JWKS のキャッシュ・クールダウン・タイムアウトを明示設定しているjose RemoteJWKSetOptions
12clockTolerance が秒単位(分単位でない)
13失敗理由がメトリクス化され、alg_not_allowed に単独アラートがある
14トークン本体・payload・PII をログに出していない
15アルゴリズム混同・alg:none の退行テストが CI にある
16JWT ライブラリが 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。慌てる必要はないが、kidalgorithms 定数化によって「移行できる設計」にしておくこと。

私が 経済産業大臣賞を受賞した木材流通DXのB2B SaaS で Cognito の RS256 + JWKS 検証を全221エンドポイントに適用したとき、最終的に効いたのは暗号の知識ではなく、**「検証経路を1本に集約し、失敗理由を観測可能にし、攻撃を退行テストで固定する」**という地味な設計でした。第三者ペネトレーションテストで認証欠落0件という結果は、その積み重ねの帰結です。

アルゴリズムの選定は、その設計の入口にすぎません。しかし入口を間違えると、あとの全部が難しくなります。

よくある質問

結局 HS256 と RS256 はどちらを選ぶべきですか?
「そのトークンを検証する主体が、発行する権限を持ってよいか」で決まります。発行と検証が同じアプリケーション・同じ運用チームに閉じる(モノリスのセッション用トークンなど)なら HS256 で十分です。検証者が複数サービスに増える、別チーム・別組織・クライアントアプリが検証する、または将来そうなる見込みがあるなら RS256(もしくは ES256)です。HS256 の共有鍵は「検証できる=偽造できる」ため、鍵を配った相手全員が発行者になれてしまいます。迷ったら RS256 が安全側です。
RS256 は遅いと聞きますが、パフォーマンス上の問題になりませんか?
APIリクエストごとに行うのは「検証」であり、そこは実用上ほぼ問題になりません。自環境の実測(Node v26.0.0 / Apple Silicon / arm64)で RS256(RSA-2048) の検証は 1回あたり約15.9マイクロ秒、HS256 は約1.5マイクロ秒でした。約10倍差ですが、その差は1リクエストあたり0.014ミリ秒です。差が大きいのは署名側(HS256 約1.8µs 対 RS256-2048 約410µs、約222倍)なので、毎リクエストでトークンを再発行する設計だけは避けてください。トークン長は HS256 が332バイト、RS256(RSA-2048) が631バイトで、Cookieやヘッダ長の制約がある場合はこちらのほうが効いてきます。
HS256 の秘密鍵はどれくらいの長さが必要ですか?
RFC 7518 3.2節は「ハッシュ出力と同じサイズ(HS256 なら256ビット)以上の鍵を使わなければならない(MUST)」と規定しています。重要なのは長さではなくエントロピーで、NIST SP 800-57 Part 1 Rev.5 も HMAC の推定セキュリティ強度は「鍵の生成に用いた長さとエントロピーが少なくともそのセキュリティ強度と同等であること」を前提とすると注記しています。つまり `openssl rand -base64 32` のような暗号論的乱数から生成した32バイトが最低ラインで、人間が記憶できるパスワードを鍵に直接使うことは RFC 8725 3.5節で MUST NOT とされています。
アルゴリズム混同攻撃はもう対策済みの古い問題ではないのですか?
いいえ。NDSS Symposium 2026 で発表された『Token Time Bomb』は、10言語43種のJWTライブラリを体系的に評価し、31件の新規脆弱性(うち20件にCVE採番)を報告しています。その内訳には Algorithm Confusion が2件含まれ、論文は CVE-2024-57453(libjwt)・CVE-2024-57454(cpp-jwt)として報告しています(両IDは2026年8月時点で NVD/MITRE には未公開のため、一次情報は論文 Table I です)。ライブラリ任せにせず、アプリ側で `algorithms: ['RS256']` のように許可リストを明示し、鍵とアルゴリズムを1対1に束ねてください。RFC 8725 3.1節も「各鍵はちょうど1つのアルゴリズムとともに使わなければならない」と要求しています。
RSA-2048 は 2030年で使えなくなるという話は本当ですか?
「使えなくなる」ではなく「非推奨(deprecated)になる」が正確です。NIST SP 800-131A Rev.3 の初期公開ドラフト(2024年10月)は、古典的な電子署名・鍵確立方式における112ビットセキュリティ強度を『2030年12月31日以降 deprecate する』としています。RSA-2048 は NIST SP 800-57 Part 1 Rev.5 の Table 2 で112ビット強度に相当するため、これに該当します。同ドラフトは「112ビット強度が近い将来に危殆化する差し迫った危険にあるようには見えない」とも述べており、慌てて全面移行する必要はありません。ただし新規設計では RSA-3072(128ビット相当)か ES256 を選び、`kid` によるアルゴリズム移行が可能な設計にしておくのが妥当です。
OpenID Connect の ID トークンで HS256 を使うとどうなりますか?
OpenID Connect Core 1.0 は、HS256/HS384/HS512 を使う場合の鍵を「`aud` クレームに含まれる `client_id` に対応する `client_secret` の UTF-8 表現のオクテット列」と定めています。つまり client_secret がそのまま署名鍵になります。同仕様16.19節は client_secret に「攻撃者が値を推測できないだけの十分なエントロピー」を要求しており、さらにクライアントが増えるたびに「ID トークンを偽造できる主体」が増えることになります。ID トークンの `alg` は既定値の RS256(またはクライアントが登録時に指定した値)を使うべき、というのが同仕様3.1.3.7節(ID Token Validation)の推奨です。

参考文献

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

認証基盤・企業SSOの設計でお悩みですか?

認証基盤(企業SSO・IdP連携)の技術選定・設計レビュー

Cognito / Auth0 / Clerk / Supabase の選定、Azure AD・Okta・Google との SAML/OIDC 連携、JWT(RS256)検証、マルチテナントのトークン設計まで。経済産業大臣賞を受賞した B2B SaaS で Cognito 認証基盤(RS256のJWT検証・マルチテナント認可・複数IdP受け入れ)を設計・運用した知見で、実装前に設計を固める技術顧問として伴走します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

あわせて読みたい