TT Lab
はじめる
学ぶ 学習パス コース

セッションとトークン — ブラウザからメッシュまで

盗まれた refresh トークンにサーバーはどう気づくか

TT Labで続きを見る

一言でいうと

accessトークンは短命にして、refreshトークンで差し替えます。その代わり、refreshは1回使うと変わり(ローテーション)、すでに使ったrefreshが再び届いたらそのログインから続くトークンをすべて断ち切ります(再利用検知)。サーバーが誰が泥棒かわからなくても、盗みを止められる設計です。

なぜ必要なのか

accessトークンを短命にすれば、漏れても被害を受ける期間が短くなります。RFC 9700 4.14は、refreshトークンがまさにこれを可能にすると書いています。ユーザーを再ログインさせなくても、寿命が短く範囲の狭いaccessを出し続けられるからです。

問題は、リスクがrefresh側に移ることです。同じ節は、refreshがそのクライアントが受け取った権限全体を代表し、盗んでリプレイすれば、攻撃者がaccessを好きなだけ発行できると警告しています。サーバーに秘密を置く機密クライアントは、refreshを使うときにクライアント認証を行うので、トークンだけでは使えません(RFC 6749 10.4)。しかしブラウザーのSPAやモバイルアプリのようなパブリッククライアントには、隠せる秘密がありません。

そこでRFC 9700は、長らくdraft-ietf-oauth-security-topicsというドラフトで呼ばれ、2025年1月にBCP 240として発行された文書ですが、2.2.2節で、パブリッククライアントのrefreshはsender-constrainedであるか、ローテーションを使わなければならない(MUST)と定めています。前者はDPoP・mTLSでトークンをクライアントの鍵に結び付ける方法で、このラボは後者を自分で作ります。

どう動くのか

ローテーションとは、refreshで新しいaccessを受け取るたびに新しいrefreshも一緒に渡すことです。RFC 9700の原文は「The previous refresh token is invalidated, but information about the relationship is retained」と書いています。古いトークンを消さずに「使用済み」の印だけを残す理由がここにあります。消してしまうと、再び届いた古いトークンが見たことのないごみ値なのか、すでに使ったトークンなのかを区別できず、そうなると再利用を検知する根拠が失われます。

再利用検知は次のように成立します。トークンが漏れて、攻撃者と正常なクライアントの両方が使うと、先に使った側がローテーションさせてしまうので、後の側は必ず無効になったトークンを出すことになります。サーバーは誰が攻撃者なのかわかりません。攻撃者が先に使った可能性もあります。そのためRFC 9700は、このとき有効なrefreshを取り消すよう求めています。正常なユーザーも再ログインが必要になりますが、攻撃はそこで止まります。1回のログインから続いたトークンの鎖をファミリー(family)と呼び、取り消しはファミリー単位で行います。

POST /login         fam:F state=active   rt1(uses=0)  at1
POST /token rt1     rt1 uses=1 → rt2, at2               정상 회전
POST /token rt1     uses=2 → 재사용! fam:F state=revoked → 400 invalid_grant
POST /token rt2     계열이 죽었음 → 400 invalid_grant
GET  /api/me at2    계열이 죽었음 → 401 invalid_token

レスポンスコードも標準に従います。無効・期限切れ・取り消し済みのrefreshに対して、トークンエンドポイントは400invalid_grantで答え(RFC 6749 5.2)、保護されたAPIは無効なaccessに401とWWW-Authenticate: Bearer error="invalid_token"を返します(RFC 6750 3.1)。ログアウトは、RFC 7009の取り消しエンドポイントの形で作ります。refreshを取り消したら、同じ承認から出たaccessも無効にすることが推奨(SHOULD)で、知らないトークンが来ても200です。そのトークンを使えなくするという目的は、すでに達成されているからです。

最後に、「使用済み」の印はアトミックに付ける必要があります。読み取り・比較・書き込みを別々にやると、同じトークンで同時に来た2つのリクエストがどちらも「まだ未使用」を見て、両方が新しいトークンを受け取ってしまいます。RedisのHINCRBYは、増加と結果の返却が1つのコマンドなので、1を見るリクエストは常に1つだけです。

現場での姿

ローテーションを有効にすると、「たまに勝手にログアウトされる」という報告が来ます。タブ2つが同時に、期限切れのaccessを更新しようとして同じrefreshを2回送ると、2回目が再利用に見えてファミリーがまるごと吹き飛びます。攻撃ではなく競合状態です。直す場所は検知ではなくクライアントで、更新リクエストを1か所にまとめて1回だけ送るようにします。Keycloakのような製品が、ローテーションを有効にする設定と「許容する再利用の回数」を別々に設定させているのもこのためですが、回数を増やした分だけ検知はゆるくなります。

もう1つは、長く放置されたrefreshです。RFC 9700は、クライアントがしばらくrefreshを使わなければ期限切れにするよう(SHOULD)求め、ログアウトやパスワード変更のようなセキュリティ上の出来事で自動的に取り消してもよい(MAY)としています。このラボでは、RedisキーのTTLが無操作での期限切れを担います。

次のラボですること

Redisにファミリーを保存する小さな発行者を立てます。ログインでトークンの組を受け取り、refreshで新しいaccessを受け取り、古いrefreshが拒否されるかを見ます。その後、盗まれた古いrefreshを再提出して、正常なクライアントの最新のrefreshとaccessまで一緒に無効になるかを確認し、短いaccessの寿命とログアウトでの取り消しを経て、全体の流れを1つのスクリプトで証明します。