Claude Media
Claude Codeでリフレッシュトークンのローテーションを実装し再利用検知まで検証

Claude Codeでリフレッシュトークンのローテーションを実装し再利用検知まで検証

認可サーバー側のリフレッシュトークンを回転させ、使用済みの再提示でトークンファミリーを丸ごと失効させる実装を、Claude Codeにテスト先行で書かせる手順です。並行リクエストの猶予の境界値も扱います。

リフレッシュトークンのローテーションは、アクセストークンを更新するたびに新しいリフレッシュトークンを発行し、古いほうを使用済みにする仕組みです。難しいのは回転そのものではありません。盗まれたトークンが使われたときに検知して止める分岐と、正規クライアントの同時リクエストを誤検知しない分岐です。この記事では、認可サーバー側の発行ロジックをClaude Codeにテスト先行で書かせ、実際に動かして確かめた結果をもとに、テーブル設計、再利用検知で全失効させる処理、並行リクエストの猶予を順に説明します。

再利用検知は何を守るのか

RFC 9700(OAuth 2.0 Security Best Current Practice)は、公開クライアントのリフレッシュトークンについて、送信者制約か、ローテーションのどちらかを使うよう求めています。ローテーションは、更新のたびに新しいリフレッシュトークンを返し、前のトークンを無効にする方式です。ただし前のトークンとの関係は認可サーバーが保持し続けます。

ここが再利用検知の根拠です。トークンが盗まれ、攻撃者と正規クライアントの両方が使うと、どちらかは必ず無効化済みのトークンを出すことになります。認可サーバーは、どちらが出したのかを判別できません。RFC 9700はその場合に、有効なリフレッシュトークンを失効させるとしています。攻撃は止まりますが、正規クライアントも新しい認可グラントを取り直すことになります。

もう一つの選択肢が、リフレッシュトークンを特定のクライアントインスタンスに暗号的に結び付ける送信者制約です。RFC 9700は、相互TLS(RFC 8705)とDPoP(RFC 9449)を例に挙げています。相互TLSはクライアント証明書の公開鍵のフィンガープリントで、DPoPは公開鍵・秘密鍵のペアによるアプリケーション層の署名で、トークンの持ち主を確かめます。DPoPは公開クライアントでも使えます。ただし、攻撃者がトークンと鍵の両方を手に入れる場合、たとえば改ざんされたクライアントソフトやブラウザー上のXSSでは、送信者制約でも防げないと書かれています。鍵をセキュリティモジュールに置くか、TLSスタック経由でしか触れない形にしておくと、トークンだけが漏れた場合の悪用は防げます。クライアントに鍵管理を持たせられるなら送信者制約、持たせられず認可サーバー側だけで完結させたいならローテーションが候補になります。この記事は後者を実装します。

設計

実装で決める3点

  • ファミリーの単位

    最初の認可で発行したトークンから派生するすべてのトークンを、1つのファミリーとして束ねます。失効の単位がここになります。

  • 使用済みの記録

    回転したトークンは削除せず、使用済みとして残します。消すと、再提示が「知らないトークン」に見えて検知できません。

  • 並行リクエストの扱い

    同じトークンが短時間に2回届く正規のケースを、盗用と区別する窓を設けるかどうかを決めます。

認可コードフローのPKCEとstateはClaude CodeでOAuth実装 — PKCE・state・リダイレクトURIをテストで固めるにあります。ここで扱うのは、認可サーバー側の refresh_token グラントです。

トークンファミリーのテーブル設計

テーブルは2つです。ファミリー側に失効の状態を、トークン側に使用済みの時刻を持たせます。

CREATE TABLE token_families (
  id            TEXT PRIMARY KEY,
  user_id       TEXT NOT NULL,
  client_id     TEXT NOT NULL,
  scope         TEXT NOT NULL,
  revoked_at    INTEGER,
  revoke_reason TEXT,
  created_at    INTEGER NOT NULL
);
 
CREATE TABLE refresh_tokens (
  token_hash TEXT PRIMARY KEY,
  family_id  TEXT NOT NULL REFERENCES token_families(id),
  used_at    INTEGER,
  expires_at INTEGER NOT NULL,
  created_at INTEGER NOT NULL
);
CREATE INDEX idx_refresh_tokens_family ON refresh_tokens(family_id);

トークンの平文は保存せず、SHA-256のハッシュを主キーにします。DBが漏れても、そのまま使えるトークンは残りません。used_at が空なら未使用、値があれば回転済みです。失効はトークン1行ずつではなくファミリーの revoked_at を立てるだけで済み、派生したすべてのトークンが一度に使えなくなります。

scope 列は、利用者が同意した範囲を記録するためのものです。RFC 9700は、発行するリフレッシュトークンを、リソースオーナーが同意したスコープとリソースサーバーに束縛するよう求めています。目的は、正規クライアントによる権限の拡大を防ぐことと、トークンが漏れたときの影響を小さくすることです。この実装では、回転で新しいトークンを返すときも、ファミリーに記録したスコープをそのまま引き継ぎ、更新リクエスト側の指定で広げられないようにします。リソースサーバーを区別する場合は、同じ要領で対象のリソースもファミリーに持たせます。

