JWKSとJWT鍵ローテーション運用ガイド|kid検証・ゼロダウンタイム・Node.js実装

(更新: 2026年6月21日 ) JWT JWKS セキュリティ 認証 Node.js 運用
結論
  • JWTの非対称鍵運用では、JWKSエンドポイント(RFC 7517)で公開鍵を配布し、JWTヘッダーのkidで検証鍵を選択する。
  • JWKSローテーションは「新鍵追加 → 発行切替 → Grace Periodで旧鍵温存 → 期限後削除」の4段階でゼロダウンタイムを実現する。
  • Node.js/Expressではjwks-rsa + jsonwebtoken、配信はNginx + Cache-Control: max-age=300 が定石。
  • OAuth2/PKCEで取得したアクセストークンの検証はOAuth2 PKCE SPA実装ガイドとセットで読むと全体像が繋がる。
  • CookieセッションとJWTを併用するBFF構成ではCSRF対策の実装ガイドも合わせて設計すること。

JWKSとJWT署名検証の全体像

JWT(JSON Web Token)をRS256(RSA + SHA-256)やES256(ECDSA P-256 + SHA-256)で署名している環境では、トークン発行側(Authorization Server)が秘密鍵で署名し、API(Resource Server)が公開鍵で検証します。公開鍵を各APIに手動配布すると、鍵ローテーションのたびに全サービスを再デプロイする必要があり、運用が破綻しやすくなります。

マイクロサービスが10個あれば10箇所にPEMを配ることになり、漏洩時のローテーションは全サービスの同時更新が必要です。KubernetesのSecretで配布しても、結局は「鍵更新 = Pod再起動」という運用コストが残ります。JWKSはこの配布問題をHTTPという既存インフラに載せ替える仕組みであり、検証側は起動時にJWKS URIを環境変数で持つだけで済みます。

2026年時点で、社内APIゲートウェイ・BFF・バッチワーカーが同一の認可サーバーを信頼する構成は一般的です。それぞれが独立にJWKSを取得・キャッシュするため、マルチキーJWKSエンドポイント(新旧複数の公開鍵を同時公開)がローテーションの前提条件になります。単一鍵しか載せないJWKSは、切り替え瞬間に必ず検証失敗が発生します。

