Claude CodeでJWT認証を安全に実装する実践ガイド
JWT認証の基礎、署名、Cookie、更新トークン、失効、鍵ローテーションをClaude Codeで安全に実装する。
JWT認証は、ログイン後のAPI呼び出しを軽くできる便利な仕組みです。けれども「署名しているから安全」「localStorageに入れておけば楽」「失効は有効期限まで待てばよい」と考えると、公開後に事故になりやすい領域でもあります。
この記事では、Claude Codeに実装を任せる前提で、初心者にもわかるJWTの基礎から、claim設計、署名と暗号化の違い、Cookieとセッション配置、refresh token rotation、失効、鍵ローテーション、よくある失敗、安全なプロンプトまでを1つの実装方針にまとめます。ログイン全体の設計はClaude Code認証実装ガイド、Cookieの細部はClaude Code Cookie管理、権限設計はClaude Code RBAC実装も合わせて確認してください。
外部情報は原典を基準にします。JWTの仕様はRFC 7519、安全な使い方はRFC 8725、refresh tokenの再利用検知はRFC 9700、実装上の注意はOWASP JSON Web Token Cheat Sheet、Cookie属性はMDN Set-Cookie、Node実装はjose、Claude Codeの権限境界はAnthropic Claude Code settingsを参照します。
JWTの基礎を初心者向けに整理する
JWTはJSON Web Tokenの略で、header.payload.signatureの3つをピリオドでつないだ文字列です。headerには署名アルゴリズム、payloadにはclaim、signatureには改ざん検知用の署名が入ります。claimとは「このトークンについての主張」です。たとえばsubはユーザーID、issは発行者、audは利用先、expは有効期限、jtiはトークンIDです。
大事なのは、JWTは標準では暗号化されていないことです。多くのWebアプリで使うJWTはJWS、つまり「署名されたJSON」です。署名は改ざんを検出しますが、payloadを隠しません。ブラウザの開発者ツールやログに出れば読めます。メールアドレス、住所、API key、請求情報、内部メモなど、見られて困る情報はpayloadに入れないでください。隠す必要がある場合はJWEという暗号化方式もありますが、一般的なセッション用途では、短命なaccess tokenとサーバー側のセッション記録で十分なことが多いです。
Claude Codeに依頼するときは、まずこの前提を固定します。
JWT認証を実装してください。
前提:
- JWTは署名であり暗号化ではない。payloadに秘密情報を入れない。
- access tokenは15分以内、refresh tokenは7日以内。
- iss、aud、sub、exp、iat、jtiを検証する。
- alg noneや想定外アルゴリズムを許可しない。
- refresh token rotationと再利用検知を実装する。
- Cookie属性、CSRF、XSS、失効、鍵ローテーションをレビューに含める。
claim設計は最小限にする
JWTに何を入れるかで、後の保守性と事故率が変わります。access tokenは「毎回DBを引かずにAPIの入口で判断したい最小情報」に絞ります。おすすめは、ユーザーID、セッションID、テナントID、粗いrole、トークンIDです。細かい権限、課金状態、停止状態、利用上限のように頻繁に変わる値は、API側でDBやキャッシュを確認します。
| claim | 目的 | 注意点 |
|---|---|---|
sub | ユーザーID | メールアドレスではなく安定したIDにする |
iss | 発行者 | 自社の認証サーバーURLなど固定値にする |
aud | 利用先 | APIごとに想定外のtokenを拒否する |
exp | 有効期限 | access tokenは短く、refresh tokenは別扱いにする |
jti | トークンID | 失効、監査、再利用検知に使う |
sid | セッションID | refresh token familyや端末単位の失効に使う |
role | 粗い権限 | 詳細な認可はサーバー側で再確認する |
失敗しやすいのは、JWTを「ユーザー情報のキャッシュ」にしてしまうことです。plan: "pro"やdisabled: falseを入れると、契約変更やアカウント停止が反映されるまで古いclaimが生きます。認証は「誰か」を確認する処理、認可は「その操作を許すか」を確認する処理です。認可の深い設計はClaude Code RBAC実装へ分けたほうが安全です。
コピーして動くTypeScript実装
次の例は、joseでaccess token署名、検証、refresh token rotation、失効、再利用検知をまとめた最小デモです。実運用ではrefreshStoreをRedisやDBに置き換え、HTTPS、CSRF対策、rate limit、監査ログを追加してください。
mkdir jwt-lab
cd jwt-lab
npm init -y
npm install jose
npm install -D tsx typescript @types/node
// auth-demo.ts
import { createHash, createSecretKey, randomUUID } from "node:crypto";
import { SignJWT, jwtVerify } from "jose";
const ISSUER = "https://auth.example.com";
const AUDIENCE = "claudecodelab-api";
const ACCESS_TTL = "15m";
const REFRESH_TTL_SECONDS = 60 * 60 * 24 * 7;
const accessKey = createSecretKey(
Buffer.from(
process.env.JWT_ACCESS_SECRET ??
"dev-only-secret-change-me-32-bytes-minimum"
)
);
const refreshKey = createSecretKey(
Buffer.from(
process.env.JWT_REFRESH_SECRET ??
"dev-only-refresh-secret-change-me-32-bytes"
)
);
type Role = "admin" | "user" | "viewer";
type User = { id: string; role: Role; tenantId: string };
type VerifiedAccess = {
userId: string;
role: Role;
tenantId: string;
sessionId: string;
tokenId: string;
};
type RefreshRecord = {
userId: string;
sessionId: string;
tokenHash: string;
expiresAt: number;
revokedAt?: number;
};
const refreshStore = new Map<string, RefreshRecord>();
const revokedAccessTokenIds = new Set<string>();
function sha256(value: string) {
return createHash("sha256").update(value).digest("hex");
}
function assertRole(value: unknown): asserts value is Role {
if (!["admin", "user", "viewer"].includes(String(value))) {
throw new Error("invalid role claim");
}
}
async function signAccessToken(user: User, sessionId: string) {
const tokenId = randomUUID();
return new SignJWT({ role: user.role, tid: user.tenantId, sid: sessionId })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuer(ISSUER)
.setAudience(AUDIENCE)
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime(ACCESS_TTL)
.setJti(tokenId)
.sign(accessKey);
}
async function verifyAccessToken(token: string): Promise<VerifiedAccess> {
const { payload } = await jwtVerify(token, accessKey, {
issuer: ISSUER,
audience: AUDIENCE,
algorithms: ["HS256"],
});
assertRole(payload.role);
if (
typeof payload.sub !== "string" ||
typeof payload.tid !== "string" ||
typeof payload.sid !== "string" ||
typeof payload.jti !== "string"
) {
throw new Error("missing required claim");
}
if (revokedAccessTokenIds.has(payload.jti)) {
throw new Error("access token revoked");
}
return {
userId: payload.sub,
role: payload.role,
tenantId: payload.tid,
sessionId: payload.sid,
tokenId: payload.jti,
};
}
async function signRefreshToken(user: User, sessionId: string) {
const tokenId = randomUUID();
const token = await new SignJWT({ sid: sessionId, kind: "refresh" })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuer(ISSUER)
.setAudience("claudecodelab-refresh")
.setSubject(user.id)
.setIssuedAt()
.setExpirationTime("7d")
.setJti(tokenId)
.sign(refreshKey);
refreshStore.set(tokenId, {
userId: user.id,
sessionId,
tokenHash: sha256(token),
expiresAt: Date.now() + REFRESH_TTL_SECONDS * 1000,
});
return token;
}
async function rotateRefreshToken(refreshToken: string, user: User) {
const { payload } = await jwtVerify(refreshToken, refreshKey, {
issuer: ISSUER,
audience: "claudecodelab-refresh",
algorithms: ["HS256"],
});
if (
typeof payload.jti !== "string" ||
typeof payload.sid !== "string" ||
typeof payload.sub !== "string"
) {
throw new Error("invalid refresh token claims");
}
const record = refreshStore.get(payload.jti);
const presentedHash = sha256(refreshToken);
if (!record || record.revokedAt || record.tokenHash !== presentedHash) {
for (const item of refreshStore.values()) {
if (item.sessionId === payload.sid) item.revokedAt = Date.now();
}
throw new Error("refresh token reuse detected");
}
if (record.expiresAt < Date.now()) {
throw new Error("refresh token expired");
}
record.revokedAt = Date.now();
return {
accessToken: await signAccessToken(user, payload.sid),
refreshToken: await signRefreshToken(user, payload.sid),
};
}
async function main() {
const user: User = {
id: "user_123",
role: "admin",
tenantId: "tenant_a",
};
const sessionId = randomUUID();
const accessToken = await signAccessToken(user, sessionId);
const refreshToken = await signRefreshToken(user, sessionId);
const verified = await verifyAccessToken(accessToken);
const rotated = await rotateRefreshToken(refreshToken, user);
console.log({ verified, rotatedRefreshLength: rotated.refreshToken.length });
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
npx tsx auth-demo.ts
このコードで重要なのは、refresh tokenそのものを保存せずハッシュだけを保存している点です。漏えい時の被害を少しでも小さくできます。また、使われたrefresh tokenを即時にrevokedAtへ移し、古いrefresh tokenが再提示されたら同じsidのtoken familyをまとめて失効させます。RFC 9700が説明する再利用検知の考え方に沿った最小形です。
Cookieとセッション配置を決める
ブラウザアプリでは、refresh tokenはHttpOnly、Secure、SameSite付きCookieに置く設計が現実的です。HttpOnlyはJavaScriptから読めないCookieです。XSSが起きてもtoken文字列を直接盗まれにくくなります。ただしCookieは自動送信されるため、CSRF対策が必要です。SameSite=LaxまたはStrict、状態変更APIのCSRF token、Origin/Referer検証を組み合わせます。
const refreshCookieOptions = {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/api/auth/refresh",
maxAge: 60 * 60 * 24 * 7,
};
const clearRefreshCookieOptions = {
...refreshCookieOptions,
maxAge: 0,
};
access tokenの置き場所はプロダクトで変わります。SPAだけでAPIを叩くならメモリ保持が安全寄りですが、タブ再読み込みで消えます。BFFやNext.jsのRoute Handlerを使うなら、ブラウザにaccess tokenを渡さず、サーバー側セッションや短命CookieでAPIを代理呼び出しする構成も選べます。localStorageは実装が簡単ですが、XSSで読まれるため、認証tokenの保存先としては慎重に扱ってください。
失効と鍵ローテーションを設計する
JWTは自己完結したtokenなので、発行後に何もしないとexpまで有効です。ログアウト、パスワード変更、管理者による停止、端末紛失、漏えい疑いに備えるには、短い有効期限、jtiの失効リスト、sid単位のセッション失効、refresh token rotationを組み合わせます。すべてのaccess tokenをDB照会するとJWTの軽さは落ちますが、重要な操作だけ追加確認する設計は現実的です。
鍵ローテーションでは、署名に使う鍵を定期的に入れ替えます。HS256の共有秘密鍵は実装が簡単ですが、発行側と検証側が同じ秘密を持ちます。サービス境界が増えるなら、RS256やES256のような公開鍵方式にして、検証側には公開鍵だけを渡すほうが管理しやすくなります。kidをheaderに入れ、JWKSで古い公開鍵と新しい公開鍵を一定期間並べて公開します。
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://auth.example.com/.well-known/jwks.json")
);
export async function verifyWithRotatingKeys(token: string) {
return jwtVerify(token, JWKS, {
issuer: "https://auth.example.com",
audience: "claudecodelab-api",
algorithms: ["RS256", "ES256"],
});
}
{
"rotationPlan": {
"step1": "新しい鍵を作り、JWKSに公開する",
"step2": "新しいkidで署名を開始する",
"step3": "古いaccess tokenのexpが切れるまで旧公開鍵を残す",
"step4": "監査ログを確認して旧鍵を削除する"
}
}
3つ以上のユースケースで判断する
flowchart LR
Login["ログイン"] --> Access["短命access token"]
Login --> Refresh["HttpOnly refresh cookie"]
Access --> API["APIでiss/aud/exp/jti検証"]
Refresh --> Rotate["更新時にrotation"]
Rotate --> Store["DB/Redisにhashとsidを保存"]
Store --> Reuse["再利用検知ならtoken family失効"]
1つ目のユースケースはSaaSの管理画面です。APIはaudを固定し、tenantIdをclaimに入れます。ただしテナント境界はclaimだけで決めず、DBクエリでもtenant_idを条件に入れます。管理者権限や請求状態は変更が多いため、重要操作ではサーバー側の最新状態を確認します。
2つ目は有料コンテンツや教材サイトです。読者体験を壊さないためaccess tokenは短く、refresh tokenで静かに更新します。AdSenseやAnalyticsがあるサイトでは、XSS対策のCSPやCookie同意設計も収益に直結します。関連してWebセキュリティヘッダー実装も確認してください。
3つ目はモバイルアプリやデスクトップアプリです。CookieではなくOSの安全なストレージを使うことが多くなります。端末紛失に備えてsid単位で失効できるようにし、refresh tokenの再利用検知を監査ログに残します。
4つ目はマイクロサービスです。すべてのサービスに署名鍵を配るのではなく、公開鍵検証やAPI gatewayでの検証を検討します。各サービスはaudを確認し、自分向けでないtokenを拒否します。
よくある失敗例と落とし穴
| 失敗 | 何が起きるか | 対策 |
|---|---|---|
algを固定しない | アルゴリズム混同や弱い設定を見逃す | 許可アルゴリズムを明示する |
| payloadに秘密情報を入れる | ログやブラウザから読まれる | IDと最小claimだけにする |
audやissを検証しない | 別API向けtokenが通る | 発行者と利用先を必ず検証する |
| refresh tokenを使い回す | 盗まれたtokenが長く使われる | rotationと再利用検知を入れる |
| localStorageに長命tokenを置く | XSSでtokenを盗まれる | HttpOnly CookieやBFFを検討する |
| ログアウト時にサーバー記録を消さない | Cookie削除後もtokenが残る | sidとjtiを失効させる |
| 鍵をいきなり削除する | 既存ユーザーが一斉に401になる | JWKSで重複期間を設ける |
| Claude Codeに秘密鍵を貼る | 会話ログにsecretが残る | 変数名と制約だけ渡す |
Claude Codeに渡す安全なプロンプト
Claude Codeには、実装だけでなくレビュー条件も渡します。曖昧な「JWTを入れて」では、tokenの置き場所、失効、CSRF、鍵管理が抜けやすくなります。
このリポジトリのJWT認証を設計・実装してください。
変更前に以下を調査して表にしてください。
- フレームワーク、既存のユーザーモデル、session/cookieの実装
- 既存の認可middleware、CSRF対策、CSP、rate limit
- tokenを保存している場所とXSS/CSRFリスク
実装条件:
- joseを使う。jsonwebtokenへ戻さない。
- access tokenは15分、refresh tokenは7日。
- iss、aud、sub、exp、iat、jti、sidを検証する。
- refresh tokenはhashだけをDB/Redisに保存し、rotationする。
- 再利用検知時は同じsidのtoken familyを失効する。
- 秘密鍵、.env、production tokenは出力しない。
- 最後にcurlまたはテストコマンドで検証証跡を出す。
レビュー用のプロンプトも分けます。
JWT認証の差分をセキュリティレビューしてください。
重点:
- alg none、aud/iss未検証、長すぎるexpがないか
- payloadにPIIやsecretが入っていないか
- refresh token rotationと再利用検知が実際に動くか
- CookieのHttpOnly、Secure、SameSite、pathが妥当か
- ログアウト、パスワード変更、管理停止で失効できるか
- テストと手動検証が不足していないか
検証手順とマネタイズCTA
実装後は、正常系だけでなく失敗系を必ず確認します。
# ログインしてrefresh cookieとaccess tokenを確認
curl -i -X POST https://example.com/api/auth/login \
-H "content-type: application/json" \
-d '{"email":"demo@example.com","password":"correct horse"}'
# 期限切れ、改ざん、aud違い、再利用済みrefresh tokenもテストする
npm test -- --runInBand auth
ClaudeCodeLabで認証まわりを整えるなら、まず無料チートシートでClaude Codeの確認手順を固定してください。テンプレートやレビュー観点を手元に置きたい場合は教材・テンプレート一覧へ、チームでJWT、RBAC、Cookie、監査ログ、CIまでまとめて整えたい場合はClaude Code研修・導入相談が向いています。
この記事で紹介した内容を実際に試した結果、最も効果があったのは「署名コードを書く前にclaim表を作る」ことでした。小さなTypeScriptデモでは、aud未検証、top-level awaitによる実行失敗、refresh tokenの生保存という3つの落とし穴を事前に潰せました。Claude Codeに実装を頼む場合も、最初にclaim、保存場所、失効条件、検証コマンドを渡すだけで、後からの手戻りがかなり減ります。
JWT認証は、短いコードで動きます。しかし公開運用に耐えるJWT認証は、claim設計、Cookie、CSRF、rotation、失効、鍵管理、レビュー手順まで含めて初めて完成します。Claude Codeは実装速度を上げられますが、安全性はプロンプトと検証条件で決まります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。