短命な token、refresh のローテーション、そして JWT の取り消し方
JWT のよくある使われ方——状態を一切参照せずサーバーがローカルに検証する、自己完結した access token として——こそが、それを簡単には取り消せない理由だ。期限が切れるまで有効で、サーバー側で何をしても変わらない。本稿はそれをどう管理するかを扱う。短い access token の寿命、再利用検知つきの refresh token とローテーション、そして自己完結型トークンを取り消す現実的な選択肢——どれも、あなたが JWT に求めた無状態性との間でのトレードオフを伴う。
前回は、多くのチームが行き着く形——短命な access token に、よく守られた refresh token を組み合わせる——で締めくくった。今回は、その形を機能させる仕組みの話であり、そもそもその仕組みを必要にさせる、JWT の少々やっかいな性質の話でもある。
その性質を率直に言おう。JWT は単なる形式にすぎず——それ自体は無状態性を要求しない——だが多くの場合、リソースサーバーがローカルに検証する、自己完結した署名済み access token として使われる。サーバーは署名と claim を確かめ、データベースにも発行者にも問い合わせずに結論を出す。これがこのパターンを速く、サービス間で拡張しやすくしている。そしてこれがまさに、そのようなトークンを簡単には取り消せない理由でもある。exp を持つとき——トークンは必ずしも持たなくてよいが、access token は持つべきだ——それはその瞬間まで有効で、サーバー側での通常の操作では変わらない。検証は「これはまだ有効か?」と立ち止まって問うことがないからだ。(唯一の乱暴な例外は、署名鍵そのものを退役させることだ。それはその鍵で署名したすべてのトークンを一度に無効にするが、全体に及ぶ粗い手段で、JWKS や検証側キャッシュの伝播にも左右され、鍵の漏洩には向くが、通常のログアウトには向かない。)だから、どの現実のシステムも必要とする操作——このユーザーを全端末でログアウトさせる、このトークンは盗まれた、無効にしろ、このアカウントは今から凍結——には、収まる自然な場所がない。本稿のあらゆる技法は、この一つの問題を管理するために存在する。すでに世に出てしまったトークンの有効性を、限定し、検知し、取り消すために。
対策1:短い有効期間
最も単純な手段が、最も重要でもある。access token が発行の数分後に切れるなら、盗まれた一つも、その数分だけしか危険でない。それを取り消すのではなく——待ち過ごせばいい。安上がりで、状態も要らない。ただし、短い有効期間は取り消しではない。取り消しとは、本来まだ有効な資格情報をサーバーが能動的に無効化することであり、期限切れはそれをしない。期限切れがするのは、漏れたトークンが使える時間を区切ることで、しかもサーバー側の状態を一切必要としない——だからこそ、ライフサイクルの残りが上に築かれる背骨になる。以下の大半は、実のところ、短い寿命を苦痛ではなく実用的にする話である。
苦痛は現実だからだ。数分ごとに切れるトークンは、それ単体では、ユーザーを絶えずログインフォームへ送り返す。現在のトークンが切れるたびに、パスワード入力を求めずに、静かに新しい access token をブラウザへ手渡す手立てが要る。その仕組みが refresh token であり、その設計全体がこの緊張への応答だ。
対策2:access と refresh の分担
refresh token は、別個の、より寿命の長い資格情報で、その唯一の仕事は、認可サーバーから新しい access token を得ることだ。一つではなく二つのトークンを持つ理由は、そうすれば正反対の露出特性を持たせられ、それぞれを相応に守れるからだ。
- access token はすべての API リクエストで送られるので、広く露出する——ヘッダー、プロキシ、ログの中に。それがどこにでもあるからこそ、寿命を短く保つ。
- refresh token はトークンエンドポイントにしか送られず、まれに、一箇所へだけ送られるので、露出は狭い。だから重く守れる——ブラウザの JavaScript が読めない
httpOnlycookie に入れる、あるいは前回の BFF パターンで、まるごと裏側に持たせる。
この分担こそが要点のすべてだ。寿命の長い資格情報は、めったに送られず、手の届かない所に置かれる。どこにでも行く資格情報は、大した害を及ぼす前に切れる。二つを「寿命が長く、すべてのリクエストで送られる」単一のトークンに畳んでしまえば、両取りの最悪が得られる——それこそ、短い寿命が避けるために存在する形だ。
対策3:ローテーションと再利用検知
refresh token は新しいリスクを持ち込む——寿命が長いので、盗まれた一つは値打ちがある——そしてローテーションは、そのリスクを、あなたが検知できるものへ変える答えだ。
ローテーションとは、refresh token が使われるたびに、サーバーが真新しい refresh token を発行し、古いものを無効化することだ。refresh token は使い切りになる。それ単体ではささやかな改善だが、肝心な部分を可能にする。再利用検知だ。ローテーションの後、現在の refresh token を正当に持つのは一者だけのはずだ。だから、以前使われてローテーションで外れたトークンが再び差し出されたら、何かがおかしい——連鎖が分岐し、同じログインの子孫を二者が握っている。これが盗難の兆候だ。
盗難を順にたどると、仕組みが明らかになる。攻撃者が refresh token を盗み、先に使う。新しい access/refresh の対を受け取り、盗まれたトークンはもう使い切られている。正当なクライアントがのちに、自分の手元にある副本——いまや無効化された原本——で更新しようとすると、サーバーは使い切られたトークンが息を吹き返すのを見て、侵害を認識する。よくできた認可サーバーは、トークンファミリー全体を無効化して応じる。その原初のログインから派生したすべての refresh token が一度に無効になり、きれいな再認証が強いられる。攻撃者が刷り出したばかりのトークンも、被害者のものと一緒に消える。ただしタイミングは正直に言おう。検知が発火するのは、正当なクライアントが次に更新を試み、あの使い切られた原本を差し出したときだけだ。それまでは、攻撃者は自分の生きた枝をローテーションし続けられる——だから活動の少ないユーザーでは、露出の窓は、本物のクライアントが戻ってくるまでいくらでも延びる。ローテーションは盗難を確実に表面化させ、正当なクライアントが戻れば止める。数分以内の封じ込めを保証はしない。この自動再利用検知つきのトークンファミリー方式は、公開クライアント(シングルページアプリとモバイル)に対する OAuth 2.0 のセキュリティ指針であり、ローテーションが余分な複雑さに見合う理由でもある。最初の悪用は防がないが、被害を限定し、侵入を確実に浮かび上がらせる。
正直な注意点を一つ。ローテーションは不安定なネットワークで誤警報を生む。更新の応答が途中で失われると、クライアントはローテーションされたと知らないまま古いトークンで再試行することがあり、それは再利用とそっくりに見える。実装はこれを和らげる——ただ、ただでは済まない。直前のトークンを許容し続ける猶予の窓は、盗まれた古いトークンにも使える窓を与えてしまうので、ごく短く、範囲を厳しく絞らねばならない。冪等な更新——再試行のリクエストに、再度ローテーションせず同じ後継を返す——は、この曖昧さをより清潔に避ける。いずれにせよ、それは再利用検知に対する本物のセキュリティ上のトレードオフであり、本番で出くわすのではなく、意図して設計すべきものだ。
ローテーションだけが認められた答えでもない。同じ OAuth セキュリティ指針は、公開クライアントに並列の選択肢を与える。sender-constrained(送信者拘束)の refresh token だ。DPoP や相互 TLS を通じて、クライアントが持つ鍵に縛られるので、盗まれたトークンも対応する秘密鍵がなければ役に立たない。仕様はこれを二者択一——送信者拘束かローテーション——として示す。とはいえ、本シリーズを貫く正直さに値する。ブラウザでは、トークンを盗める XSS はたいてい鍵の材料にも届くので、これが硬化するのは、ネットワーク傍受とオフラインでのリプレイに対してであって、すでにあなたのオリジンで動いているスクリプトに対してではない。
難しい部分:取り消し
短い寿命とローテーションで大半の状況は片づくが、時には今すぐアクセスを無効化する必要がある——ユーザーが「すべての端末からログアウト」を押す、管理者がアカウントを無効化する、あるトークンが侵害されたと判明する。ここで JWT の無状態性は利点でなくなる。組み込みの「削除」がないからだ。取れる手はどれも、「どれだけ即時に取り消せるか」と「どれだけ無状態性を返す気があるか」の間のトレードオフだ。知っておく価値のあるものが四つある。
短い TTL に頼り、更新の層で取り消す。 これが実務的な既定だ。クライアントはなお refresh token を持つが、認可サーバーがその状態——保存したハッシュ、トークンファミリーの記録——を追っており、それこそが後でそのトークンを拒める根拠だ。その記録を取り消せば、このセッションに新しい access token はもう発行されない。現在の access token は、自分で切れるまで有効なままだ——だが短く保っていれば、それも数分だ。access token を即座に無効化はできないが、その残り寿命を数分に封じ、源で出血を止める。多くのアプリにとって、わずかな残余有効期間は、検証を無状態に保つための受け入れられる代価だ。
denylist(拒否リスト)を保つ。 トークンを取り消すとき、その識別子を記録する——発行者が設定していれば jti(それは任意の claim であって、保証ではない)、なければトークンのハッシュやそのセッション ID——を、本来切れるはずの時まで記録し、検証器に毎リクエストでそのリストを確認させる。これで特定のトークンをほぼ即時に取り消せる——代価はホットパス上の一回の照会で、あなたが JWT に求めた無状態性をいくらか返すことになる。ただし代価は有界だ。このリストは、取り消し済みでまだ切れていないトークンしか持たないので、小さく保たれ、項目も自分で期限切れになる。
ユーザー単位の「valid-after(この時点以降のみ有効)」の区切りを保つ。 個々のトークンを追う代わりに、ユーザーごとに一つの目印を保存し、「この時点より前に発行されたものはすべて拒否する」を意味させる。全ログアウト、パスワード変更、凍結のときに、それを進める。検証はより古いトークンを拒否する。あなた自身の発行者が出す信頼できる iat に紐づける、あるいは——より頑健には——あなたが増やしていくユーザー単位のセッションバージョン claim に紐づける。iat は任意で、秒粒度のタイムスタンプは同一秒の発行や時計のずれの前で扱いにくくなるからだ。一回の安い照会で、「あるユーザーのすべてのセッションを一度に無効化する」という一般的なケースをきれいに覆う。粗い——ユーザー単位で全か無か、単一セッションの粒度はない——が、小さく有効で、より細かい制御が要る場面のために denylist とよく組み合わさる。
本当に重要な所では、無状態性を手放す。 最も高リスクなアクセスには、自己完結型トークンを捨て、リソースサーバーが認可サーバーへ毎回 introspection(内省)する不透明な参照型トークンに切り替えるシステムもある——取り消し可能で、状態を持つ。ただしこれ自身の注意点がある。リソースサーバーは introspection の結果をよくキャッシュするので、取り消しが実際どれだけ即時かは、そのキャッシュの TTL と、取り消しがどれだけ速く伝播するかに依る。ここでも正直な言い回しが役に立つ。即時で細粒度の取り消しがあるトークンにとって硬い要件なら、自己完結型の JWT はそもそも間違った道具かもしれず、サーバー側セッションか、内省されるトークンのほうが正しい。
四つを貫くのは、一つの取引だ。取り消しの即時性か、無状態性か。短い TTL は無状態のまま「十分な」取り消しを買い、denylist と introspection は状態を戻すことで即時性を買う。この線上に、万人に正しい一点はない——トークンごとに、それが悪用されたら何ができるかに応じて選ぶ。
「ログアウト」が実際に意味すること
これは直接言う価値がある。よくある、そして結果の重い誤解だからだ。JWT にとって、クライアントでトークンを削除することは、取り消しではない。ユーザーがログアウトする前にトークンが漏れていたら、自分の副本を消しても、攻撃者の副本には何もしない——向こうのは切れるまで動き続ける。本当のログアウトはサーバー側の動作だ。クライアントの資格情報を消し、かつ refresh token を取り消してセッションを更新できなくする。「すべてからログアウト」はもう一歩進む——すべての refresh token ファミリーを取り消し、どのセッションも更新できなくする。すでに流通している access token を、次の期限切れではなく即座に止める必要があるなら、そのユーザーの valid-after の区切りを進める(あるいは denylist に載せる)必要があり、そうしてリソースサーバーがそれらを能動的に拒む。refresh token を取り消すだけでは更新を止めるにすぎない。外にある access token は、リクエスト経路上の何かがその区切りを確認しない限り、切れるまで検証を通り続ける。
セッションの長さ:絶対上限とアイドルタイムアウト
refresh token はたいてい、互いに独立した二つの上限を持つ。分けて捉えると役に立つ。絶対上限はセッション全体に蓋をする——たとえば最初のログインから三十日、そのあとはどれだけ活動的でも再サインインが要る。アイドルタイムアウト(非アクティブ期間の上限)は、より短い期間使われないままだとセッションを終える。このタイムアウトはサーバー側のポリシーであって、自動の効果ではない——refresh token が最後に使われた時を記録し、間隔が窓を超えたら拒否する(OAuth のセキュリティ指針は、クライアントが一定期間非アクティブなら refresh token を期限切れにする、と表現する)。ローテーションは、それを執行しやすくするものだ。各ローテーションは、新しい「最終使用時刻」とアイドルの締切を後継トークンに刻む自然な機会なので、更新し続けるセッションは生き、休眠するセッションは失効する。二つの上限は合わせて、「セッションが最長どれだけ続きうるか」と「どれだけ休眠したら信用しなくなるか」の両方を縛る。
第一回につながる実用的な小さな注意。寿命を時計と争うところまで詰めないこと。検証器は exp/nbf に小さな時計ずれの許容を設定できる——仕様は要求ではなく許可であり、通常はせいぜい数分に抑える——サーバーの時計はずれるからだ。そのずれと競うほど短命な access token は、わけのわからない失敗を生む。だから「短い」は数分であって、数秒ではない。
実際のトークンでライフサイクルを見る
ライフサイクルのバグの多くは、実際の数字を読むまで見えない。そしてその数字は、ただの claim だ。トークンが iat と exp を持つとき——それぞれ、いつ発行され、いつ期限が切れるかを表す登録済みの claim だ——両者は合わせて、それがどれだけ生きるはずかを正確に告げるが、目には読めない裸の Unix タイムスタンプとして格納されている。トークンを JWT インスペクターに貼れば、これらの claim が本物の日付として表示される。手にしているのが短命な access token か長寿命のものか一目でわかり、発行者が設定どおりの寿命を実際に刷っているかを確かめられる。よくある驚きは、十五分で切れると思っていたトークンが、実は数時間を抱えていると気づくことだ——exp が十桁の整数ではなく日付になった瞬間に明白で、それまでは見えないライフサイクルのバグである。
このシリーズを、それぞれ一行で
これで四部のまとまりに幕が下りる。そして各部分は、合わさってはじめて機能する。トークンを読む——デコードして claim を理解し、デコードは信用ではないと忘れずに。正しく検証する——トークンの header ではなく、あなた自身の設定に、何を受け入れるかを決めさせる。慎重に保存する——長寿命の資格情報を JavaScript の手の届かない所に置き、XSS を止めることを本当の防御とみなす。そしてライフサイクルを管理する——短い access token の寿命、盗難を検知可能にするローテーションする refresh token、そして短い寿命では足りないときの取り消しの経路。どれか一つでも欠ければ、残りは救ってくれない。完璧に検証しても、トークンを 24 時間の寿命で localStorage に入れ、取り消す術もないシステムは、頑丈な錠を作って、鍵を扉に貼りつけたにすぎない。
JWT 自体は、小さく、ほとんど優雅な考えだ——署名された claim を、誰もがデコードできるが、期待する鍵を持つ検証器だけが実際に信用できる。それについて難しいことはすべて運用にある。検証器が何を信用すると選ぶか、ブラウザがトークンをどこに置くか、そして必要なときにどう取り戻すか。それらを正せば、トークンは静かに仕事をこなす。誤れば、暗号は初めから肝心な部分ではなかったのだ。