JWKS(JSON Web Key Set) は、この問題を解決する標準的な配布方式です。HTTPS上の固定URL(例: https://auth.example.com/.well-known/jwks.json)に公開鍵の集合をJSON形式で置き、検証側は必要に応じて取得します。

JWT自体はステートレスですが、公開鍵の配布はステートフルな運用問題を伴います。鍵の有効期限、ローテーション履歴、漏洩時の緊急失効——これらはJWTペイロードには載せられず、JWKSエンドポイントとRunbookで管理します。OAuth2のアクセストークンが短命(15分〜1時間)であるほど、Grace Periodは短く済み、ローテーションリスクは下がります。逆にリフレッシュトークンや長寿命APIキー型JWTを使う場合は、Grace Period設計が難しくなるため、トークン寿命の見直しもセットで検討してください。

1

クライアントがAuthorization Serverにログインし、RS256/ES256署名付きJWTを受け取る(Headerにalg・kidを含む)

2

クライアントがAPIリクエストに Authorization: Bearer <JWT> を付与する

3

APIがJWTヘッダーのkidを読み、JWKSエンドポイントから対応する公開鍵を取得する

4

公開鍵で署名を検証し、exp・iss・aud等のクレームをチェックする

5

検証成功後、subやscopeに基づいて認可処理を行う

OAuth2 PKCE SPA実装ガイドでは、SPAがAuthorization Code + PKCEでアクセストークンを取得するフローを解説しています。取得したBearer JWTをAPIがどう検証するかが、本記事の主題です。BFFパターンでHttpOnly Cookieにセッションを載せる場合でも、バックエンドがJWTを検証する場面は残るため、JWKS運用の知識は必須です。

方式 特徴と向き不向き
環境変数に公開鍵PEMを直書き 小規模・鍵固定なら簡単。ローテーションのたびに全サービス再デプロイが必要で、本番運用には不向き
JWKSエンドポイント 公開鍵をHTTPで配布。kidで鍵選択。ローテーション時も検証側の再デプロイ不要(キャッシュTTLに注意)
OpenID Connect Discovery .well-known/openid-configuration から jwks_uri を自動取得。Auth0・Keycloak等の標準構成
HS256(対称鍵) JWKS不要。秘密鍵を全検証側と共有するため漏洩リスクが高く、マイクロサービス分割時はRS256/ES256+JWKSが一般的

OpenID Connect Discoveryとwell-known URI

OAuth 2.0 / OpenID Connect(OIDC)を採用している認可サーバーは、メタデータを well-known URI で公開します。クライアントやリソースサーバーは、このJSONから jwks_uri を読み取り、JWKSの場所を自動解決できます。

Discoveryドキュメントは通常1時間程度キャッシュされます(Cache-Control: max-age=3600)。JWKS本体より長いTTLが許容されるのは、URI自体が変わる頻度が極めて低いためです。ただし認可サーバーのドメイン移行時は、DiscoveryとJWKSの両方を同時に更新し、Resource Serverの JWKS_URI 環境変数も合わせて変更してください。

Resource Server起動時にDiscoveryから jwks_uri を動的取得する実装も可能ですが、起動時の外部依存が増えます。本番では JWKS_URI を固定値で持ち、Discoveryは開発時の自動設定用に留めるチームが多いです。いずれの方式でも、最終的に信頼する iss とJWKSの組み合わせはコードまたは設定で明示してください。

{
  "issuer": "https://auth.example.com",
  "authorization_endpoint": "https://auth.example.com/oauth2/authorize",
  "token_endpoint": "https://auth.example.com/oauth2/token",
  "jwks_uri": "https://auth.example.com/.well-known/jwks.json",
  "response_types_supported": ["code"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256", "ES256"]
}

自前の認可サーバーを運用する場合も、issuerjwks_uri の組み合わせを固定し、JWTの iss クレームと一致させるのが基本です。検証側は「このissuerから配布されたJWKSだけを信頼する」というポリシーをコードで明示してください。

GET /.well-known/openid-configuration HTTP/1.1
Host: auth.example.com
Accept: application/json
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=3600

{"issuer":"https://auth.example.com","jwks_uri":"https://auth.example.com/.well-known/jwks.json"}

JWKSエンドポイントのJSON構造とRFC 7517仕様

JWKSは RFC 7517 で定義された JWK(JSON Web Key) の配列です。2026年時点でもこの仕様が業界標準であり、Auth0・AWS Cognito・Google Identity等の主要IdPが同一形式を採用しています。

RFC 7517では、keys 配列の各要素が自己完結型の公開鍵表現であることが求められます。PEM形式の -----BEGIN PUBLIC KEY----- ブロックをそのまま載せるのではなく、RSAなら n(モジュラス)と e(指数)をBase64Urlエンコードしたフィールドに分解します。これによりJSONパーサだけで鍵を復元でき、言語やライブラリの境界を越えて相互運用できます。

use: "sig" は署名用途であることを示します。暗号化(enc)用の鍵と混在させないのがベストプラクティスです。x5c(X.509証明書チェーン)を載せる実装もありますが、JWT署名検証だけが目的なら n/e または x/y/crv で十分です。証明書ピンニングが必要なmTLS連携とは別レイヤーとして整理してください。

JWKSエンドポイント自体に認証をかけるIdPも存在しますが、公開鍵は「誰でも読んでよい」情報です。アクセス制限よりHTTPS + issuer固定 + キャッシュ戦略で整合性を担保する方が一般的です。内部ネットワーク限定のJWKSは、VPC外のSaaS連携時に検証不能になるため、設計段階で公開範囲を決めておきましょう。監査ログでJWKSアクセス元IPを記録する程度で足りるケースがほとんどです。WAFでJWKS URIだけレート制限を緩める設定も、正当なResource Serverからの再取得を妨げないよう注意が必要です。高QPS APIではJWKS取得がボトルネックにならないよう、キャッシュ設計を先に固めてください。

フィールド意味RS256での例ES256での例
kty鍵タイプ"RSA""EC"
use用途"sig""sig"
algアルゴリズム"RS256""ES256"
kid鍵ID(JWTヘッダーと対応)"key-2026-06-21""ec-key-2026-06-21"
n / eRSAモジュラス・指数Base64Url文字列(不使用)
crv / x / y楕円曲線パラメータ(不使用)"P-256" + 座標

典型的なマルチキーJWKSレスポンス(ローテーション中の正常状態):

{
  "keys": [
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "key-2026-06-21",
      "n": "t6Q8PWS3-3jzi5y36tWw-Pdmj86Sgc7q43vfe5R32AM",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "use": "sig",
      "alg": "RS256",
      "kid": "key-2025-12-01",
      "n": "xWEiO3VZmb6OF0jXwJ51B1uHc0aW9YAWy7C1dQ",
      "e": "AQAB"
    }
  ]
}

ローテーション中は keys配列に複数エントリ が存在するのが正常状態です。検証側はJWTヘッダーの kid と一致するJWKだけを選んで署名検証します。keys が空、またはkidが見つからない場合は 401 Unauthorized を返し、内部エラーの詳細をクライアントに漏らさないでください。

JWKSはHTTPS必須

JWKSをHTTP(平文)で配信すると、中間者攻撃で公開鍵を差し替えられ、任意のJWTが受理される恐れがあります。本番ではTLS 1.2以上、可能ならissuer固定で防御を重ねてください。

kid(Key ID)ヘッダーの役割とJWT kid 検証

kid(Key ID) はJWTヘッダーに含める任意(だが実務上は必須級)のフィールドで、署名に使った鍵を識別します。JWKSローテーションや複数テナント環境では、kidなし運用はほぼ不可能です。

RFC 7515(JWS)ではkidは「署名に使った鍵を示すヒント」として定義されています。検証側はkidを唯一の鍵選択手段として扱うべきで、「JWKSの最初の鍵を常に使う」フォールバックはローテーション時に必ず破綻します。単一鍵環境でも、将来のローテーションに備えてkidを付与する習慣をつけておくと、移行コストが下がります。

IdPがkidを省略するレガシー実装に遭遇した場合、運用上の回避策は2つです。一つはJWKSに鍵を1本だけ載せ続けること(ローテーション不可に近い)、もう一つはIdPベンダーへのkid対応要求です。自前Authorization Serverなら jwt.signkeyid オプションで一発です。kid未対応IdPを使い続ける場合は、契約更新時にJWKS kid対応をSLAに含める交渉材料にもなります。

{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "key-2026-06-21"
}

JWT kid 検証の処理順序

検証側(Resource Server)がBearer JWTを受け取ったとき、次の順序で処理します。

  1. 形式チェック — 3セグメント(Header.Payload.Signature)に分割できるか
  2. Headerデコードalg が許可リスト(RS256/ES256のみ等)に含まれるか。alg: none は拒否
  3. kid取得 — Headerの kid を読む。無い場合はフォールバック方針(単一鍵環境のみ許容)を決めておく
  4. JWKS解決 — jwks-rsa等で kid に一致する公開鍵を取得(キャッシュヒット or HTTP GET)
  5. 署名検証 — 取得した公開鍵でSignatureを検証
  6. クレーム検証exp(期限)、nbf(開始)、iss(発行者)、aud(対象)をチェック

kid不一致の典型ログ:

[JWT] Signing key not found for kid: key-2025-01-01
[JWT] Available kids in JWKS: key-2026-06-21, key-2025-12-01

このエラーは「旧鍵をJWKSから早く削除しすぎた」「発行側だけ新kidに切り替えたがJWKS未更新」「キャッシュが古いJWKSを返している」のいずれかが多いです。後述のトラブルシューティングに沿って切り分けます。

kidの命名規則

key-2026-06-21 のように日付ベースにすると、Runbook上「いつの鍵か」が一目で分かります。UUIDでも構いませんが、ローテーション履歴と対応付けやすい命名を推奨します。

RS256とES256の選択と署名アルゴリズム

2026年の新規プロジェクトでは、RS256ES256 のどちらを選ぶかが最初の設計判断になります。どちらもJWKSで公開鍵を配布でき、ローテーション手順も同一です。

RS256はRSA PKCS#1 v1.5 padding上のSHA-256署名です。2048bit鍵でもJWTヘッダー+ペイロード+署名で数百バイト程度になり、HTTPヘッダーに載せても問題になりにくいサイズです。一方ES256はP-256楕円曲線上のECDSA署名で、同等の暗号強度をより短い鍵長で実現します。モバイル回線やIoTデバイス向けに帯域を削りたい場合はES256が有利です。

既存システムとの互換性を最優先するならRS256一択です。Javaの古いJWTライブラリ、レガシーなAPI Gateway、社内のCOBOL連携ミドルウェアなど、ES256未対応のコンポーネントが1つでも残る場合は移行コストが跳ね上がります。逆にグリーンフィールドでNode.js/Go/RustのみならES256も十分検討に値します。

RS256からES256へのアルゴリズム移行もJWKSローテーションと同型です。Phase 1でES256鍵をJWKSに追加、Phase 2で新規発行をES256に切替、Grace Period後にRS256鍵を削除、という流れです。移行期間中は algorithms: ["RS256", "ES256"] の両方を許可し、kidで実際の鍵を選択します。検証側が両アルゴリズムに対応していることをステージングで必ず確認してください。

アルゴリズム 特徴
RS256 RSA 2048bit以上。ライブラリ互換性が最も高い。JWTサイズはES256より大きい
ES256 ECDSA P-256。JWTが短く帯域節約。モバイル・IoT向け。一部レガシー環境で未対応の例あり
HS256 対称鍵。JWKS非対応。マイクロサービス間共有に不向き。新規APIでは非推奨

検証側の jwt.verify では、許可するアルゴリズムを明示的ホワイトリストにします。Headerの alg を信用せず、検証オプションで固定することが重要です。alg: none 攻撃や HS256 ダウングレード攻撃を防ぐ基本防御です。

// 許可アルゴリズムを環境変数で管理する例
const ALLOWED_ALGORITHMS: jwt.Algorithm[] =
  process.env.JWT_ALGORITHMS?.split(",") as jwt.Algorithm[] ??
  ["RS256", "ES256"];

jwt.verify(token, getKey, {
  algorithms: ALLOWED_ALGORITHMS,
  issuer: ISSUER,
  audience: AUDIENCE,
  clockTolerance: 30,
});

同一JWKSエンドポイントにRS256用とES256用の鍵を同時に載せることも可能です。ただし運用複雑度が上がるため、通常は1つの alg に統一し、移行期のみ両方を載せるパターンが現実的です。チーム内で「アルゴリズム混在JWKS」を採用する場合は、Runbookにalg別のkid一覧表を添付し、ローテーション時にどのalgの鍵を操作しているかを明示してください。混同はkid不一致401の温床になります。

秘密鍵の生成とJWKS JSONの作成

ローテーションの第一歩は、新しい鍵ペアの生成です。Node.jsの crypto モジュールでRSAは2048bit以上(本番は3072bitまたは4096bit推奨)、ECはprime256v1(P-256) を使います。

鍵生成はローテーション当日の手動作業にせず、CIパイプラインまたは内部CLIツールに組み込みます。人間がopensslコマンドを打つ運用は、kidの打ち間違いやファイル権限(0o600未設定)のヒューマンエラーを招きます。本記事の generate-jwk.ts は環境変数 KID / KEY_ALG / RSA_BITS で再現可能な生成を目指しています。

生成直後の秘密鍵PEMは、即座にKMSへアップロードしローカルから削除するフローが理想です。開発マシンに本番秘密鍵が残る状態は、ノートPC紛失時のリスク面で避けてください。JWKS JSON(公開鍵のみ)はGit管理して問題ありません。public/jwks.json をリポジトリに置き、CIがNginxへrsyncする構成は一般的です。

merge-jwks.ts の REVOKE_KIDS 環境変数はPhase 4専用です。誤ってPhase 1で設定すると旧鍵がJWKSから消え、進行中のJWTが一斉に401になります。シェルスクリプト rotate-key.sh がPhaseを分岐しているのは、この操作ミスを構造的に防ぐためです。

鍵生成スクリプト(scripts/generate-jwk.ts

#!/usr/bin/env node
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";

type KeyAlgorithm = "RS256" | "ES256";

interface ExportedJwk {
  kty: string;
  use: string;
  alg: KeyAlgorithm;
  kid: string;
  n?: string;
  e?: string;
  crv?: string;
  x?: string;
  y?: string;
}

function generateKid(prefix: string): string {
  const today = new Date().toISOString().slice(0, 10);
  return `${prefix}-${today}`;
}

function exportRsaJwk(publicKey: crypto.KeyObject, kid: string): ExportedJwk {
  const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey;
  return {
    kty: "RSA",
    use: "sig",
    alg: "RS256",
    kid,
    n: jwk.n as string,
    e: jwk.e as string,
  };
}

function exportEcJwk(publicKey: crypto.KeyObject, kid: string): ExportedJwk {
  const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey;
  return {
    kty: "EC",
    use: "sig",
    alg: "ES256",
    kid,
    crv: jwk.crv as string,
    x: jwk.x as string,
    y: jwk.y as string,
  };
}

function main(): void {
  const alg = (process.env.KEY_ALG || "RS256") as KeyAlgorithm;
  const kid = process.env.KID || generateKid(alg === "RS256" ? "key" : "ec-key");
  const outDir = process.env.OUT_DIR || path.join(__dirname, "..", "keys");
  fs.mkdirSync(outDir, { recursive: true });

  if (alg === "RS256") {
    const bits = parseInt(process.env.RSA_BITS || "4096", 10);
    const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
      modulusLength: bits,
      publicKeyEncoding: { type: "spki", format: "pem" },
      privateKeyEncoding: { type: "pkcs8", format: "pem" },
    });
    const publicKeyObj = crypto.createPublicKey(publicKey);
    const jwk = exportRsaJwk(publicKeyObj, kid);
    fs.writeFileSync(path.join(outDir, `${kid}.private.pem`), privateKey, { mode: 0o600 });
    fs.writeFileSync(path.join(outDir, `${kid}.public.pem`), publicKey, { mode: 0o644 });
    fs.writeFileSync(path.join(outDir, `${kid}.jwk.json`), JSON.stringify(jwk, null, 2));
    console.log(JSON.stringify({ kid, alg, jwk }, null, 2));
    return;
  }

  const { publicKey, privateKey } = crypto.generateKeyPairSync("ec", {
    namedCurve: "P-256",
    publicKeyEncoding: { type: "spki", format: "pem" },
    privateKeyEncoding: { type: "pkcs8", format: "pem" },
  });
  const publicKeyObj = crypto.createPublicKey(publicKey);
  const jwk = exportEcJwk(publicKeyObj, kid);
  fs.writeFileSync(path.join(outDir, `${kid}.private.pem`), privateKey, { mode: 0o600 });
  fs.writeFileSync(path.join(outDir, `${kid}.public.pem`), publicKey, { mode: 0o644 });
  fs.writeFileSync(path.join(outDir, `${kid}.jwk.json`), JSON.stringify(jwk, null, 2));
  console.log(JSON.stringify({ kid, alg, jwk }, null, 2));
}