RFC 9700は実装メモとして、グラントの情報をトークン自体に含める方法も挙げています。その場合は署名などで値の完全性を守る必要があります。この記事ではテーブルを引く方式にしています。

Claude Codeにテストから書かせる

Claude Codeの一般的なワークフローは、テストを足すときに、まず未カバーの箇所を探させ、雛形を作らせ、意味のあるケースを足し、実行して失敗を直させる順序を示しています。また、確かめたい挙動を具体的に指定するほど、既存のテストの書き方に合った出力になります。今回はそれを、拒否条件を先に並べる形に置き換えます。

CLAUDE.mdには、仕様の根拠と禁止事項を短く書いておきます。

# リフレッシュトークンの規約
- 回転のたびに新しいトークンを返し、古いトークンは削除せず used_at を残す
- トークンの平文を保存・ログ出力しない。保存するのは SHA-256 のハッシュだけ
- 使用済みトークンの再提示は、猶予の外ならファミリー全体を失効させる
- 時刻は引数で注入できるようにし、テストでは Date.now を直接待たない
- テストは node --test で実行し、実装前に失敗する出力を見せる

実装前に設計を確認したいときは、Plan Mode(claude --permission-mode plan)で始めます。Plan Modeでは、Claudeがファイルを読んで計画を提示するだけで、承認するまで編集しません。ファミリーの単位や猶予の扱いは後から直しにくいので、ここで人間が決めておくと手戻りが減ります。

refresh-store.test.mjs だけを先に書いて。実装はまだ作らない。
確かめたいのは次の5つ。
1. 回転で新しいトークンが返り、元のトークンは使用済みになる
2. 使用済みの再提示でファミリー全体が失効し、最新のトークンも使えなくなる
3. 別ファミリーは巻き込まない
4. 猶予内の再送は失効させず、猶予を過ぎた再提示で失効する
5. 期限切れと未知のトークンは invalid_grant
テストを実行して、モジュール未作成で失敗する出力を見せてから実装に進んで。

実装して動かした結果

次の実装を、Node.js 20.18の標準 node:test で検証しました。DBの代わりにMapを使った最小の形で、ロジックの分岐だけを確かめています。

import { createHash, randomBytes } from 'node:crypto';
 
const sha256 = (v) => createHash('sha256').update(v).digest('hex');
 
export class InvalidGrant extends Error {
  constructor(reason) { super('invalid_grant'); this.reason = reason; }
}
 
export function createRefreshService(
  { now = () => Date.now(), ttlMs = 30 * 24 * 3600e3, graceMs = 0 } = {}
) {
  const families = new Map(); // familyId -> { revokedAt, reason }
  const tokens = new Map();   // tokenHash -> { familyId, usedAt, expiresAt }
 
  const mint = (familyId) => {
    const raw = randomBytes(32).toString('base64url');
    tokens.set(sha256(raw), { familyId, usedAt: null, expiresAt: now() + ttlMs });
    return raw;
  };
 
  return {
    issue() {
      const familyId = randomBytes(8).toString('hex');
      families.set(familyId, { revokedAt: null, reason: null });
      return { familyId, refreshToken: mint(familyId) };
    },
    rotate(raw) {
      const row = tokens.get(sha256(raw ?? ''));
      if (!row) throw new InvalidGrant('unknown');
      const fam = families.get(row.familyId);
      if (fam.revokedAt) throw new InvalidGrant('family_revoked');
      if (row.expiresAt <= now()) throw new InvalidGrant('expired');
      if (row.usedAt === null) {
        row.usedAt = now();
        return mint(row.familyId);
      }
      if (now() - row.usedAt < graceMs) return mint(row.familyId);
      fam.revokedAt = now();
      fam.reason = 'reuse_detected';
      throw new InvalidGrant('reuse_detected');
    },
    familyState: (id) => families.get(id),
  };
}

判定の順序には意味があります。未知のトークン、失効済みファミリー、期限切れを先に弾き、そのあとで未使用なら回転、使用済みなら猶予か失効かを分けます。失効済みファミリーのトークンを「再利用」と数え直さないよう、family_revoked を別の理由にしています。

最初の実行で落ちたのは猶予の境界値だった

猶予のコードは、最初は now() - row.usedAt <= graceMs と書かれていました。graceMs の既定値は0で、猶予なしのつもりです。この状態で5本のテストを走らせると、結果は次のとおりでした。

not ok 1 - 回転すると新しいトークンが返り、元のトークンは使用済みになる
not ok 2 - 使用済みの再提示でファミリー全体が失効し、最新のトークンも使えなくなる
ok 3 - 別ファミリーは巻き込まない
ok 4 - 猶予内の再送は失効させず、猶予を過ぎた再提示で失効する
ok 5 - 期限切れ・未知のトークンは invalid_grant
# pass 3
# fail 2

