Skip to content
Published on

リフレッシュトークンローテーションとセッション管理 — 窃取に強いトークンライフサイクル設計

Authors

はじめに

認証システムで最も難しい問いは「どうやってログインさせるか」ではなく「ログイン状態をどれだけ、どのように維持するか」です。トークンの有効期間を長くすればユーザー体験は良くなりますが窃取被害が大きくなり、短くすれば安全になりますが再ログイン地獄が待っています。この緊張を解く標準的な道具が refresh token であり、その安全装置が rotation と reuse detection です。

2026 年の文脈でこのテーマはさらに重要になりました。OAuth 2.1 draft は public client の refresh token に rotation または sender-constraining を義務化し、RFC 9700(OAuth Security BCP)はその具体的な実装要件を定義しています。一方で passkeys が標準的な認証手段になるにつれ、「パスワードは安全になったのに、セッション/トークンが弱い輪になる」という逆転現象が起きています。攻撃者は今やパスワードの代わりにセッションクッキーと refresh token を狙います。インフォスティーラー型マルウェアが盗むのも、まさにこれらです。

本記事では、トークン有効期間の設計原則から rotation メカニズムの内部動作、IdP/アプリ/SSO セッションの 3 層モデル、Keycloak のセッション設定の詳細、そしてトークン窃取インシデント発生時の対応手順までを扱います。

トークン有効期間の設計原則

2 つのトークンの役割分担

┌──────────────────────────────────────────────────────────┐
│  Access Token (AT)                                       │
│  - 用途: API 呼び出しの認可                                 │
│  - 有効期間: 5〜15 分 (短く!)                              │
│  - 検証: stateless (署名検証のみ、IdP への照会なし)           │
│  - 窃取時: 有効期間の分だけ被害。遮断不可が前提                │
├──────────────────────────────────────────────────────────┤
│  Refresh Token (RT)                                      │
│  - 用途: 新しい AT の発行 (IdP の token エンドポイントのみ)    │
│  - 有効期間: idle 数時間〜数週間、max 数週間〜数か月           │
│  - 検証: stateful (IdP がセッション/ファミリー状態を確認)      │
│  - 窃取時: rotation + reuse detection で検知/遮断           │
└──────────────────────────────────────────────────────────┘

設計原則は次の 4 つに要約されます。

  1. AT は「遮断できない」と前提し、有効期間で被害を制限します。 AT が 5 分なら、窃取されても被害の窓は最大 5 分です。
  2. RT は「窃取される」と前提し、検知メカニズムを付けます。 rotation + reuse detection がその検知器です。
  3. 有効期間はリスクに比例させます。 金融サービスの RT と社内 Wiki の RT が同じ有効期間である理由はありません。
  4. idle と max を分離します。 「使わなければこれだけ経過後に失効(idle)」と「どれだけ使ってもこの時点で失効(max)」は別の統制です。

有効期間の推奨開始値

サービス種別ATRT idleRT max備考
金融/決済5 分30 分8 時間再認証頻度が高い
一般 B2C Web/アプリ10〜15 分14 日90 日rotation 必須
社内業務システム10 分8 時間24 時間勤務日単位
バックグラウンド同期 (offline)10 分30 日180 日offline トークン、別途監査

これらの値は正解ではなく出発点です。重要なのは「各値がなぜその値なのか」を説明できることです。

Rotation + Reuse Detection メカニズム

基本動作

rotation とは、RT を使用するたびに新しい RT を発行し、直前の RT を無効化することです。RT が使い捨てチケットになるわけです。

時刻      クライアント            IdP
t0        RT1 で更新リクエスト →   AT2 + RT2 発行、RT1 を「使用済み」にマーク
t1        RT2 で更新リクエスト →   AT3 + RT3 発行、RT2 を「使用済み」にマーク
t2        RT3 で更新リクエスト →   AT4 + RT4 発行、...

トークンファミリーと再利用検知

同じ最初の認証から派生した RT の系譜を**トークンファミリー(token family)**と呼びます。再利用検知はこのファミリー単位で動作します。

ファミリー F1:  RT1 ──> RT2 ──> RT3 (現在有効)

