無効化という難しい問題
一言でいうと
JWTは、発行後にサーバーが関与しないので速いのです。そして、同じ理由で取り消しにくくなります。 この取引を理解して、寿命で管理することが実務です。
なぜ必要なのか
「ログアウトしたのにトークンがまだ有効です」は、JWTを使うチームが必ず出会う問題です。 セッションベースの認証では、サーバーがセッションストアから削除すれば終わりでした。JWTは、サーバーに 状態がないので、削除するものがありません。
解決策として、拒否リストが思い浮かびます。取り消されたトークンのIDをストアに入れて、リクエストのたびに 確認する方法です。しかし、これだとリクエストのたびにストアへの問い合わせが発生し、ステートレスの 利点が失われます。セッション方式に戻ったのと、大きくは変わりません。
寿命で管理する
実務の標準的な答えは、2つのトークンの寿命を変えることです。
| アクセストークン | リフレッシュトークン | |
|---|---|---|
| 寿命 | 5–15分 | 数日–数週間 |
| 検証 | 署名だけを確認(ステートレス) | 認証サーバーが状態を持つ |
| 即時の取り消し | 不可能 | 可能 |
| どこに置くか | メモリ | httpOnlyクッキーまたは安全なストア |
アクセストークンを短くしておけば、盗まれてもその時間が過ぎれば使い物にならず、権限を取り消しても その時間内に反映されます。リフレッシュトークンは長くしますが、ローテーションさせます。更新のたびに 新しいものを渡して、古いものを無効化します。
この組み合わせの効果が重要です。ログアウトすると、リフレッシュトークンを取り消します。アクセス トークンは残りますが、最大15分後に期限切れになり、その後は更新できないので、セッションは 実質的に終わります。拒否リストなしで、15分以内の無効化が得られます。
ローテーションには、副次的な効果がもう1つあります。古いリフレッシュトークンが再び使われたら、それは 盗難のシグナルです。正常なクライアントは新しいものを持っているからです。このとき、そのユーザーの トークンの系列全体を無効化するのが、再利用の検知(reuse detection)です。
정상: RT1 → (갱신) → RT2 → (갱신) → RT3
탈취: RT1 → (갱신) → RT2
└── 공격자가 RT1 을 다시 사용 → 서버가 계열 전체를 끊는다
本当に即座に切らなければならない場合、つまりアカウントの乗っ取りの通報や管理者による強制ログアウトに限って、
例外的に拒否リストを使います。すべてのトークンではなく、そのユーザーのトークンだけが対象なので、ストアの負担が
小さくなります。もっと軽い方法は、ユーザーごとにtoken_not_beforeの時刻を1つ持たせて、それより
前に発行されたトークンをすべて拒否することです。ストアには、ユーザーあたり値が1つあれば足ります。
Keycloakの4つの寿命設定
Keycloakには複数の寿命設定があり、混乱しやすいものです。それぞれ別のものを制御します。
| 設定 | 何を制御するか | よくある値 |
|---|---|---|
| Access Token Lifespan | アクセストークンが有効な時間 | 5–15分 |
| SSO Session Idle | 操作がなければセッションが終わる時間 | 30分 |
| SSO Session Max | 操作があっても強制的に切る時間 | 10時間 |
| Offline Session Idle | オフライントークンのアイドルの上限 | 30日 |
アイドル時間と最大時間の違いが要点です。アイドルは「手を離すと切れる」、最大は 「使い続けていても、いつかは切れる」です。銀行のように規制がある場所では、最大 時間を短くします。逆に社内ツールでは、最大時間を長くして、1日に一度だけ ログインさせます。
2つの値の関係を間違えると、おかしな現象が起きます。Access Token LifespanがSSO Session Idleより長いと、セッションはすでに終わっているのに、アクセストークンはまだ有効な区間が できます。アクセストークンの寿命は、常にアイドル時間より短くします。
同時ログインの制限もよく求められます。Keycloakはセッション数を制限する機能を提供していますが、 アプリケーション側で処理するなら、ユーザーごとのセッション一覧を別に管理しなければなりません。
バックチャネルログアウトとBFF
複数のアプリケーションが同じ認証サーバーを使っていると、1か所でログアウトしたときに、他の場所も
切らなければなりません。OIDCのバックチャネルログアウトは、認証サーバーが各アプリケーションの
登録されたアドレスに、ログアウトトークンを直接送る方式です。ブラウザーを経由しないので、
タブが閉じていても動作します。受け取る側は、そのトークンの署名とsidを確認して、該当する
セッションを削除します。
BFF(Backend for Frontend)パターンも、セッション管理の観点で有利です。トークンをバックエンドが 持ち、ブラウザーにはセッションクッキーだけを渡せば、ログアウト時にそのセッションを削除するだけで、即座に 無効化できます。XSSでトークンが盗まれる経路もなくなります。その代わり、バックエンドが状態を 持つことになるので、スケーリングのときにセッションストアが必要です。
トークンをどこに置くのか
寿命の設計と同じくらい重要なのが、保存場所です。間違った場所に置くと、寿命をどれだけ短くしても 意味がありません。
| 場所 | XSSに安全 | CSRFに安全 | リロード後も維持 | 評価 |
|---|---|---|---|---|
localStorage |
✗ スクリプトが読める | ✓ | ✓ | 最もよく使われ、最も危険 |
sessionStorage |
✗ | ✓ | タブ単位 | 大差なし |
| JavaScriptの変数 | △ 同じコンテキストなら読める | ✓ | ✗ | リフレッシュと併用すれば実用的 |
httpOnlyクッキー |
✓ スクリプトが読めない | ✗ SameSiteが必要 | ✓ | リフレッシュトークンの置き場所 |
実務での組み合わせは、こうです。アクセストークンはメモリに、リフレッシュトークンはhttpOnly + Secure + SameSite=Laxのクッキーに置きます。リロードすると、メモリにあるアクセストークンは消えますが、 クッキーにあるリフレッシュトークンで、静かに受け取り直します。
SameSite=Strictは、外部リンクから入ってきたときにクッキーが付かず、ログインが解除されたように
見えます。たいていはLaxが適切で、決済のような機密性の高いフローだけをStrictに絞ります。
時計がずれると起きること
JWTの検証は、expとnbfをサーバーの時計で判定します。認証サーバーとリソースサーバーの時計が
数秒ずれただけでも、「受け取ったばかりのトークンがまだ有効ではない」(nbf違反)というエラーが
断続的に起きます。再現が難しく、長く迷うタイプのバグです。
検証ライブラリは、たいてい30–60秒の余裕(clock skew)を許容します。この値を0に しないでください。代わりに、すべてのノードでNTPを合わせます。
現場での姿
トークンの寿命の設定は、画面上ではいくつかの数字にすぎませんが、その数字が生む症状は、 認証とは関係なさそうな形で現れます。
「ときどきログアウトされます」は、たいていセッションのアイドル時間です。アクセストークンは更新されるのに、 SSOセッションのアイドル時間が短いと、少し席を外したユーザーが戻ったときに、更新が 拒否されます。ユーザーは「数分で切れた」と言い、ログには正常な期限切れとして 残ります。
「ログアウトしたのに他のタブは生きています」は、バックチャネルログアウトが組み込まれていないのです。 フロントチャネルだけでは、開いている他のクライアントがその事実を知りません。セッションIDを 保存しておいて、ログアウトの通知を受け取って削除する場所がなければ、ログアウトがそのブラウザーの タブでしか起きません。
「デプロイの直後に全員ログアウトされます」は、署名の鍵が入れ替わったのです。鍵をローテーションするときは、 古い鍵を検証用にしばらく残しておく必要があります。公開鍵の一覧(JWKS)をキャッシュする 側の更新間隔も、併せて見ます。
「あるユーザーだけできません」は、たいていトークンが大きくなったのです。グループやロールが多い
アカウントはトークンが大きくなり、それをクッキーに入れるとヘッダーのサイズの上限に引っかかって、プロキシが
431や400を返します。症状が認証ではなく、何の画面も表示されない
こととして現れます。
「時刻が少しずつずれます」は、実際に時計の問題です。発行サーバーと検証サーバーの
時計が数秒違うだけでも、nbf/expの判定が分かれます。NTPを合わせ、検証側に
数秒の余裕(clock skew)を置きます。
調査するときは、トークンをそのままログに残しません。トークンは、それ自体が資格
情報です。必要なのはsub、sid、exp程度なので、それだけを取り出して残します。
次の確認で見ること
このモジュールは、クイズで寿命の設計の判断基準を点検して、コースを終えます。