こんにちは。 大学の春休みにPlatform Engineering Unit Identity Platformグループでインターンをしていた河内です。
今回のインターンでは、Sansan株式会社の複数プロダクトに認証・認可を提供している共通認証基盤「Auth One」へRefresh Token Rotationの追加に取り組みました。
背景となるプロダクト側のMCP提供については、次のリリースも参照ください。 Contract One、MCPサーバーの提供を開始(プレスリリース)
Refresh Token Rotation自体は、OAuth/OIDCに関わる人にとっては比較的知られた考え方かもしれません。一方で、実際にプロダクトの共通認証基盤へ組み込むには、仕様、既存実装との互換性、セキュリティー、UX、運用時の観測性を同時に考える必要がありました。
本記事では実装の細部ではなく、導入にあたって行った設計判断を中心にまとめます。
Auth Oneとは
Auth Oneは、Bill OneやContract Oneなどのプロダクトに対して認証・認可を提供する共通認証基盤です。
OAuth/OIDCプロバイダーとして、パスワード認証、SSO、API認可などを提供しています。もともとはAuth0のコスト課題を契機に、Amazon Cognitoとサーバーレスアーキテクチャを中心に内製化された基盤です。
Amazon Cognitoや利用しているライブラリで要件を満たせない部分については、Auth One側で追加実装しています。
参考:Sansanの認証基盤を支えるアーキテクチャとその振り返り (Speaker Deck)
なぜRefresh Token Rotationが必要だったのか
背景はMCP(Model Context Protocol)対応です。
Sansanでも、MCPサーバーを通じてプロダクトの機能やデータをAIアプリケーションやAIエージェントから利用できるようにする取り組みが進んでいます。このときAuth OneはMCPサーバーのOAuth認可基盤として利用されます。
MCPクライアントがMCPサーバーを継続的に利用するには、認可後もAccess Tokenを更新し続ける必要があります。そこでRefresh Tokenを利用しますが、MCPクライアントの形態上、Refresh Tokenを安全に扱ううえでいくつか課題があります。
- 今回想定したMCPクライアント(ユーザー環境で動作する形態)ではClient Secretを安全に保持できないため、 Public Clientとして扱う必要がある
- 一般にPublic ClientでRefresh Tokenを使う場合、漏えい時の不正利用リスクを下げるために (1) Sender-constrained(DPoPなど)とする、または (2) Refresh Token Rotationを行う、といった対策が必要。Auth OneはDPoPを実装済みだが、2026/05現在のMCP仕様ではDPoPが採用されておらず、MCPクライアントでは (1) を前提にできない。
特にPublic Clientでは端末内での保護に限界があり、さらにDPoPなどでトークンを特定クライアント(鍵)に束縛(sender-constrain)できない場合、Refresh Tokenが単体で漏えいしただけで第三者による継続的な不正利用につながり得ます。 この条件下でRefresh Token漏えい時の影響範囲を抑えるため、Refresh Token Rotationを導入する必要がありました。
Refresh Token Rotationと、その単純実装で起きる問題
Refresh Token Rotationは、Refresh Tokenを使ってAccess Tokenを更新するたびに、Refresh Token自体も新しいものへ入れ替える仕組みです。
通常のRefresh Tokenでは、漏えいしたトークンが有効期限まで使われ続ける可能性があります。一方、Refresh Token Rotationでは、古いRefresh Tokenを使った時点で新しいRefresh Tokenへ入れ替えるため、漏えいしたRefresh Tokenが使われ続けるリスクを抑えやすくなります。
ただし、単純に旧Refresh Tokenを失効し、新Refresh Tokenを発行するだけだと、クライアントが不必要に再ログインを求められる状況が起こりえます。
代表例は次の2つです。
- レスポンス未達によるトークン喪失
- サーバー側では旧Refresh Tokenを無効化し、新Refresh Tokenを発行したにもかかわらず、ネットワークエラーなどでレスポンスがクライアントへ届かない場合、クライアントは旧Refresh Tokenしか保持していません。
- その結果、次回以降の更新が失敗し、再ログインが必要になることがあります。
- 同時リフレッシュによる競合
- 同じRefresh Tokenを使ってほぼ同時にリフレッシュが走ると、一方が成功してローテーションされた後、もう一方は旧Refresh Tokenでのリクエストになり失敗しうるため、再ログインにつながることがあります。
この正しく更新しているのに再ログインとなる問題をどう吸収するかが、設計上の主要な論点でした。
設計判断1: Grace Period方式か、冪等方式か
Refresh Token Rotationの実装方針はRFCで細かく規定されているわけではないため、既存の認証基盤・サービスの実装方針を調査しました。
調査した範囲では、レスポンス未達や同時リフレッシュへの対処は大きく2つに分かれました。
| 方式 | 概要 | 特徴 |
|---|---|---|
| Grace Period方式 | ローテーション後も、旧Refresh Tokenを一定時間受け付ける | 実装は比較的素直だが、同じ旧Refresh Tokenから複数の新Refresh Tokenが発行されうる |
| 冪等方式 | 同じ旧Refresh Tokenに対するリクエストには、同じ新Refresh Tokenを返す | 有効なRefresh Tokenを常に 1 つに保ちやすいが、新Refresh Tokenを一時保存する必要がある |
Auth Oneでは冪等方式を選んだ
Auth Oneでは最終的に冪等方式を選びました。 なお冪等方式では、同じ旧Refresh Tokenに対して同じ新Refresh Tokenを返すことで再送や同時リフレッシュによる競合を吸収しますが、無期限に同一応答を保証するのではなく、一定期間に限って同一応答を返す設計にすることが多いです。本記事ではこの期間を「冪等Window」と呼びます。 冪等方式を選んだ理由は次の2つです。
- トークン管理を単純に保ちやすい
- Grace Period方式では、同時リフレッシュ時などにRefresh Tokenが分岐します。そのため、有効なRefresh Tokenの系列が木構造のように増えていき、管理が複雑になります。
- 冪等方式では、同じ旧Refresh Tokenに対して同じ新Refresh Tokenを返すため、分岐を避けやすくなります。
- 冪等Window内の再リクエストでDB書き込みを避けやすい
- 再リクエスト時にキャッシュから同じ新Refresh Tokenを返せれば、毎回のDB書き込みを伴わずに処理できます。
設計判断2: 新Refresh Tokenをどう安全に一時保存するか
冪等方式では、同じ旧Refresh Tokenに対して同じ新Refresh Tokenを返すために、新Refresh Tokenを一時的に保存する必要があります。
ここで問題になるのは、Refresh Tokenが長寿命であり、新しいAccess Tokenを発行する起点になる重要な値だという点です。
Auth Oneの既存実装では、Refresh Tokenの値そのものは保存せず、一致確認用のハッシュのみを保存していました。これは、Refresh Tokenの漏えいリスクを下げるためです。
そのため、冪等方式のためだけに新Refresh Tokenを平文でキャッシュする設計は避けたい、という前提がありました。
検討した選択肢は、主に次のようなものです。
| 選択肢 | 概要 | 懸念 |
|---|---|---|
| 平文で保存する | 透過的データ暗号化に任せ、Valkey には平文で保存する | アプリケーションからはRefresh Tokenを平文で扱える |
| 共通鍵で暗号化する | アプリケーション共通の鍵で新Refresh Tokenを暗号化する | 鍵漏えい時の影響範囲が大きい。追加で鍵管理する必要がある。 |
| KMSでエンベロープ暗号化する | KMS を使ってデータキーを保護する | レイテンシやコストが増える |
| 旧Refresh Token由来の鍵で暗号化する | 旧Refresh Tokenから鍵を導出し、新Refresh Tokenを暗号化する | 鍵導出の安全性(用途の分離や漏えい時の影響)を設計で担保する必要がある |
| HMACなどで導出する | 保存ではなく導出で対応する | 前方秘匿性などの観点で課題がある |
この中で採用したのが、旧Refresh Tokenから鍵を導出し、その鍵で新Refresh Tokenを暗号化して保存する方式です。
旧Refresh Tokenから鍵を導出して新Refresh Tokenを暗号化する
採用した方式のポイントは、冪等キャッシュヒット時のリクエストには、必ず旧Refresh Tokenが含まれることです。
同じ旧Refresh Tokenに対して同じ新Refresh Tokenを返すには、クライアントが旧Refresh Tokenを再度送ってくる必要があります。つまり、復号に必要な材料である旧Refresh Tokenは、リクエストから毎回得られます。
そこで、旧Refresh TokenからHKDF-SHA256で256bitの対称鍵を導出し(用途分離のためにinfoを設定)、その鍵で新Refresh TokenをAES-256-GCMにより暗号化します。
Valkeyには、新Refresh Tokenの平文ではなく、暗号文、IV、認証タグを保存します。キャッシュキーは旧Refresh Tokenのハッシュを用いて、旧Refresh Tokenを平文で扱う箇所を増やさないようにしました。
なお、この方式が主に想定しているのは、キャッシュ(Valkey)や周辺コンポーネントから暗号文が漏えいした場合の影響を抑えることです。旧Refresh Token自体が漏えいしているケースは別問題であり、その前提ではRefresh Token Rotationや有効期限など別の対策が重要になります。
初回リフレッシュ時の流れは次の通りです。
- クライアントが旧Refresh Tokenでリフレッシュを要求する
- Auth Oneが旧Refresh Tokenを検証する
- 新Refresh Tokenを生成する
- 旧Refresh TokenからHKDF-SHA256で鍵を導出する
- 新Refresh TokenをAES-256-GCMで暗号化する
- 暗号文、IV、認証タグをValkeyに短時間保存する
- クライアントへ新Access Tokenと新Refresh Tokenを返す
sequenceDiagram
participant C as クライアント
participant A as Auth One
participant D as DB
participant V as Valkey
C->>A: 1. Refresh Request (旧RT)
A->>D: 2. 旧RT 検証・無効化
A->>D: 3. 新RT保存
A->>V: 4. 暗号化キャッシュ設定
A-->>C: 5. 新RT + 新AT 返却
その後、冪等Window内に同じ旧Refresh Tokenで再リクエストが来た場合は次の通りです。
- クライアントが同じ旧Refresh Tokenでリフレッシュを要求する
- Auth OneがValkeyを参照する
- キャッシュヒットした暗号文を取得する
- リクエストに含まれる旧Refresh Tokenから同じ鍵を導出する
- 暗号文を復号し、新Refresh Tokenを取得する
- 同じ新Refresh Tokenと、新たに生成したAccess Tokenを返す
sequenceDiagram
participant C as クライアント
participant A as Auth One
participant V as Valkey
C->>A: 1. Refresh Request (旧RT)
A->>V: 2. Valkeyキャッシュ検索 → HIT
A->>A: 3. HKDF(旧RT)で復号 <br> → 新RT取得 + 新AT再生成
A-->>C: 4. 同じ新RT + 新AT 返却
この方式では、Valkeyに新Refresh Tokenの平文を保存しません。また、復号用の共通鍵を別途永続管理する必要もありません。
情報セキュリティ部レビューとパラメータ設計
Refresh Tokenは認証基盤において重要な値です。そのため、設計を確定する前に情報セキュリティ部へ相談しました。
相談時には、次のような点を整理しました。
- なぜRefresh Token Rotationが必要なのか
- なぜ冪等方式を選ぶのか
- 新Refresh Tokenをどこに、どの形式で保存するのか
- 旧Refresh Token由来の鍵で暗号化する方式にどのようなリスクがあるか
- 想定する攻撃や漏えい時の影響範囲
- 各種有効期限やWindowをどう設定するか
また、パラメータは最初から最適値を決め打ちするのではなく、観測しながら調整できるようにしました。
これらの値は、セキュリティーとUXの両方に影響します。期間を短くすれば、漏えい時の影響範囲は小さくなります。一方で、短すぎるとクライアントが頻繁に再認証を求められ、UXが悪化します。逆に期間を長くすればUXは改善しますが、漏えい時のリスクは大きくなります。
実装と検証
実装では、主に次の作業をしました。
- DB設計とマイグレーション
- Protocol Buffersでのスキーマ設計
- 内部向けAPI対応
- Refresh Token Rotationのコアロジック
- 暗号化・復号ロジック
- E2Eテスト
- OpenTelemetryメトリクス
- k6による負荷テスト
- 実MCPクライアントでの動作確認
検証では、主要サービス3種のMCPクライアントを使い、Product側のMCPサーバーへ接続しました。
MCPクライアントがMCPサーバーへ接続し、Auth Oneを通じてOAuth認可とトークンの更新を行います。MCPサーバー側では取得したJWTを検証してサービス環境へアクセスします。
検証の結果、3種類のクライアントで認証・接続が成功し、定期的なRefresh Token更新を通じて継続利用できることを確認しました。
また、テスト・観測面では次の観点を確認しました。
| 種別 | 確認したこと |
|---|---|
| ユニットテスト | 暗号化・復号、ローテーション処理 |
| E2Eテスト | Refresh Tokenの連続ローテーション、冪等性 |
| 負荷テスト | 想定されるリフレッシュ頻度での挙動 |
| メトリクス | キャッシュヒット、ローテーション結果、Refresh Tokenの経過秒数、セッション全体の経過秒数 |
| 実クライアント検証 | ChatGPT、Claude Desktop、Copilot Studioでの接続 |
やりきれなかったことと今後
今回のインターン期間では、Refresh Token Rotationの基本的な設計、実装、テスト、実クライアント検証まで進めることができました。
一方で、今後取り組むべきことも残っています。
- Refresh Token Rotationの適用範囲の拡大
- 管理画面からRefresh Token Rotationを有効化できるようにする対応
- 実際の利用状況を見ながらの各種パラメータ調整
おわりに
今回の取り組みでは、Refresh Token Rotationという一見よく知られた機能であっても、実際にプロダクトの共通認証基盤へ組み込むには多くの判断が必要だと感じました。
特に、MCPクライアントのようにPublic Clientであり、かつDPoPを前提にできないクライアントでは、仕様上の要件、セキュリティー、UX、既存基盤との互換性を同時に考える必要があります。
また、OAuth/OIDC周りでは、仕様に明確に書かれている部分と、実装側で判断しなければならない部分があります。今回のRefresh Token Rotationもまさにそのような領域でした。「銀の弾丸」はなく、複数の選択肢のPros/Consを比較しながら、その時点の要件に対して最も妥当な方式を選ぶことが重要だと学びました。
最後に、設計・実装にあたって相談に乗ってくださったIdentity Platformグループの皆さん、情報セキュリティ部レビューに協力してくださった皆さん、実クライアント検証に協力してくださった皆さんに感謝します。