0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

なぜJWTの失効にtokenVersionを選ぶのか:世代番号・拒否リスト・短命トークンの比較

0
Last updated at Posted at 2026-11-01

この記事は 削除したユーザーの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. 1本の問い合わせで3つの場面を片づけられる。 主キーで1件引けば、存在するか、削除済みか、今の役割は何か、番号が合うかが同時に分かる
  2. 主キーで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失効の方式の選定理由を解説しました。

:page_facing_up: 削除したユーザーのJWTが8時間通る問題:失効の3方式(世代番号・拒否リスト・短命トークン)を比べる ではさらに:

  • tokenVersion を入れても取りこぼす4つの経路(別サービス・WebSocket・role 以外の値・強制ログアウト)と対処
  • AIエージェントに実装を任せるときの指示文と、確かめるテスト項目
  • 導入手順(前提の確認から、パスワード変更で番号を上げて新トークンを返すところまで)

合同会社playpark について

:link: お問い合わせ | ブログ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?