はじめに
認証システムで最も難しい問いは「どうやってログインさせるか」ではなく「**ログイン状態をどれだけ、どのように維持するか**」です。トークンの有効期間を長くすればユーザー体験は良くなりますが窃取被害が大きくなり、短くすれば安全になりますが再ログイン地獄が待っています。この緊張を解く標準的な道具が 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)」は別の統制です。
有効期間の推奨開始値
| サービス種別 | AT | RT idle | RT 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 サーバー/DB | IdP | RT 更新失敗、新規トークン発行不可 |
実務で最も多い混乱が「ログアウトしたのに、別のアプリはなぜログインされたままなのですか?」です。答えは、この 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 Logout | IdP が各アプリサーバーへ 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 のトークン更新ミドルウェア (概念スケッチ)
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 設定を開いて、各タイムアウト値に「なぜ?」を問うことから始めてみてください。
参考資料
- [OAuth 2.1 draft (draft-ietf-oauth-v2-1)](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/)
- [RFC 9700 - Best Current Practice for OAuth 2.0 Security](https://datatracker.ietf.org/doc/html/rfc9700)
- [RFC 6749 - The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749)
- [RFC 7009 - OAuth 2.0 Token Revocation](https://datatracker.ietf.org/doc/html/rfc7009)
- [RFC 7662 - OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
- [RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449)
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
- [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)
- [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)
- [OpenID Connect Session Management 1.0](https://openid.net/specs/openid-connect-session-1_0.html)
- [Keycloak Documentation](https://www.keycloak.org/documentation)
- [Keycloak Server Administration - Managing user sessions](https://www.keycloak.org/docs/latest/server_admin/index.html)
- [Keycloak 26.6.0 Release Notes](https://www.keycloak.org/2026/04/keycloak-2660-released)
현재 단락 (1/246)
認証システムで最も難しい問いは「どうやってログインさせるか」ではなく「**ログイン状態をどれだけ、どのように維持するか**」です。トークンの有効期間を長くすればユーザー体験は良くなりますが窃取被害が大...