JWTのキーローテーションを正しく実装できていますか?
「秘密鍵を変えたらトークンが全部無効になった」——これ、ゼロダウンタイムが必要な本番環境では致命的です。
Claude Codeに以下のルールをCLAUDE.mdに記述することで、JWK・kid管理・ゼロダウンタイムローテーションの設計を一気に実装できます。
CLAUDE.mdルール定義
まずClaude Codeへの指示をまとめます。
# JWT Key Rotation Rules
- 常時2つの鍵を保持する: current(署名用)と previous(検証用)
- 各鍵にはkidを付与し、JWTヘッダーのkidフィールドで使用鍵を識別する
- ローテーション後30分経過したら previous を破棄する
- JWKS(/.well-known/jwks.json)エンドポイントを公開し、公開鍵を配布する
これを読み込んだClaude Codeは、設計ルールに従ったコードを生成します。
JWKKeyManagerクラスの実装
import { SignJWT, importJWK, exportJWK, generateKeyPair } from 'jose';
import { v4 as uuidv4 } from 'uuid';
interface KeyEntry {
kid: string;
privateKey: CryptoKey;
publicKey: CryptoKey;
createdAt: Date;
role: 'current' | 'previous';
}
export class JWKKeyManager {
private keys: Map<string, KeyEntry> = new Map();
async generateNewKey(): Promise<void> {
// currentをpreviousに降格
for (const [kid, entry] of this.keys) {
if (entry.role === 'current') {
entry.role = 'previous';
// 30分後に自動削除
setTimeout(() => {
this.keys.delete(kid);
console.log(`[KeyRotation] Removed expired key: ${kid}`);
}, 30 * 60 * 1000);
}
}
// 新しいcurrentを生成
const { privateKey, publicKey } = await generateKeyPair('RS256');
const kid = uuidv4();
this.keys.set(kid, {
kid,
privateKey,
publicKey,
createdAt: new Date(),
role: 'current',
});
console.log(`[KeyRotation] New current key generated: ${kid}`);
}
getCurrentKey(): KeyEntry | undefined {
for (const entry of this.keys.values()) {
if (entry.role === 'current') return entry;
}
return undefined;
}
getKeyById(kid: string): KeyEntry | undefined {
return this.keys.get(kid);
}
}
ポイント:
-
generateNewKey()でcurrent→previousへ降格 - previousは30分後に自動削除(
setTimeout管理) - kidはUUIDで一意性を保証
signToken():kidをヘッダーに埋め込む
export async function signToken(
keyManager: JWKKeyManager,
payload: Record<string, unknown>
): Promise<string> {
const currentKey = keyManager.getCurrentKey();
if (!currentKey) throw new Error('No current signing key available');
const token = await new SignJWT(payload)
.setProtectedHeader({ alg: 'RS256', kid: currentKey.kid })
.setIssuedAt()
.setExpirationTime('1h')
.sign(currentKey.privateKey);
return token;
}
setProtectedHeaderのkidフィールドに現在の鍵IDを埋め込むことで、検証時にどの鍵を使うべきか特定できます。
verifyToken():kidで鍵を引き当てる
import { jwtVerify, decodeProtectedHeader } from 'jose';
export async function verifyToken(
keyManager: JWKKeyManager,
token: string
): Promise<Record<string, unknown>> {
// ヘッダーからkidを取得
const header = decodeProtectedHeader(token);
if (!header.kid) throw new Error('Token missing kid in header');
const keyEntry = keyManager.getKeyById(header.kid);
if (!keyEntry) throw new Error(`Unknown kid: ${header.kid}`);
const { payload } = await jwtVerify(token, keyEntry.publicKey);
return payload as Record<string, unknown>;
}
なぜこの設計が安全か:
- ローテーション直後でも
previous鍵が30分間残るため、既存トークンは検証可能 - kidが存在しない場合はエラーで弾く(不正トークンの早期検出)
/.well-known/jwks.jsonエンドポイント
クライアントが公開鍵を取得できるよう、JWKSエンドポイントを公開します。
import express from 'express';
const app = express();
app.get('/.well-known/jwks.json', async (req, res) => {
const jwks = [];
for (const entry of keyManager.getAllKeys()) {
const jwk = await exportJWK(entry.publicKey);
jwks.push({ ...jwk, kid: entry.kid, use: 'sig', alg: 'RS256' });
}
res
.setHeader('Cache-Control', 'public, max-age=3600')
.json({ keys: jwks });
});
Cache-Control: max-age=3600で1時間キャッシュを許可しつつ、ローテーション後は古いキャッシュが切れるまでprevious鍵で検証継続できます。
まとめ
Claude Codeにルールを与えるだけで、以下の4点を満たした実装が生成されます:
- kidヘッダー管理 — JWTのどの鍵で署名したかを明示
- JWKSエンドポイント — 公開鍵を標準形式で配布
- ゼロダウンタイムローテーション — previousを30分間保持して既存トークンを継続検証
- 自動クリーンアップ — 古い鍵はタイマーで自動削除
キーローテーションを「後回し」にすると、鍵漏洩時に全ユーザーのセッションを強制失効させる選択肢しか残りません。最初から設計に組み込んでおきましょう。
セキュリティ設計をもっと強化したい方へ
JWT以外のOWASP Top 10対策・APIセキュリティ設計を網羅したSecurity PackをPromptWorksで販売中です。
-
/security-check— コードのセキュリティ診断プロンプト - 価格: ¥1,480
- 販売ページ: prompt-works.jp
Claude Codeと組み合わせて、セキュアな実装を最速で仕上げてください。
Top comments (0)