窃取シナリオ A: 攻撃者が先に使用
  1. 攻撃者が RT2 を窃取して更新 → RT3' を獲得 (攻撃者が最新)
  2. 正規ユーザーが RT2 で更新を試みる
  3. IdP: 「RT2 はすでに使用済み」→ 再利用検知!
  4. ファミリー F1 全体を無効化 (RT3' も死ぬ)
  5. 双方ともログアウト → 正規ユーザーは再ログイン、攻撃者は遮断

窃取シナリオ B: 正規ユーザーが先に使用
  1. 攻撃者が RT2 を窃取 (まだ未使用)
  2. 正規ユーザーが RT2 で更新 → RT3 発行
  3. 攻撃者が RT2 で更新を試みる → 再利用検知! → ファミリー無効化

どちらが先であれ、「二度使われた RT」が検知された瞬間に
ファミリー全体が死ぬ、というのが核心です。

このメカニズムの美しさは、攻撃者と正規ユーザーを区別する必要がない点です。誰が泥棒か分からなくても、衝突が検知されたらすべて無効化して再認証を要求すればよいのです。正規ユーザーは多少の不便(再ログイン)を被りますが、攻撃者は永久に遮断されます。

ネットワークエラーと grace period

現実には、正規クライアントも RT を「二度」送ることがあります。更新リクエスト後にレスポンスを受け取れず、リトライする場合です。このために短い grace period(送信重複の許容ウィンドウ、通常数秒〜数十秒)を設ける実装が多くあります。Keycloak は revokeRefreshToken を有効にすると refreshTokenMaxReuse(デフォルト 0)で許容再利用回数を制御します。0 が最も安全であり、クライアントのリトライロジックを冪等にするのが王道です。

# Keycloak: rotation + 再利用 0 回許容 (最も厳格)
kcadm.sh update realms/myrealm \
  -s revokeRefreshToken=true \
  -s refreshTokenMaxReuse=0

セッションの 3 層モデル — IdP セッション、アプリセッション、SSO セッション

トークンだけを見ていては、セッション管理の半分しか見えていません。実際のシステムには 3 種類の「ログイン状態」が共存します。

┌───────────────────────────────────────────────────────────────┐
│ 3 層セッションモデル                                              │
│                                                               │
│  [ブラウザ]                                                     │
│   ├── アプリセッション A (app-a.example.com のセッションクッキー)   │
│   ├── アプリセッション B (app-b.example.com のセッションクッキー)   │
│   └── IdP セッション  (idp.example.com の SSO クッキー)          │
│                       │                                       │
│                       ▼                                       │
│  [IdP サーバー]                                                 │
│   └── SSO セッション (サーバー側状態: どのユーザーが、どの           │
│        クライアントに、いつ認証したかの台帳)                        │
│        ├── Client Session A (app-a へのトークン発行記録)         │
│        └── Client Session B (app-b へのトークン発行記録)         │
└───────────────────────────────────────────────────────────────┘
保存場所寿命の管理者失効時の効果
アプリセッション各アプリのクッキー/セッションストアアプリそのアプリのみログアウト
IdP セッション(SSO クッキー)IdP ドメインのクッキーIdP新規 SSO ログイン不可、既存アプリセッションは残りうる
SSO セッション(サーバー状態)IdP サーバー/DBIdPRT 更新失敗、新規トークン発行不可

実務で最も多い混乱が「ログアウトしたのに、別のアプリはなぜログインされたままなのですか?」です。答えは、この 3 層が独立して失効するからです。アプリ A でログアウトしても(アプリセッション A を削除)、IdP セッションが生きていればアプリ A に再アクセスしたとき自動で再ログインされます。逆に IdP セッションを殺しても、アプリ B 自身のセッションクッキーが生きていればアプリ B は動き続けます。これを整理するには、後述するログアウト伝播が必要です。

Keycloak セッション設定の詳細

Keycloak はこのモデルを SSO Session、Client Session、Offline Session として実装しています。Realm Settings の Sessions と Tokens タブで設定します。

SSO Session

SSO Session Idle:    デフォルト 30 分
  - この時間セッション活動 (トークン更新など) がなければセッション失効
  - RT の実効 idle 有効期間を決定 (RT 有効期間 = min(この値, client session 値))

SSO Session Max:     デフォルト 10 時間
  - 活動と無関係に、最初の認証からこの時間が経つとセッション失効
  - 「どれだけ熱心に更新しても 10 時間後には再ログイン」の根拠

Client Session

Client Session Idle / Client Session Max: デフォルト 0 (= SSO 値を継承)
  - クライアント単位でより短いトークン有効期間を強制したいときに使用
  - 例: realm 全体は idle 30 日だが、決済クライアントのみ idle 30 分
  - クライアントの Advanced Settings で個別の上書きも可能

重要な動作: refresh token の失効時刻は、SSO セッションとクライアントセッションのうち先に終わる方に紐付きます。つまり Keycloak において RT の有効期間は独立した設定値ではなく、セッション有効期間の派生物です。この事実を知らないと「RT の有効期間を延ばしたのに反映されません」というミステリーにはまります。

Offline Session

バックグラウンド同期のように、ユーザーがブラウザを閉じた後もトークン更新が必要な場合のための別トラックです。scope に offline_access を含めてリクエストすると offline token が発行されます。

Offline Session Idle:           デフォルト 30 日
  - offline RT を 30 日以内に一度ずつ使えば延長され続ける

Offline Session Max Limited:    有効化すると絶対上限を適用
Offline Session Max:            デフォルト 60 日
  - 更新の有無と無関係な絶対失効

offline token は通常の SSO セッションとは分離して管理され、ユーザーがログアウトしても生き残ります。強力である分、発行対象を厳格に制限し(クライアント scope の統制)、Admin Console のユーザーごとの Consents/Sessions で定期的に監査すべきです。

トークン有効期間の設定

Access Token Lifespan:               デフォルト 5 分 (延ばさないこと)
Access Token Lifespan For Implicit:  レガシー、無視
Client Login Timeout:                認可コード → トークン交換の許容時間

kcadm での設定例をまとめると次のとおりです。

kcadm.sh update realms/myrealm \
  -s accessTokenLifespan=600 \
  -s ssoSessionIdleTimeout=1209600 \
  -s ssoSessionMaxLifespan=7776000 \
  -s offlineSessionIdleTimeout=2592000 \
  -s offlineSessionMaxLifespanEnabled=true \
  -s offlineSessionMaxLifespan=15552000 \
  -s revokeRefreshToken=true \
  -s refreshTokenMaxReuse=0

(上記の例: AT 10 分、SSO idle 14 日、SSO max 90 日、offline idle 30 日、offline max 180 日、rotation 有効化。)

デバイス別セッション管理

一人のユーザーは複数のデバイスから同時にログインします。各ログインは独立した SSO セッション(したがって独立したトークンファミリー)であるべきです。

ユーザー alice のセッション一覧 (IdP 視点)
┌────────────┬──────────────┬─────────────┬──────────────┐
│ セッション ID│ デバイス       │ 開始         │ 最終活動       │
├────────────┼──────────────┼─────────────┼──────────────┤
│ sess-a1    │ MacBook/Chrome│ 06-10 09:12 │ 06-12 11:40  │
│ sess-b2    │ iPhone/アプリ  │ 06-08 20:01 │ 06-12 08:15  │
│ sess-c3    │ 会社 PC/Edge  │ 06-11 08:55 │ 06-11 18:02  │
└────────────┴──────────────┴─────────────┴──────────────┘

この構造が与える運用能力は次のとおりです。

  1. セッション一覧の表示: 「自分のアカウントでログイン中のデバイス」画面。ユーザー自身が疑わしいセッションを終了できるようにします。
  2. 個別の無効化: 紛失したスマートフォンのセッションだけを終了。他のデバイスには影響なし。
  3. 異常検知の単位: 同一ファミリーの RT が異なる国の IP から使われたら、そのセッションだけを隔離できます。

Keycloak Admin REST API でセッションを照会/終了できます。

# ユーザーのセッション一覧を照会
kcadm.sh get users/USER-UUID/sessions -r myrealm

# 特定のセッションのみ終了
kcadm.sh delete sessions/SESSION-ID -r myrealm

# ユーザーのすべてのセッションを終了 (全デバイスからログアウト)
kcadm.sh create users/USER-UUID/logout -r myrealm

ログアウトとセッションの後始末

ログアウトは「クッキーの削除」ではなく、3 層のセッションとトークンファミリーを一貫して片付ける分散トランザクションです。完全なログアウトは次のすべてを実行しなければなりません。

1. アプリセッションの削除   (アプリ自身のセッションクッキー/ストア)
2. IdP セッションの終了     (OIDC RP-Initiated Logout: /logout エンドポイント)
3. RT の無効化            (token revocation: RFC 7009 /revoke)
4. 他のアプリへの伝播       (Back-Channel Logout: OIDC 仕様)

伝播メカニズムは 2 つあります。

方式動作長所と短所
Front-Channel Logoutブラウザが各アプリの logout URL を iframe などで巡回呼び出し実装が単純 / ブラウザ依存、サードパーティクッキー遮断に弱い
Back-Channel LogoutIdP が各アプリサーバーへ logout token(JWT)を直接 POST信頼性が高く 2026 年の推奨 / アプリがセッション-sid のマッピングを保持する必要

Back-Channel Logout を受けるアプリ側の責任が重要です。logout token の署名とクレーム(iss、aud、events、sid)を検証した後、sid に該当するアプリセッションを実際に破棄しなければなりません。このマッピング(IdP の sid → アプリセッション ID)をログイン時点で保存していなければ、伝播を受け取っても何もできません。

Back-Channel Logout のフロー
IdP ── POST logout_token(JWT, sid=xyz) ──> App B の /backchannel-logout
                                            ├ 署名/クレームの検証
                                            ├ sid=xyz → アプリセッション s-42 を照会
                                            └ s-42 を破棄、204 を応答

BFF(Backend-for-Frontend)パターン

SPA のトークン管理問題を構造的に解消するパターンです。トークンをブラウザに置かず、フロントエンド専用のバックエンドがトークンの保管者兼プロキシになります。

┌─────────┐   HttpOnly セッションクッキー ┌─────────────┐  AT/RT 保管  ┌────────┐
│ Browser │ <─────────────────────────> │     BFF     │ <──────────> │  IdP   │
│  (SPA)  │      /api/* をプロキシ        │ (サーバー    │              └────────┘
└─────────┘                             │  セッション)  │  AT を添付
                                        │      └──────┼─────────────> APIs
                                        └─────────────┘

動作方式:

  1. ログインは BFF が confidential client として code + PKCE フローを実行します。
  2. AT/RT は BFF のサーバー側セッションストア(Redis など)にのみ存在します。
  3. ブラウザは HttpOnly + Secure + SameSite=Strict クッキーで BFF と通信します。
  4. SPA の API 呼び出しは BFF がプロキシし、そのとき AT を添付します。AT 失効時は BFF が RT で静かに更新します。
項目トークンをブラウザに置く場合BFF
XSS によるトークン流出可能不可能 (トークンがブラウザにない)
CSRF該当なしSameSite + CSRF トークンで防御が必要
rotation の実装場所SPA (タブ間の競合問題)BFF (単一地点、シンプル)
運用コスト低いBFF インフラの追加

複数のタブが同時に RT 更新を試みて rotation と衝突する SPA の宿痾(タブ A が更新して RT が変わったのにタブ B が旧 RT で更新を試みる → 再利用検知の誤発動)は、BFF で更新を一本化すれば自然に消えます。新しく始めるプロジェクトなら、BFF をデフォルトとして検討することをお勧めします。

BFF のトークン更新の中核を Node/Express でスケッチすると次のとおりです。更新をセッション単位のロックで直列化する部分がポイントです。

// BFF のトークン更新ミドルウェア (概念スケッチ)
import { Issuer } from 'openid-client';

const sessionLocks = new Map(); // セッションごとに更新を直列化

async function ensureFreshToken(req, res, next) {
  const session = await store.get(req.sessionId);
  if (!session) return res.status(401).end();

  const skewMs = 30_000; // 失効 30 秒前から先制更新
  if (session.atExpiresAt - Date.now() > skewMs) {
    req.accessToken = session.accessToken;
    return next();
  }

  // 同一セッションの同時更新を 1 つに直列化 (rotation 誤発動の防止)
  let lock = sessionLocks.get(req.sessionId);
  if (!lock) {
    lock = oidcClient
      .refresh(session.refreshToken) // rotation: 新しい RT が返る
      .then(async (tokens) => {
        await store.update(req.sessionId, {
          accessToken: tokens.access_token,
          refreshToken: tokens.refresh_token, // 旧 RT は即座に破棄
          atExpiresAt: Date.now() + tokens.expires_in * 1000,
        });
        return tokens.access_token;
      })
      .finally(() => sessionLocks.delete(req.sessionId));
    sessionLocks.set(req.sessionId, lock);
  }

  try {
    req.accessToken = await lock;
    next();
  } catch (err) {
    await store.destroy(req.sessionId); // 更新失敗 = セッション終了
    res.status(401).end();
  }
}

BFF を複数インスタンスに水平スケールする場合は、上記のインメモリロックの代わりに Redis の分散ロック(または更新専用の単一ワーカー)で同じ直列化を保証する必要がある点に注意してください。

セキュリティインシデントのシナリオと対応

シナリオ 1: インフォスティーラーによる RT 大量流出の疑い

兆候: 再利用検知イベントが平常時の数十倍に急増
対応:
 1. 検知されたファミリーが自動で無効化されていることを確認
    (すでに防御が動作中)
 2. 影響ユーザーを抽出 → 全セッションの強制終了 +
    パスワード/passkey の再登録を案内
 3. offline token を持つユーザーは別途点検 (ログアウトでも生き残るため)
 4. AT 有効期間の一時短縮 (10 分 → 5 分) を検討

シナリオ 2: 特定ユーザーアカウントの乗っ取り報告

# 1. 直ちにすべてのセッションを終了 (全デバイスからログアウト)
kcadm.sh create users/USER-UUID/logout -r myrealm

# 2. offline トークンを含む同意 (consent) を撤回
kcadm.sh delete users/USER-UUID/consents/CLIENT-ID -r myrealm

# 3. クレデンシャルの再設定を要求 (パスワード + passkey の再登録)
kcadm.sh update users/USER-UUID -r myrealm \
  -s 'requiredActions=["UPDATE_PASSWORD","webauthn-register-passkey"]'

AT は遮断できないため(stateless)、AT の有効期間分の残余リスクは受容するか、高リスク API に限って introspection でセッションの生存を確認させます。

シナリオ 3: IdP 署名鍵の漏洩疑い

トークンライフサイクル設計が輝く瞬間です。鍵をローテーション(旧鍵を即時削除)すれば、旧鍵で署名されたすべての AT が一斉に無効化されます。AT の有効期間が 10 分なら、「漏洩鍵で偽造できる窓」も鍵の削除と同時に閉じます。RT はサーバー側の状態で検証されるため、セッション無効化で対応します。

モニタリング指標

指標意味アラート基準の例
RT 再利用検知数窃取の試み or クライアントのバグ1 時間あたり N 件超過
同一ファミリーの多国間 IP 使用セッションハイジャック1 件でも
invalid_grant の比率失効/誤用/攻撃の混合シグナル平常時の 3 倍
offline token の発行数長期クレデンシャルの増加週次トレンドの監視
セッションあたりの平均寿命ポリシーの実効性確認分布の変化

アンチパターン集

アンチパターン問題修正
AT の有効期間 24 時間遮断不可トークンの被害窓が 24 時間5〜15 分 + RT 更新
rotation なしで RT 90 日窃取検知の手段が皆無rotation + reuse detection
RT を localStorage に保存XSS 一発で長期クレデンシャル流出HttpOnly クッキーまたは BFF
ログアウト時にクッキーだけ削除IdP セッション/RT が生存 → 自動再ログインRP-Initiated Logout + revoke
全クライアントに offline_access を許可ログアウト不能トークンの乱発scope を必要なクライアントに限定
セッションストアなしの複数インスタンス IdP再利用検知がインスタンスごとに動作共有ストア/クラスタキャッシュ
タブごとに独立した RT 更新rotation の誤発動による無効化更新の一本化 (lock または BFF)

おわりに

窃取に強いトークンライフサイクル設計の核心をまとめます。

  • AT は短く、遮断不可を前提に: 5〜15 分。被害の窓は有効期間で統制します。
  • RT は stateful に、窃取を前提に: rotation + reuse detection + ファミリー無効化。泥棒と持ち主を区別する必要はなく、衝突したら即座にすべて切ります。
  • セッションは 3 層で考えましょう: アプリセッション、IdP セッション(クッキー)、SSO セッション(サーバー状態)は別々に失効します。Keycloak では RT の有効期間がセッション有効期間の派生物である点を覚えておきましょう。
  • ログアウトは分散トランザクションです: RP-Initiated Logout + token revocation + Back-Channel Logout の伝播までがワンセットです。
  • SPA なら BFF をデフォルトに: トークンをブラウザから取り除けば、rotation の並行性問題と XSS 流出が同時に消えます。
  • インシデント対応手順を事前に練習しましょう: セッション強制終了、consent 撤回、鍵ローテーションのコマンドをランブックにしておけば、深夜 3 時の対応速度が変わります。

認証の本当の実力は、ログイン画面ではなく、トークンが失効し、回転し、廃棄される、あの見えないライフサイクルに現れます。いま運用中のシステムの realm 設定を開いて、各タイムアウト値に「なぜ?」を問うことから始めてみてください。

参考資料