この記事は 削除したユーザーのJWTが8時間通る問題:失効の3方式(世代番号・拒否リスト・短命トークン)を比べる からの転載です。
この記事で分かること
- JWTが「取り消せない」ように見える理由と、場面ごとに必要なものの違い
- 失効の3方式(tokenVersion・拒否リスト・短命トークン)の比較と、tokenVersion を選ぶ理由
- どういう場面でどの方式を採用すべきか
背景: 削除したユーザーのトークンが通る
退職者のアカウントを管理画面で削除しても、朝に発行済みのトークンは有効期限(たとえば8時間)まで通る。降格しても、トークン内の role は発行時のままだ。
原因は、JWTの検証が署名と有効期限しか見ないことにある。RFC 7519 の exp より前なら、署名が正しい限り通る。サーバーがDBを見ないのがJWTの売りだが、それがそのまま「取り消せない」になる。
取り消したい場面は3つあり、必要なものが違う。
| 場面 | 何が古いままか | 必要なもの |
|---|---|---|
| 削除・無効化 | ユーザーが存在するという前提 | ユーザーの今の状態を確かめる |
| 降格・権限変更 | トークン内の役割 | 権限の判定にDBの今の役割を使う |
| パスワード変更 | 変更前に発行した全トークン | 発行済みトークンをまとめて無効にする |
降格はDBの役割を読めば直る。削除とパスワード変更には、トークンを通さない仕組みが要る。
選択肢の検討
| アプローチ | 仕組み | 失効が効くまで | 毎リクエストの参照 | 失効の単位 |
|---|---|---|---|---|
| tokenVersion | ユーザー行の整数をトークンにも載せ、一致を見る | 次のリクエストから | ユーザー表を主キーで1件 | ユーザー単位 |
| 拒否リスト | 失効させた jti を期限まで保存して照合 |
次のリクエストから | 拒否リストを1回 | トークン1本単位 |
| 短命トークン+リフレッシュ | アクセストークンを数分にし、リフレッシュ時に状態を確かめる | 最長でアクセストークンの寿命だけ遅れる | なし | リフレッシュトークン単位 |
拒否リストの弱点
jti をRedisなどに保存し、Redis 6.2 以降の SET の EXAT で exp と同じ時刻に期限切れにすれば、リストは勝手に縮む(Redisの説明)。弱いのはユーザー単位の失効で、削除したユーザーのトークンを止めるには、そのユーザーに発行した jti を全部知っている必要がある。発行記録を持ち始めると、実質セッション表を作っているのと同じになる。降格もこれだけでは解けない。
短命トークンの弱点
問い合わせは減るが、削除済みでもアクセストークンの寿命の間は通る。リフレッシュトークン自体の保護も要り、RFC 9700 は公開クライアントに送信元への紐付けかローテーションを求めている。実装の量は3つの中で一番多い。
なぜ tokenVersion を選ぶか
判断基準は2つ。
- 1本の問い合わせで3つの場面を片づけられる。 主キーで1件引けば、存在するか、削除済みか、今の役割は何か、番号が合うかが同時に分かる
- 主キーで1件引くコストより、消したユーザーが数時間使えるリスクのほうが重い。 業務向けのAPIなら、この比較で tokenVersion が手堅い
代償は、毎リクエストDBを引くこと(JWTを選んだ理由の半分は返上する)と、端末を1台だけログアウトさせられないこと。後者が要るなら拒否リストを足す。3方式は排他ではなく組み合わせられる。短命トークンを選んでも、パスワード変更時に発行済みのリフレッシュトークンをまとめて止める仕組みは要る。
発行時刻(iat)とパスワード変更時刻を比べる案もあるが、iat は秒単位で、変更と同じ1秒の中で発行されたトークンの前後が区別できない。整数の番号ならこの境界の問題がない。
実装例
列を足す。
ALTER TABLE users ADD COLUMN token_version integer NOT NULL DEFAULT 0;
jose で署名を確かめたあと、ユーザーの今の状態を1回だけ引く。マルチテナントを想定してテナントIDと組で引いており、テナントが無いなら問い合わせの条件と requiredClaims の tenantId を外す。
import { jwtVerify, type JWTPayload } from 'jose';
export class HttpError extends Error {
status: number;
constructor(status: number) {
super(String(status));
this.status = status;
}
}
type SessionState = { role: string; tokenVersion: number };
type Deps = {
secret: Uint8Array;
// SELECT role, token_version FROM users
// WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
findSessionState: (id: string, tenantId: string) => Promise<SessionState | null>;
};
export async function authenticate(token: string, deps: Deps) {
let claims: JWTPayload;
try {
({ payload: claims } = await jwtVerify(token, deps.secret, { requiredClaims: ['sub', 'tenantId'] }));
} catch {
throw new HttpError(401); // 署名不正・期限切れ・claim の欠落
}
// try の外で呼ぶ。DB 障害は 401 にせず、そのまま上に投げて 500 で返す
const user = await deps.findSessionState(String(claims.sub), String(claims.tenantId));
// 削除済み・存在しない・パスワード変更前のトークンを弾く。claim が無い旧トークンは 0 扱い
if (!user || user.tokenVersion !== (claims.tokenVersion ?? 0)) {
throw new HttpError(401);
}
// 権限の判定には claim の role ではなく、DB の今の role を渡す
return { ...claims, role: user.role };
}
パスワード変更では番号を上げる。
UPDATE users SET password_hash = $1, token_version = token_version + 1
WHERE id = $2 AND tenant_id = $3;
ポイントは3つ。番号の無い旧トークンは0扱い(拒否するとデプロイ直後に全員ログアウトする)、DB障害は401にせず500(401だとフロントがログイン画面へ戻す)、権限の判定にはDBの role を使う。
まとめ: どういう場面で使うべきか
次のリクエストから削除・降格を効かせたいなら tokenVersion、端末単位の失効も要るなら拒否リストを足す。数分の遅れを許せるなら短命トークンにして、パスワード変更でそのユーザーのリフレッシュトークンを止める。どの方式でも、トークンの検証が1か所に集まっているかを先に確かめる。別サービスが自前で署名だけ確かめていると、そこだけ失効が効かない。
さらに深掘りしたい方へ
この記事ではJWT失効の方式の選定理由を解説しました。
削除したユーザーのJWTが8時間通る問題:失効の3方式(世代番号・拒否リスト・短命トークン)を比べる ではさらに:
- tokenVersion を入れても取りこぼす4つの経路(別サービス・WebSocket・
role以外の値・強制ログアウト)と対処 - AIエージェントに実装を任せるときの指示文と、確かめるテスト項目
- 導入手順(前提の確認から、パスワード変更で番号を上げて新トークンを返すところまで)