原因は等号です。回転と再提示が同じミリ秒に起きると、経過時間が0になり、0 <= 0 が成り立って猶予内と判定されます。猶予0のはずが、同一ミリ秒だけ再利用が通るので、再利用検知が効かなくなります。< に直すと、5本とも通りました。

ok 1 - 回転すると新しいトークンが返り、元のトークンは使用済みになる
ok 2 - 使用済みの再提示でファミリー全体が失効し、最新のトークンも使えなくなる
ok 3 - 別ファミリーは巻き込まない
ok 4 - 猶予内の再送は失効させず、猶予を過ぎた再提示で失効する
ok 5 - 期限切れ・未知のトークンは invalid_grant
# tests 5
# pass 5
# fail 0

境界値は、テスト側で時刻を注入できるから確かめられます。猶予の1500ms後は通り、2500ms後は失効する、という2点をテスト4で固定しています。実時間で待つテストにすると、遅い環境で結果がぶれます。

並行リクエストの猶予をどう扱うか

モバイルアプリや複数タブのWebアプリでは、同じリフレッシュトークンで2つの更新が同時に飛ぶことがあります。先に届いたほうが回転し、後から届いたほうは使用済みのトークンを出す形になり、検知ロジックはこれを盗用と見分けられません。

猶予はこの誤検知を避けるための窓です。RFC 9700とRFC 6749は、この窓を規定していません。窓の長さも、窓の中で何を返すかも、実装側の判断です。今回の実装は、窓の中の再提示には同じファミリーの新しいトークンをもう1つ発行し、ファミリーは失効させません。

この判断には代償があります。

  • 窓の中では、攻撃者の再提示も通ります。窓は短いほど安全で、長いほど誤検知が減ります
  • 窓の中で発行した2つのトークンは、どちらも有効なまま残ります。片方しか使われなければ、もう片方は期限まで残ります
  • 窓の外での再提示は、正規クライアントであっても失効します。クライアント側は、invalid_grant を受けたら再ログインへ進む作りにしておく必要があります

DBで実装するときは、判定と更新を1文にまとめて、同時に2つの要求が来ても回転が1回だけ成立するようにします。SQLite 3.51.0で次を実行したところ、1回目だけが更新に成功し、2回目は0件でした。

UPDATE refresh_tokens SET used_at = ?
  WHERE token_hash = ? AND used_at IS NULL;
-- 1回目: changes() = 1(回転が成立)
-- 2回目: changes() = 0(使用済みの分岐へ進む)

更新件数が0のときだけ、猶予か失効かの判定に進みます。SELECT で未使用を確かめてから UPDATE する二段にすると、その間に別の要求が割り込めます。同じ「条件つき更新」の考え方は、Claude Codeで楽観ロックを実装する — versionカラムと競合テストの書かせ方でも扱っています。

失効の書き込みも、revoked_at IS NULL を条件に付けると、二重に走っても最初の理由が残ります。

UPDATE token_families
  SET revoked_at = ?, revoke_reason = 'reuse_detected'
  WHERE id = ? AND revoked_at IS NULL;

このテストが保証しない範囲

Mapで確かめたのは分岐の順序と境界値です。次の点は別に確かめる必要があります。

  • 実DBでの同時実行。上のSQLは単発の実行で件数を見ただけで、複数接続からの競合は負荷をかけて確かめていません
  • 複数台のサーバー間での時刻のずれ。猶予は時刻の差で決まるため、時計がずれると窓の長さが変わります
  • トークンファミリーの掃除。期限切れの行を消すジョブは含めていません。ただし、使用済みの行を消すのは、そのファミリーの最後のトークンの期限が切れてからにします
  • 公開クライアントと機密クライアントの区別。RFC 6749は、機密クライアントなら更新時にクライアント認証を求めています。この実装は認証の部分を持ちません
  • 失効のきっかけの追加。RFC 9700は、パスワード変更やログアウトで自動的に失効させてよいとしています。ファミリー単位の失効なので、同じ仕組みで足せます

トークン更新エンドポイントには、総当たりを防ぐレート制限も別に要ります。実装の考え方はClaude CodeでRedisのレート制限を実装するが参考になります。

まとめ

ローテーションは「新しいトークンを返す」だけなら数行ですが、守りの価値は使用済みの記録と、ファミリー単位の失効にあります。Claude Codeには、拒否条件と猶予の窓をテストに落としてから実装させるのが確実です。今回も、猶予0を <= で書いたせいで同一ミリ秒の再利用が通る不具合が、実行して初めて見つかりました。猶予を入れる場合は、窓の長さを短く固定し、その外で必ず失効することを、時刻を注入したテストで確かめてください。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/common-workflows
  2. rfc-editor.org/rfc/rfc9700
  3. rfc-editor.org/rfc/rfc6749
  4. oauth.net/2/oauth-best-practice
この記事を共有:XはてブLinkedIn