管理画面で退職者のアカウントを削除した。ところが、その人が朝に受け取ったトークンでは、夕方までAPIが普通に通る。「消したはずのユーザーが、まだ一覧を見られる」状態だ。有効期限を8時間にしたJWTなら、削除から最長8時間これが続く(退職者に1勤務日分の延長戦を渡している)。
降格も同じだ。管理者から一般ユーザーに戻したのに、トークンに書かれた役割は管理者のままなので、管理者専用のAPIが通る。
この記事は、JWTでログインを作っていて、削除・降格・パスワード変更をその場で効かせたいエンジニア向けだ。失効の方式は大きく3つある。ユーザーごとの世代番号(tokenVersion)、失効させたトークンを覚えておく拒否リスト、アクセストークンを短命にしてリフレッシュ時に止める方式だ。3つを比べ、選び方と最小の実装、AIエージェントに実装を任せるときの指示までまとめる。
なぜ消したユーザーのトークンが通るのか
JWTの検証は、署名と有効期限を確かめれば終わる。仕様のRFC 7519では、exp は「この時刻以降は受け付けてはならない」時刻だ。それより前なら、署名が正しい限り通る。
サーバーはDBを見ない。発行後にユーザーが消えても、役割が変わっても、トークンの中身は発行時のままだ。DBを引かずに済むのがJWTの売りのはずが、その売りがそのまま「取り消せない」に化ける。
取り消したい場面は3つあり、必要なものが少しずつ違う。
| 場面 | 何が古いままか | 必要なもの |
|---|---|---|
| 削除・無効化 | ユーザーが存在するという前提 | ユーザーの今の状態を確かめる |
| 降格・権限変更 | トークン内の役割(role) | 権限の判定にDBの今の役割を使う |
| パスワード変更・乗っ取り対応 | 変更前に発行したトークン全部 | そのユーザーの発行済みトークンをまとめて無効にする |
降格は、トークンを無効にしなくても直せる。権限の判定でトークン内の役割を読まず、DBの値を読めば済む。一方、削除とパスワード変更は、トークンそのものを通さない仕組みが要る。
失効の3方式を比べる
| tokenVersion(世代番号) | 拒否リスト | 短命トークン+リフレッシュ | |
|---|---|---|---|
| 仕組み | ユーザー行に番号を持ち、トークンにも載せて一致を見る | 失効させたトークンのIDを期限まで保存し、毎回照合する | アクセストークンを数分〜十数分にし、リフレッシュ時にユーザーを確かめる |
| 失効が効くまで | 次のリクエストから | 次のリクエストから | 最長でアクセストークンの寿命だけ遅れる |
| 毎リクエストの参照 | ユーザー表を主キーで1件 | 拒否リストを1回 | なし(リフレッシュ時だけ) |
| 失効の単位 | ユーザー単位(全端末まとめて) | トークン1本単位 | リフレッシュトークン単位 |
| 実装の量 | 列を1つ足し、認証で比べる | 保存先と、ユーザーごとの発行済みIDの記録 | 2種類のトークン、保存、ローテーション |
| 運用で増えるもの | ほぼなし | 保存先の可用性 | リフレッシュトークンの保管と漏えい対策 |
tokenVersion:1本の問い合わせで3つの場面を片づける
ユーザー表に整数の列を1つ足し、トークンにも同じ値を載せる。パスワードを変えたら列を +1 する。認証では、主キーで1件引いて番号を比べる。
この1件の問い合わせで、存在するか、削除済みか、今の役割は何か、番号が合うかが同時に分かる。最初の節の表で挙げた3つの場面が、追加の問い合わせ1本で片づく。代わりに、毎リクエストDBを引く(JWTを選んだ理由の半分は返上する)。失効はユーザー単位なので、端末を1台だけログアウトさせることはできない。
拒否リスト:1本ずつ止められるが、ユーザー単位は苦手
RFC 7519 の jti は、トークンを一意に識別するIDだ。失効させたトークンの jti をRedisなどに保存し、認証のたびに照合する。Redis 6.2 以降なら SET の EXAT オプションで、キーの期限をトークンの exp と同じ時刻にできる(Redisの説明)。期限を過ぎたトークンはどうせ通らないので、拒否リストは勝手に縮む。
弱いのはユーザー単位の失効だ。削除したユーザーのトークンを止めるには、そのユーザーに発行した jti を全部知っている必要がある。発行の記録を持ち始めると、実際はセッション表を作っているのと変わらない。降格の問題も、これだけでは解けない。
短命トークン+リフレッシュ:問い合わせは減るが、即時にはならない
アクセストークンを短くし、切れたらリフレッシュトークンで取り直させる。ユーザーの状態はリフレッシュのときだけ確かめるので、毎リクエストの問い合わせが無い。
代わりに、ユーザーは削除済みなのに、アクセストークンの寿命の間は通る。リフレッシュトークンは長く生きるので、それ自体の保護も要る。OAuthのセキュリティ指針RFC 9700は、公開クライアントのリフレッシュトークンに、送信元への紐付けかローテーションを求めている。ローテーションはリフレッシュのたびに新しいトークンを出し、使い終えたものが再び使われたら漏えいとみなして有効なトークンも取り消す。実装の量は3つの中で一番多い。
どれを選ぶか
業務向けのAPIなら、まず tokenVersion から入るのが手堅い。主キーで1件引くコストより、消したユーザーが数時間使えるリスクのほうが重いからだ。短命トークンでも、パスワード変更のときに、発行済みのリフレッシュトークンをまとめて止める仕組みは要る。保存しているリフレッシュトークンをそのユーザーの分だけ消すか、同じ世代番号をリフレッシュ時に比べるかのどちらかだ。3つは排他ではなく、組み合わせて使える。
tokenVersion の実装
1. 前提を確かめる
トークンの検証が1か所に集まっているかを先に見る。別のサービスやルートが自前で署名を確かめていると、そこだけ失効が効かない。権限の判定がトークン内の役割を読んでいるかも、ここで確かめておく。
2. 列を足す
ALTER TABLE users ADD COLUMN token_version integer NOT NULL DEFAULT 0;
既定値があるので、既存の行は0で埋まる。
3. 認証で比べる
jose で署名を確かめたあと、ユーザーの今の状態を1回だけ引く。フレームワークに依存しない形で書いた。マルチテナントのアプリを想定して、ユーザーをテナントIDと組で引いている。テナントが無いなら、問い合わせのテナント条件と、必須 claim(requiredClaims)の tenantId の両方を外す。
import { jwtVerify, type JWTPayload } from 'jose';
// フレームワークの HTTP エラー型があればそちらを使う
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); // 署名不正・期限切れ・sub や tenantId の欠落
}
// 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 };
}
設計の芯は3つある。
- 番号の無いトークンは0として扱う。 導入前に発行したトークンには番号が無い。拒否すると、デプロイした瞬間に全員がログアウトする
- DB障害は401にしない。 フロントエンドが401でログイン画面へ戻す作りなら、DBが一瞬止まっただけで全員を追い出すことになる
- 問い合わせは主キーで1件、2列だけ。 削除済みとテナント違いは条件で落とす。行数のある表で実行計画を見ると、主キーの索引で1行を引く形になる(数行しかない開発用DBでは全件走査が選ばれることもある)
4. パスワード変更で番号を上げる
UPDATE users SET password_hash = $1, token_version = token_version + 1
WHERE id = $2 AND tenant_id = $3;
パスワードを変えた本人のトークンも、この時点で401になる。変更APIの応答で、新しい番号を載せたトークンを発行し直す。
導入しても取りこぼす経路
では、tokenVersion を入れれば、もう取りこぼしは無いのか? 残るのは次の4つだ。
| 経路・タイミング | 何が起きるか | 対処 |
|---|---|---|
| 自前で署名だけ確かめる別サービス・エッジ | 失効も降格も効かない | 同じ確認を通すか、重い操作をさせない |
| 接続時にだけ認証するWebSocketなど | 接続している間は失効後も流れ続ける | 失効時に接続を切る仕組みを別に持つ |
| トークン内の役割以外の値(メール・表示名) | 発行時の値のまま | 表示以外に使うならDBから読む |
| 管理者による強制ログアウト | パスワードを変えないと番号が上がらない | 番号を +1 する管理操作を別に用意する |
AIエージェントに実装を任せるときの指示
自分で書かず、AIエージェントに任せることも多いはずだ。「削除したユーザーのトークンを失効させて」だけでも、それらしい実装は返ってくる。指示に書かないと実装ごとに分かれるのは、番号の無いトークンの扱いとDB障害時の応答だ。どちらを選んでも「削除したら401」のテストは通るので、テストからは気づけない。
認証ミドルウェアで、JWT の署名と期限を確かめたあとに次を足してください。
- sub と tenantId でユーザーを主キーで 1 件引く(role と token_version の 2 列だけ)
- 存在しない・論理削除済み・token_version が一致しない場合は 401
- トークンに tokenVersion が無ければ 0 として扱う
- 権限の判定には、トークンの role ではなく DB の role を使う
- DB の障害は 401 にせず 500 のまま返す
- パスワード変更・リセットで token_version を +1 し、変更したセッションには新しいトークンを返す
- JWT を独自に検証している箇所が他に無いか探して報告する
テストで次を確かめてください。
- 削除したユーザーの既存トークンが 401
- 降格したユーザーの既存トークンで管理者専用 API が 403
- パスワード変更前のトークンが 401、変更後に受け取ったトークンは 200
- tokenVersion の無いトークンが、変更前は 200
- 1 リクエストで増える DB 問い合わせが 1 本
最後の「他に無いか探して報告」は、前の節の表の1行目を潰すためだ。エージェントは頼まれた箇所を直しても、頼まれていない経路まで見に行くとは限らない。AIに認証を書かせたときの別の落とし穴は、認証コードをAIに書かせたら危なかった話にまとめている。
別案:発行時刻とパスワード変更時刻を比べる
列を足さずに、トークンの iat(発行時刻)とパスワード変更時刻を比べる手もある。ただ、iat は秒単位で、jose も jsonwebtoken も整数の秒で入れる。変更と同じ1秒の中で発行されたトークンは、変更の前か後か区別できない。前を弾けば変更直後に発行し直したトークンまで落ち、後を通せば変更直前のトークンが生き残る。整数の番号なら、この境界の問題が起きない。
まとめ
JWTが取り消せないのは、検証が署名と期限しか見ないからだ。3つの場面のうち降格はDBの役割を読めば直り、削除とパスワード変更には失効の仕組みが要る。
即時性が要るなら tokenVersion、端末単位の失効も要るなら拒否リストを足す。数分の遅れを許せるなら短命トークンにして、パスワード変更でそのユーザーのリフレッシュトークンを止める(保存分を消すか、同じ番号を確かめる)。JWTに限らず、署名だけで信じる仕組みは、発行後の変化をどこで拾うかを先に決めておくと、消したはずのユーザーに悩まされずに済む。