main();

JWKS JSONのマージスクリプト(scripts/merge-jwks.ts

複数の .jwk.json を1つのJWKSにまとめます。ローテーション中は削除せず追加が原則です。

#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";

interface JwkEntry {
  kid: string;
  kty: string;
  alg: string;
  use: string;
}

function main(): void {
  const keysDir = process.env.KEYS_DIR || path.join(__dirname, "..", "keys");
  const revokeFile = process.env.REVOKE_KIDS || "";
  const revoked = new Set(
    revokeFile.split(",").map((s) => s.trim()).filter(Boolean)
  );

  const files = fs.readdirSync(keysDir).filter((f) => f.endsWith(".jwk.json"));
  const keys: JwkEntry[] = [];

  for (const file of files) {
    const full = path.join(keysDir, file);
    const jwk = JSON.parse(fs.readFileSync(full, "utf8")) as JwkEntry;
    if (revoked.has(jwk.kid)) {
      continue;
    }
    keys.push(jwk);
  }

  keys.sort((a, b) => a.kid.localeCompare(b.kid));

  const jwks = { keys };
  const outPath = process.env.OUT_PATH || path.join(__dirname, "..", "public", "jwks.json");
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
  fs.writeFileSync(outPath, JSON.stringify(jwks, null, 2));

  console.log(`Wrote ${keys.length} keys to ${outPath}`);
  console.log("kids:", keys.map((k) => k.kid).join(", "));
}

main();

実行例:

npx ts-node scripts/generate-jwk.ts
npx ts-node scripts/merge-jwks.ts
cat public/jwks.json

Node.js/Express jwks-rsaによる検証ミドルウェア

本番のResource Serverでは jwks-rsajsonwebtoken の組み合わせが定番です。JWKSをHTTP取得し、kidに応じた公開鍵を getSigningKey で返します。

jwks-rsaは内部で node-cache 相当のメモリキャッシュを持ち、同一kidへの繰り返し検証ではHTTPリクエストを発行しません。高QPSのAPIではこのキャッシュが必須です。一方、ローテーション直後はキャッシュに旧JWKSスナップショットが残る可能性があるため、cacheMaxAge を300〜600秒程度に抑え、rateLimit: true でJWKS取得の暴発を防ぎます。

Expressミドルウェアとして実装する場合、認証失敗時のレスポンスボディは最小限にしてください。kid not found: key-xxx のような内部情報を返すと、攻撃者がJWKSの状態をプローブできます。ログには詳細を残し、クライアントには invalid_token 等の固定コードのみ返すのが安全です。

TypeScriptで req.auth を拡張する際は、グローバルな型宣言を1ファイルにまとめ、複数ミドルウェアで再利用します。後続の認可ミドルウェアが scope クレームを読む場合、JWTペイロードの型定義を厳密にしておくと、ランタイムエラーを減らせます。

OAuth2 PKCE SPA実装ガイドで取得したアクセストークンを、このミドルウェアで検証するイメージです。SPAから直接JWTをlocalStorageに置く構成はXSSリスクが高いため、BFF + HttpOnly Cookieが推奨されますが、いずれの構成でもResource Server側のJWKS検証ロジックは同じです。

JWT検証ミドルウェア(src/middleware/verifyJwt.ts

import type { Request, Response, NextFunction } from "express";
import jwt from "jsonwebtoken";
import jwksClient from "jwks-rsa";
import type { JwtHeader, SigningKeyCallback } from "jsonwebtoken";

const ISSUER = process.env.JWT_ISSUER ?? "https://auth.example.com";
const AUDIENCE = process.env.JWT_AUDIENCE ?? "https://api.example.com";
const JWKS_URI =
  process.env.JWKS_URI ?? "https://auth.example.com/.well-known/jwks.json";
const ALLOWED_ALGORITHMS = (process.env.JWT_ALGORITHMS ?? "RS256,ES256")
  .split(",") as jwt.Algorithm[];

export interface AuthPayload extends jwt.JwtPayload {
  sub: string;
  scope?: string;
}

declare global {
  namespace Express {
    interface Request {
      auth?: AuthPayload;
    }
  }
}

const client = jwksClient({
  jwksUri: JWKS_URI,
  cache: true,
  cacheMaxEntries: 10,
  cacheMaxAge: 600000,
  rateLimit: true,
  jwksRequestsPerMinute: 10,
  timeout: 5000,
});

function getKey(header: JwtHeader, callback: SigningKeyCallback): void {
  if (!header.kid) {
    callback(new Error("JWT header missing kid"));
    return;
  }

  client.getSigningKey(header.kid, (err, key) => {
    if (err) {
      callback(err);
      return;
    }
    const signingKey = key.getPublicKey();
    callback(null, signingKey);
  });
}

export function verifyJwtMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const authHeader = req.headers.authorization ?? "";
  const match = authHeader.match(/^Bearer\s+(.+)$/i);
  if (!match) {
    res.status(401).json({ error: "missing_bearer_token" });
    return;
  }

  const token = match[1];

  jwt.verify(
    token,
    getKey,
    {
      algorithms: ALLOWED_ALGORITHMS,
      issuer: ISSUER,
      audience: AUDIENCE,
      clockTolerance: 30,
    },
    (err, decoded) => {
      if (err) {
        if (err.name === "TokenExpiredError") {
          res.status(401).json({ error: "token_expired" });
          return;
        }
        if (err.name === "JsonWebTokenError") {
          res.status(401).json({ error: "invalid_token" });
          return;
        }
        res.status(401).json({ error: "authentication_failed" });
        return;
      }
      req.auth = decoded as AuthPayload;
      next();
    }
  );
}

export { client };

Express APIサーバー(src/server.ts

import express from "express";
import { verifyJwtMiddleware } from "./middleware/verifyJwt";

const app = express();
const PORT = parseInt(process.env.PORT ?? "3000", 10);

app.get("/health", (_req, res) => {
  res.json({ status: "ok" });
});

app.get("/api/me", verifyJwtMiddleware, (req, res) => {
  res.json({
    sub: req.auth?.sub,
    scope: req.auth?.scope,
    iss: req.auth?.iss,
  });
});

app.listen(PORT, () => {
  console.log(`Resource server listening on port ${PORT}`);
});

起動:

JWT_ISSUER=https://auth.example.com \
JWT_AUDIENCE=https://api.example.com \
JWKS_URI=https://auth.example.com/.well-known/jwks.json \
npx ts-node src/server.ts

Express認可サーバーとしての鍵発行実装

検証側だけでなく、発行側(Authorization Server) でもkidをHeaderに付与する必要があります。複数鍵を保持し、アクティブなkidだけで署名する実装例です。

自前Authorization Serverを運用するケースは、スタートアップのMVPからエンタープライズの内部IdPまで幅広く存在します。KeycloakやAuth0に比べて機能は限定的ですが、JWKSローテーションの挙動を完全にコントロールできる利点があります。特に ACTIVE_KID を環境変数1つで切り替えられる設計にしておくと、Phase 2のデプロイが kubectl set env やECSタスク定義更新だけで済みます。

/.well-known/jwks.json/.well-known/openid-configuration は両方実装してください。Resource ServerがDiscoveryから jwks_uri を自動解決する構成に将来移行しても、URLの二重管理を避けられます。Cache-Control: max-age=300 は発行側ExpressでもNginxでも統一し、多層キャッシュのTTLを揃えることが重要です。

client_credentialsグラントの例は最小構成です。Authorization Code + PKCEの完全実装はOAuth2 PKCE SPA実装ガイドを参照してください。本記事の signAccessToken は、PKCEフローでトークンエンドポイントが返すアクセストークンと同一形式です。

トークン発行サービス(src/auth/tokenService.ts

import fs from "node:fs";
import path from "node:path";
import jwt from "jsonwebtoken";

const KEYS_DIR = process.env.KEYS_DIR ?? path.join(__dirname, "..", "..", "keys");
const ACTIVE_KID = process.env.ACTIVE_KID ?? "key-2026-06-21";
const ISSUER = process.env.JWT_ISSUER ?? "https://auth.example.com";
const DEFAULT_ALG = (process.env.SIGN_ALG ?? "RS256") as jwt.Algorithm;

interface TokenPayload {
  sub: string;
  scope?: string;
  aud?: string | string[];
}

interface SignOptions {
  kid?: string;
  expiresIn?: string | number;
  algorithm?: jwt.Algorithm;
}

function loadPrivateKey(kid: string): string {
  const pemPath = path.join(KEYS_DIR, `${kid}.private.pem`);
  return fs.readFileSync(pemPath, "utf8");
}

export function signAccessToken(payload: TokenPayload, options: SignOptions = {}): string {
  const kid = options.kid ?? ACTIVE_KID;
  const privateKey = loadPrivateKey(kid);
  const expiresIn = options.expiresIn ?? "1h";
  const algorithm = options.algorithm ?? DEFAULT_ALG;

  return jwt.sign(payload, privateKey, {
    algorithm,
    keyid: kid,
    issuer: ISSUER,
    audience: payload.aud ?? "https://api.example.com",
    expiresIn,
  });
}

export function decodeHeader(token: string): jwt.JwtHeader {
  const headerPart = token.split(".")[0];
  const json = Buffer.from(headerPart, "base64url").toString("utf8");
  return JSON.parse(json) as jwt.JwtHeader;
}

export { ACTIVE_KID, ISSUER };

認可サーバー(src/auth/server.ts

import express from "express";
import fs from "node:fs";
import path from "node:path";
import { signAccessToken, decodeHeader } from "./tokenService";

const app = express();
app.use(express.json());

const PORT = parseInt(process.env.PORT ?? "4000", 10);
const JWKS_PATH =
  process.env.JWKS_PATH ?? path.join(__dirname, "..", "..", "public", "jwks.json");

app.get("/.well-known/jwks.json", (_req, res) => {
  const jwks = JSON.parse(fs.readFileSync(JWKS_PATH, "utf8"));
  res.set("Cache-Control", "public, max-age=300");
  res.set("Content-Type", "application/json");
  res.json(jwks);
});

app.get("/.well-known/openid-configuration", (_req, res) => {
  const issuer = process.env.JWT_ISSUER ?? "https://auth.example.com";
  res.json({
    issuer,
    jwks_uri: `${issuer}/.well-known/jwks.json`,
    token_endpoint: `${issuer}/oauth2/token`,
    response_types_supported: ["code"],
    subject_types_supported: ["public"],
    id_token_signing_alg_values_supported: ["RS256", "ES256"],
  });
});

app.post("/oauth2/token", (req, res) => {
  const { client_id, client_secret, grant_type } = req.body as {
    client_id?: string;
    client_secret?: string;
    grant_type?: string;
  };

  if (grant_type !== "client_credentials") {
    res.status(400).json({ error: "unsupported_grant_type" });
    return;
  }

  if (client_id !== "demo-client" || client_secret !== "demo-secret") {
    res.status(401).json({ error: "invalid_client" });
    return;
  }

  const accessToken = signAccessToken({
    sub: "service-account-demo",
    scope: "read:users write:users",
    aud: "https://api.example.com",
  });

  const header = decodeHeader(accessToken);

  res.json({
    access_token: accessToken,
    token_type: "Bearer",
    expires_in: 3600,
    kid: header.kid,
  });
});

app.listen(PORT, () => {
  console.log(`Auth server listening on port ${PORT}`);
});

トークン取得とkid確認:

curl -s -X POST http://localhost:4000/oauth2/token \
  -H "Content-Type: application/json" \
  -d '{"grant_type":"client_credentials","client_id":"demo-client","client_secret":"demo-secret"}' \
  | jq .

KMS・Vaultによる秘密鍵の安全な保管

JWKSで配布するのは公開鍵のみです。秘密鍵はAuthorization Serverだけが保持し、Gitリポジトリ・Slack・Wikiには絶対に置きません。2026年の本番運用では、ローカルPEMファイルは開発環境に限定し、本番はKMSまたはSecrets Manager経由で読み込むのが標準です。

保管先向きローテーション時の操作
AWS KMSマネージド鍵、監査ログ付き新Alias作成 → ACTIVE_KID切替 → 旧Alias無効化
GCP Cloud KMSHSMバックオプション鍵バージョン追加 → 最新版で署名
HashiCorp Vaultオンプレ・マルチクラウドvault write で新鍵 → JWKS生成ジョブ
ローカルPEM開発・CIのモックのみ.gitignore + pre-commit検出

KMSに秘密鍵を置いても、JWKS JSONの生成は別プロセスです。KMSから公開鍵部分をエクスポートし、JWK形式に変換して jwks.json を組み立てるCIジョブを用意します。ローテーションRunbookのPhase 1では「KMSに新鍵作成 → CIがJWKS再生成 → Nginxへデプロイ」の順序になります。

秘密鍵へのアクセス権限はAuthorization Serverのサービスアカウントだけに絞り、Resource Server(API)はJWKS URIへのHTTPSアクセスのみ許可します。この分離が、API侵害時にトークン偽造まで波及しない最小権限モデルです。

開発環境と本番鍵の分離

開発用 auth.localhost のJWKSと本番 auth.example.com のJWKSはkid命名規則から分けてください(例: dev-key-2026-06-21key-2026-06-21)。ステージングJWTが本番APIで通る事故を構造的に防げます。

ゼロダウンタイム JWKS ローテーションの設計原則

JWKSローテーション で最も多い障害は、「新鍵で署名したJWTを、旧JWKSしか見ていない検証側が拒否する」パターンです。これを防ぐ オーバーラップ方式 がゼロダウンタイムの核心です。

2026年の運用ベストプラクティスでは、旧鍵をJWKSに残す待機期間を Grace Period(猶予期間) と呼びます。Grace Period = 最大JWT TTL + バッファ(通常15分) が目安です。

1

Phase 1 — 新鍵を生成し、JWKSに追加(旧鍵は削除しない)

2

Phase 2 — トークン発行の ACTIVE_KID を新kidに切り替え(新JWTは新kidで署名)

3

Phase 3 — Grace Period: 旧kidで署名されたJWTがすべて exp 切れするまで待機

4

Phase 4 — JWKSから旧kidを削除し、merge-jwks.ts で再生成・デプロイ

タイムライン例

日時JWKSのkids発行に使うkid備考
6/21 10:00key-2025-12-01key-2025-12-01ローテーション前
6/21 10:15key-2025-12-01, key-2026-06-21key-2025-12-01新鍵追加のみ
6/21 11:00key-2025-12-01, key-2026-06-21key-2026-06-21発行切替
6/22 12:00key-2026-06-21key-2026-06-21Grace Period経過後に旧鍵削除

アクセストークンTTLが1時間なら、発行切替後最低1時間15分は旧kidをJWKSに残します。リフレッシュトークンや長寿命JWTを使っている場合は、その最大寿命まで旧鍵を保持してください。

2026年のコンプライアンス要件(PCI DSS、SOC2等)では、署名鍵の定期ローテーションが監査項目に含まれることが増えています。四半期ローテーションをカレンダーに登録し、Runbook・リハーサル記録・変更管理チケットをセットで保管すると、監査時の説明がスムーズです。ローテーション自体がゼロダウンタイムでも、文書化と訓練が欠けると組織的には「未実施」とみなされる点に注意してください。

削除は最後に

「新鍵を先に追加せず、旧鍵をJWKSから削除してから新鍵を載せる」順序は必ず障害を招きます。追加 → 切替 → Grace Period → 削除の順序をRunbookに明文化してください。

ローテーション自動化スクリプト(scripts/rotate-key.sh

#!/usr/bin/env bash
set -euo pipefail

PHASE="${1:-}"
OLD_KID="${OLD_KID:-}"
NEW_KID="${NEW_KID:-}"

ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT_DIR"

case "$PHASE" in
  prepare)
    echo "=== Phase 1: Generate new key and merge JWKS (keep old) ==="
    npx ts-node scripts/generate-jwk.ts
    NEW_KID="$(node -e "const fs=require('fs');const p=require('path');const d=p.join('keys');const f=fs.readdirSync(d).filter(x=>x.endsWith('.jwk.json')).sort().pop();console.log(JSON.parse(fs.readFileSync(p.join(d,f))).kid)")"
    echo "NEW_KID=$NEW_KID"
    npx ts-node scripts/merge-jwks.ts
    echo "Deploy JWKS now, then run: ACTIVE_KID=$NEW_KID $0 switch"
    ;;
  switch)
    if [[ -z "${NEW_KID}" ]]; then
      echo "NEW_KID env required"
      exit 1
    fi
    echo "=== Phase 2: Switch ACTIVE_KID to $NEW_KID ==="
    echo "Update deployment env ACTIVE_KID=$NEW_KID and redeploy auth server"
    echo "Verify new tokens have kid=$NEW_KID"
    ;;
  cleanup)
    if [[ -z "${OLD_KID}" ]]; then
      echo "OLD_KID env required"
      exit 1
    fi
    echo "=== Phase 4: Remove $OLD_KID from JWKS ==="
    REVOKE_KIDS="$OLD_KID" npx ts-node scripts/merge-jwks.ts
    echo "Deploy JWKS without $OLD_KID"
    rm -f "keys/${OLD_KID}.private.pem" "keys/${OLD_KID}.public.pem" "keys/${OLD_KID}.jwk.json"
    echo "Removed local key files for $OLD_KID"
    ;;
  *)
    echo "Usage: $0 {prepare|switch|cleanup}"
    exit 1
    ;;
esac

ローテーション Runbook(手順書)

本番で JWKS ローテーション を実施するときのチェックリストです。変更管理チケット番号を各Phaseに記録してください。四半期に1回程度の定期ローテーションと、漏洩疑い時の緊急ローテーションでは、Phase 3のGrace Periodの取り方が異なります。定期ローテーションでは最大TTLに余裕を持たせ、緊急時は旧トークンを即座に無効化(セッション失効API・トークンリボケーション)する並行措置を検討してください。

ローテーション作業は平日日中・オンコール体制で実施するのが原則です。金曜夜のデプロイは、Grace Period中に週末障害が起きても対応できないリスクがあります。ステージング環境で同一Runbookを48時間前にリハーサルし、curlコマンドとダッシュボードURLをチケットに貼っておくと当日の手戻りが減ります。

Phase 0: 事前確認

  • 現在のJWKS URLが正常(200 OK、keys 配列が非空)
  • 現在のACTIVE_KIDを記録
  • アクセストークン最大TTL(例: 3600秒)とリフレッシュトークンTTLを確認
  • Grace Period = 最大TTL + 15分 を計算してカレンダーに登録
  • ステージングで同手順のリハーサル完了
  • ロールバック手順(ACTIVE_KIDを旧値に戻す)を用意

Phase 1: 新鍵追加(ダウンタイムなし)

bash scripts/rotate-key.sh prepare
curl -s https://auth.example.com/.well-known/jwks.json | jq '.keys[].kid'
  • JWKSに新旧両kidが存在
  • まだ発行は旧kidのまま(既存動作に影響なし)

Phase 2: 発行切替

export ACTIVE_KID=key-2026-06-21
bash scripts/rotate-key.sh switch

検証:

TOKEN=$(curl -s -X POST https://auth.example.com/oauth2/token \
  -H "Content-Type: application/json" \
  -d '{"grant_type":"client_credentials","client_id":"demo-client","client_secret":"REDACTED"}' \
  | jq -r .access_token)

echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null | jq .
  • 新トークンのkidが新kid
  • Resource Serverで /api/me が200を返す

Phase 3: Grace Period(待機期間)

  • 旧kidで署名されたJWTの最大exp + 15分バッファを経過
  • 401率・Signing key not found ログが増えていない

Phase 4: 旧鍵削除

export OLD_KID=key-2025-12-01
bash scripts/rotate-key.sh cleanup
curl -s https://auth.example.com/.well-known/jwks.json | jq '.keys[].kid'
  • JWKSに新kidのみ
  • 秘密鍵ファイルをKMS/Vaultからも削除
  • 変更管理チケットをクローズ

ロールバック手順

Phase 2直後に問題が起きた場合:

  1. ACTIVE_KID を旧kidに戻して認可サーバーを再デプロイ
  2. JWKSは新旧両方載ったまま維持(Phase 1の状態)
  3. 新kidで発行されたJWTは短期TTLなら自然失効を待つ

NginxでのJWKS配信とキャッシュ設定

認可サーバー前段の Nginx でJWKSを静的配信する構成は、Authトラフィックと分離できて安定します。/.well-known/jwks.json 用のlocationを独立させます。

Node.jsプロセスがJWKSリクエストで忙しくなるのを防ぐ効果も大きいです。トークンエンドポイントへの大量POSTとJWKSへのGETが同一プロセスを共有していると、ローテーション直後にJWKS取得がタイムアウトし、連鎖的に全APIが401になる事例があります。静的ファイル配信に切り出すと、JWKSはNginxの sendfile で効率よく返せます。

try_files@auth_jwks フォールバックは、CI/CDが jwks.json を配置する前の空白期間を埋める安全弁です。本番では常にファイルが存在する状態を維持し、フォールバックは監視対象にしてください。フォールバックが発動している間はNode側の動的JWKSが使われており、Nginx静的配信と内容が乖離している可能性があります。

CORSヘッダー Access-Control-Allow-Origin: * は、ブラウザ上のJavaScriptからJWKSを直接fetchするSPA向けです。Resource Serverがサーバー側でJWKSを取得する構成ではCORSは不要ですが、公開鍵であるため付与しても機密性は下がりません。逆に、トークンエンドポイントにはCORSを厳格に制限してください。

Nginx設定(/etc/nginx/conf.d/auth.example.com.conf

upstream auth_node {
    server 127.0.0.1:4000;
    keepalive 32;
}

server {
    listen 443 ssl http2;
    server_name auth.example.com;

    ssl_certificate     /etc/ssl/certs/auth.example.com.fullchain.pem;
    ssl_certificate_key /etc/ssl/private/auth.example.com.key;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;

    add_header X-Content-Type-Options nosniff always;
    add_header X-Frame-Options DENY always;

    location = /.well-known/jwks.json {
        alias /var/www/auth/jwks.json;
        default_type application/json;
        add_header Cache-Control "public, max-age=300, must-revalidate" always;
        add_header Access-Control-Allow-Origin "*" always;
        try_files $uri @auth_jwks;
    }

    location @auth_jwks {
        proxy_pass http://auth_node;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location = /.well-known/openid-configuration {
        proxy_pass http://auth_node;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        add_header Cache-Control "public, max-age=3600" always;
    }

    location /oauth2/ {
        proxy_pass http://auth_node;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        limit_req zone=token_endpoint burst=20 nodelay;
    }
}

limit_req_zone $binary_remote_addr zone=token_endpoint:10m rate=30r/m;

JWKSデプロイ(CI/CDから):

install -m 644 -D public/jwks.json /var/www/auth/jwks.json
nginx -t && nginx -s reload
CDNキャッシュ

Cloudflare等のCDNをJWKS前段に置く場合、キャッシュTTLを300秒以下にし、Phase 1/4のデプロイ後はキャッシュパージをRunbookに含めてください。CDNが旧JWKSを返し続けると、kid不一致が長時間続きます。

キャッシュ・TTL・Grace Periodの関係

jwks-rsaはメモリ内キャッシュ(cacheMaxAge)を持ちます。NginxやCDNにもキャッシュ層があるため、多層キャッシュのどこかで古いJWKSが残ると、新kidのJWTだけ401になる現象が起きます。

キャッシュ設計の基本原則は「JWKSの更新頻度は低い(月次〜四半期)が、更新時の反映遅延は短くしたい」という矛盾を、短いTTL + ローテーション時パージで解くことです。通常時は5分キャッシュでJWKS取得負荷を95%以上削減でき、ローテーション時だけCDNパージとPod再起動で即時反映します。

Grace Periodは「キャッシュTTL」とは別概念です。キャッシュが古くても、旧kidがJWKSに残っていれば旧JWTは検証可能です。逆に、Grace Period前に旧kidを削除すると、キャッシュに関係なく即座に401になります。運用チームが混同しやすいポイントなので、Runbookには「Grace Period = 鍵の寿命」「Cache TTL = 配信の遅延」と明記してください。

シナリオJWKSのkeysキャッシュ状態結果
正常(ローテーション前)旧kidのみ任意旧JWT検証OK
Phase 1完了旧+新kid古いキャッシュ(旧のみ)新JWTのみ401の可能性
Phase 1 + CDNパージ旧+新kid最新新旧両方OK
Phase 4早すぎ新kidのみ任意旧JWT即401

監視メトリクスとアラート

JWKS運用では、認証失敗の急増JWKS取得失敗を早期検知します。401エラーはクライアント側のトークン期限切れでも発生するため、単純な401カウントだけではローテーション障害を検知できません。jwt_kid_unknown_total のように理由別にラベル付けしたメトリクスが鍵です。

ダッシュボードには最低限、次の4パネルを用意してください。① JWKS HTTP取得成功率、② kid不一致率(ローテーション期間中は許容、平常時はゼロ)、③ JWT検証レイテンシ p99、④ 認可サーバーのACTIVE_KID(外部ラベルとして)。ローテーション当日は③が一時的に上がることもありますが、JWKS取得タイムアウトが続く場合はネットワークまたはNginx設定を疑います。

アラートのベースラインは「過去7日間の同曜日同時間帯」と比較する方法が誤検知を減らします。月曜朝のログイン集中で401が増えるパターンと、kid不一致で401が増えるパターンは形状が似るため、メトリクスのラベル分離が不可欠です。PagerDuty等に飛ばすアラートは jwt_kid_unknown_totaljwks_fetch_errors_total に限定し、単純な token_expired はINFOログに留める運用が現実的です。

推奨メトリクス

メトリクス説明アラート閾値例
jwt_verify_failures_total署名・kid・exp失敗のカウンタ5分間でベースラインの3倍
jwks_fetch_errors_totalJWKS HTTP非200、タイムアウト1件以上/5分
jwks_fetch_duration_secondsJWKS取得レイテンシ p992秒超
jwt_kid_unknown_totalkid不一致ローテーション外で1件以上/分

Prometheus用カスタム計装(src/middleware/jwtMetrics.ts

import client from "prom-client";
import type { JwtHeader, SigningKeyCallback } from "jsonwebtoken";

export const jwtVerifyFailures = new client.Counter({
  name: "jwt_verify_failures_total",
  help: "Total JWT verification failures",
  labelNames: ["reason"],
});

export const jwksFetchErrors = new client.Counter({
  name: "jwks_fetch_errors_total",
  help: "Total JWKS fetch errors",
});

export const jwtKidUnknown = new client.Counter({
  name: "jwt_kid_unknown_total",
  help: "JWT presented with kid not in JWKS",
  labelNames: ["kid"],
});

export function wrapGetKey(
  originalGetKey: (header: JwtHeader, callback: SigningKeyCallback) => void
): (header: JwtHeader, callback: SigningKeyCallback) => void {
  return function wrappedGetKey(header: JwtHeader, callback: SigningKeyCallback): void {
    if (!header.kid) {
      jwtVerifyFailures.inc({ reason: "missing_kid" });
      callback(new Error("JWT header missing kid"));
      return;
    }
    originalGetKey(header, (err, key) => {
      if (err) {
        if (err.message.includes("Unable to find a signing key")) {
          jwtKidUnknown.inc({ kid: header.kid });
          jwtVerifyFailures.inc({ reason: "kid_not_found" });
        } else {
          jwksFetchErrors.inc();
          jwtVerifyFailures.inc({ reason: "jwks_error" });
        }
      }
      callback(err, key);
    });
  };
}

ローテーション当日は、Runbook Phase 2の前後30分はオンコール体制にし、jwt_kid_unknown_total のダッシュボードを開いた状態で作業するのが安全です。作業完了後24時間はアラート閾値を平常より敏感に設定し、Grace Period終了までは旧kid関連のログをウォッチしてください。

トラブルシューティング

本番でJWKS関連の401が急増したときの切り分け手順です。症状から逆引きできるよう、よくあるパターンを整理します。ローテーション当日以外でも、CDN障害・設定ミス・デプロイ順序の誤りで同じ症状が出るため、Runbookを常時参照できる状態にしておくことを推奨します。

症状別クイック診断

症状第一候補確認コマンド
新kidのJWTだけ401JWKS未更新 or CDNキャッシュcurl -s URL | jq '.keys[].kid'
全JWTが401JWKS取得失敗curl -sI URL、Nginxエラーログ
旧kidのみ401Grace Period前に旧鍵削除JWKSのkeys数を確認
間欠的401jwks-rsa rateLimitメトリクス jwks_fetch_errors_total

手順1: JWKSエンドポイントの直接確認

curl -sI https://auth.example.com/.well-known/jwks.json
curl -s https://auth.example.com/.well-known/jwks.json | jq '.keys | map(.kid)'

期待値: HTTP 200、keys 配列にJWTのkidが含まれる。CDN経由とオリジン直叩きで結果が異なる場合はCDNキャッシュが原因です。

手順2: JWTヘッダーのkid確認

echo "$ACCESS_TOKEN" | cut -d. -f1 | base64 -d | jq '{alg, kid}'

kidがJWKSに存在しない場合、Phase 1(新鍵追加)が未完了か、Phase 4(旧鍵削除)が早すぎる可能性があります。複数のResource Serverインスタンスが異なるJWKSキャッシュ世代を持っている場合、インスタンスごとに401の出方がばらつくこともあります。ロードバランサ配下ではローテーション直後にローリング再起動をRunbookに含めると、キャッシュ世代のズレを短時間で解消できます。

手順3: 検証側キャッシュのクリア

import { client } from "./middleware/verifyJwt";

client.getSigningKeys((err, keys) => {
  if (err) {
    console.error("getSigningKeys error:", err.message);
    process.exit(1);
  }
  console.log("cached keys:", keys.map((k) => k.kid));
});

プロセス再起動でもキャッシュはクリアされます。ローテーション直後は、JWKS URLのcurl確認 → APIプロセス再起動 → CDNパージの順で試してください。

手順4: iss / aud 不一致の切り分け

kid解決後も401が続く場合、署名検証は成功しているがクレーム検証で失敗している可能性があります。ステージングJWTが本番APIに到達していないか、JWT_ISSUER / JWT_AUDIENCE 環境変数を確認してください。

jsonwebtokenTokenExpiredErrorJsonWebTokenError は別名で返りますが、ログには err.message 全文を残してください。jwt audience invalid. expected: https://api.example.com のようなメッセージは、OAuth2クライアント登録時の aud とAPI側 JWT_AUDIENCE の不一致を示します。OAuth2 PKCE SPA実装ガイドで設定したclient_idやaudienceと、Resource Serverの環境変数が1文字でも違うとここで失敗します。

手順5: ステージングでの再現テスト

本番障害の原因がJWKSかクレームか切り分けられない場合、ステージングで次を順に実行します。① 認可サーバーからトークン取得、② HeaderのkidとJWKSのkeys照合、③ Resource Serverの /api/me へBearer付きリクエスト、④ 401時は検証ミドルウェアのログで失敗理由を確認。ステージングで再現しない場合、本番特有のCDN・WAF・環境変数差分を疑います。

よくある失敗パターン

kidを付け忘れたJWTを発行jwt.sign に必ず keyid: ACTIVE_KID を渡す。CIで発行トークンのHeaderを自動検証する。

alg:none や HS256へのダウングレードalgorithms: ["RS256", "ES256"] を明示。Headerの alg を信用しない。

iss / aud 検証の省略 — 別環境で発行された正当なJWTが本番APIで通る。環境変数で固定し検証必須にする。

ローテーション中のCDNキャッシュ — JWKSは更新済みだがエッジが旧JSONを返す。RunbookにCDNパージを組み込む。

Grace Period不足 — リフレッシュトークンTTLが7日なのに旧鍵を1時間後に削除した。最大JWT寿命をRunbook Phase 0で必ず洗い出す。

複数リージョンのJWKS不整合 — 東京リージョンだけ新JWKS、大阪は旧JWKSのまま。グローバルデプロイでは全リージョンの keys 配列をcurlで横断確認する。

時刻ずれ(clock skew) — 署名は成功するが exp / nbf で失敗。NTP同期と clockTolerance: 30 の設定を確認。

秘密鍵のGitコミット — リポジトリ漏洩で全JWTが偽造可能に。KMS/Vault管理、pre-commitフックで検出。

keys/*.private.pem
.env
.env.*

JWTとCSRF・セッションの併用設計

JWT検証だけではCSRF対策は完結しません。BFFがOAuth2トークンをHttpOnly Cookieに載せてSPAに返す構成では、CSRF対策の実装ガイドで述べたSameSite CookieSynchronizer Tokenが必要です。

認証アーキテクチャを3層に分解すると理解しやすくなります。第1層はブラウザ ↔ BFFのセッション(Cookieベース、CSRFリスクあり)。第2層はBFF ↔ 認可サーバーのOAuth2(PKCE、トークン交換)。第3層はBFF ↔ マイクロサービスのサービス間JWT(Bearer + JWKS検証、CSRFリスク低)。各層で脅威モデルが異なるため、JWKS検証だけを入れてもCookieセッションのCSRF穴は塞がりません。

SPAが Authorization: Bearer ヘッダーで直接APIを叩く構成(パブリッククライアント + PKCE)では、CSRFよりXSSによるトークン窃取が主脅威です。トークンをlocalStorageに置かない、Content Security Policyを段階導入する、といった対策が優先されます。Cookieセッションに切り替える場合は、JWT検証に加えてCSRF対策のSynchronizer Tokenを状態変更APIに適用してください。

内部マイクロサービス間通信でJWTを使う場合、ゲートウェイがJWTを検証した後、下流サービスにユーザー情報をヘッダー転送するパターンもあります。この場合も下流がJWTを再検証するなら同一JWKS URIを共有し、kidローテーションの影響範囲を文書化しておきます。

認証方式CSRFリスク推奨対策
Bearer JWT(Authorizationヘッダー)低(Cookie自動送信なし)XSS対策が主
HttpOnly CookieセッションSameSite + CSRF Token
BFF + Cookie + 内部JWTCookie層でCSRF、内部JWT層でJWKS

Pure API(モバイルアプリやサーバー間通信)ではBearer JWT + JWKS検証が主戦場です。ブラウザSPA向けにはOAuth2 PKCE SPA実装ガイドのBFFパターンと本記事のJWKS検証を組み合わせるのが2026年の定石です。モバイルSDKがKeychainにトークンを保持する構成ではCSRFは通常問題になりませんが、端末紛失時のリモート失効とJWKSローテーションのGrace Periodは別軸で設計してください。

セキュリティチェックリスト

本番リリース前に確認する項目です。チェックリストはPDFやNotionに固定化し、新メンバーのオンボーディング資料にも組み込んでください。JWKS運用は「設定したら忘れる」タイプのインフラなので、四半期ごとにこのリストを全項目再確認する習慣が有効です。

鍵管理

  • 秘密鍵はKMS/Vault管理、GitにPEMが含まれていない
  • 開発・ステージング・本番でkid命名規則が分離されている
  • ローテーションRunbookが最新(Grace Period計算式含む)
  • 緊急ローテーション手順(漏洩疑い)が別途文書化されている

検証側(Resource Server)

  • JWKSはHTTPSのみ(HTTP→HTTPSリダイレクト)
  • JWT Headerにkidが含まれる
  • 検証側は algorithms: ["RS256"] または ["RS256", "ES256"] を明示
  • iss / aud / exp を検証
  • 401レスポンスにスタックトレースやJWKS URLを含めない
  • jwks-rsaの cacheMaxAge が300〜600秒の範囲

発行側(Authorization Server)

  • jwt.signkeyid(ACTIVE_KID)を必ず付与
  • JWKSにローテーション中は複数kidが載る設計
  • Cache-Control: max-age=300 がJWKSレスポンスに付与
  • Discovery(openid-configuration)の jwks_uri が正しい

運用・監視

  • jwt_kid_unknown_total メトリクスがダッシュボードにある
  • ローテーションリハーサルが年1回以上実施済み
  • CDNパージ手順がRunbook Phase 1/4に含まれる
  • BFF構成ではCSRF対策を併用

関連記事

この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
XSS攻撃と防御の完全ガイドXSS攻撃と防御の完全ガイド|反射型・蓄積型・DOM型の対策
Double Submit Cookie完全実装Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃
Content Security Policy (CSP) 設定ガイドContent Security Policy (CSP) 設定ガイド|XSS・クリックジャッキング対策
CSP Report-Only 段階導入ガイドCSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する
セキュリティヘッダー一覧と設定ガイドセキュリティヘッダー一覧と設定ガイド|CSP・HSTS・Referrer-Policy・Permissions-Policy
セキュアなCookie設定ガイドセキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策
CORSエラーの対処法CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する
CORSプリフライトリクエストCORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答
ローカル開発でCORSエラーが出る原因と3つの回避策ローカル開発でCORSエラーが出る原因と3つの回避策
Passkeys・WebAuthn実装の基礎Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server
JWTのデバッグ方法JWTのデバッグ方法|トークンの中身を安全に確認する
JWTの中身を確認する方法JWTの中身を確認する方法|改ざんが検知される仕組み

まとめ

JWKSエンドポイント(RFC 7517)は非対称鍵JWTの公開鍵配布の標準手段です。kidヘッダーにより複数鍵共存時も正しい公開鍵を選択でき、JWKSローテーションは「追加 → 切替 → Grace Period → 削除」でゼロダウンタイムを実現します。

本記事で扱った概念は、特定のSaaS IdPに依存しません。Auth0・Cognito・Keycloak・自前Expressサーバーいずれでも、JWKS URLを開いて keys 配列に複数のkidがあるか、新規トークンのHeaderにkidがあるか、を確認すれば運用状態が読み取れます。障害時はcurlとjqだけで第一切り分けが可能なのも、HTTP標準で配布する利点です。

実装の要点:

  • 発行側: jwt.signkeyid を付与、JWKSに新旧鍵を並載
  • 検証側: jwks-rsa + アルゴリズムホワイトリスト + iss/aud検証
  • インフラ: NginxCache-Control: max-age=300、CDNパージをRunbook化
  • 運用: Phase別Runbook、Grace Period、メトリクス監視、年次リハーサル
  • 連携: OAuth2 PKCEでトークン取得、CSRF対策でBFFセッション保護

鍵ローテーションは「一度設定したら終わり」ではなく、四半期〜年次の定期運用としてカレンダーに組み込み、ステージングでのリハーサルを習慣化してください。

最後に、JWKS運用で最も重要な心構えを1つだけ挙げます。公開鍵の配布は追加が先、削除は最後——この順序を守る限り、検証側のコード変更なしに鍵を更新できます。kid・Grace Period・キャッシュTTLの3つをセットで設計すれば、2026年の本番環境でも安心してJWT非対称鍵認証を運用できるはずです。