サーバーサイドトークンストアと分散セッション(Redis・リフレッシュロック)
概要
BFF パターンでは、アクセストークン・リフレッシュトークン・ID トークンを ブラウザに渡さずサーバー側に保管します。本ドキュメントは、その「サーバー側トークンストア」の設計、 すなわち In-Memory と Redis の選択、TTL 設計、複数 Pod での同時リフレッシュ競合への対処 (分散ロック)、fail-closed なキー解決を解説します。
なぜサーバー側に保管するのか
- クッキーサイズ制限の回避:access + refresh + id token をクッキーに入れると容易に 4 KB を超過する。
クッキーには不透明な
session_idのみを入れ、トークン本体はストアに置く。 - トークン露出の排除:トークンがブラウザに存在しないため、XSS による窃取が成立しない。
- 集中管理:失効・ローテーション・期限管理をサーバー側で一元的に制御できる。
__Host-bff クッキー(暗号化, 中身は session_id のみ)
│
▼
トークンストア(キー: session_id)
└── { access_token, refresh_token, id_token, expires_at }
クッキー(session_id を含む)は ASP.NET Core の Data Protection で暗号化・署名されます。複数 Pod で
クッキーを相互に復号するには鍵リングの共有が必要です。詳細は
.NET Data Protection と認証クッキーのセキュリティ
を参照してください。
In-Memory と Redis の選択
| レプリカ数 | バッキングストア | 説明 |
|---|---|---|
| 1(初期) | DistributedMemoryCache(In-Memory) | 外部依存なし。開発・低トラフィック向け。Pod 再起動でセッション消失 |
| 2 以上 | Redis(分散キャッシュ) | レプリカ間でセッション共有が必須。共有しないと別 Pod 振り分けでセッション断 |
共通のストア抽象(例 IUserTokenStore)を介すことで、フェーズ間の移行はバッキングストアの差し替え
(NuGet 追加+設定変更)のみで済み、アプリのコード構造を変えずに In-Memory → Redis へ移行できます。
TTL 設計(最重要)
ストアのエントリにはリフレッシュトークンが含まれ、これはアクセストークンより長命です。したがって、
- ✅ TTL = セッション/リフレッシュトークン寿命にする。
- ❌ アクセストークン期限(数十分)を TTL にすると、アクセストークン失効と同時にリフレッシュトークンごと 消滅し、更新不能になる。ユーザーは数十分ごとに強制ログアウトされる。
fail-closed なキー解決
session_id をキーにストアを引く際、session_id が欠落していたら**例外で拒否(fail-closed)**します。
var sessionId = user.FindFirst("session_id")?.Value;
if (string.IsNullOrWhiteSpace(sessionId))
throw new InvalidOperationException("session_id is missing; cannot resolve token store key.");
return $"tokens:{sessionId}";
session_id が空のとき tokens:(空文字キー)を返してしまうと、クレームを持たない複数ユーザーが同一
エントリを共有し、他人のトークンを読めてしまう重大な情報漏洩につながります。必ず fail-closed にします。
同時リフレッシュ競合と分散ロック
トークン管理ライブラリは通常、単一プロセス内の同時リフレッシュは同期します。しかし BFF が複数 Pod の 場合、別 Pod 上の同時リクエストが同時にリフレッシュし得て、リフレッシュトークンのローテーションと 相まって更新競合(トークン上書き・リフレッシュトークン無効化)が起きます。
これを防ぐのが分散リフレッシュロックです。
キー構造:
tokens:{session_id} # トークン本体(TTL = セッション/リフレッシュ寿命)
lock:refresh:{session_id} # 分散ロック(TTL = 10〜30 秒)
リフレッシュ手順(concurrency control 内):
1. lock:refresh:{session_id} を SET NX PX で取得
2. 取得できたら → ストアを「再読込」(別 Pod が直前に更新済みかもしれない)
まだ期限切れなら → IdP でリフレッシュ → 保存
ロックを compare-and-delete で解放(他人のロックを消さない)
3. 取得できなければ → 短く待ってストアを再読込し、新しいトークンを返す(再リフレッシュしない)
ここでの分散ロックは 単一の論理 Redis(例: マネージド Redis のプライマリ)に対する
SET NX PX + compare-and-delete です。複数の独立した Redis マスターを前提とする Redlock
アルゴリズムとは異なります。独立マスターを運用しない限り Redlock は不要で、用語も区別すべきです。
取得後の「再読込(re-read)」が肝心です。待機中にピア Pod がトークンをローテーション済みの場合に、 二重リフレッシュを防ぎます。
ストアのハードニング
トークンは保存時に平文相当のため、Redis を使う場合は次を必須とします。
| 項目 | 内容 |
|---|---|
| 転送時 TLS | ssl=true で接続(TLS 1.2+) |
| ネットワーク隔離 | 公開エンドポイントを無効化し、Private Endpoint / VNet 経由のみ |
| 永続化の無効化 | RDB/AOF を無効化し、トークンがディスクに書き出されないようにする(揮発キャッシュ運用) |
| 認証 | アクセスキーより Managed Identity(Microsoft Entra 認証) を優先 |
| 任意の多層防御 | 保存前にトークン値を暗号化し、メモリダンプでの平文露出を防ぐ |
まとめ
- BFF はトークンをサーバー側ストアに保管し、クッキーには不透明な
session_idのみを入れる (4 KB 制限の回避・トークン露出の排除)。 - レプリカ 1 は In-Memory、2 以上は Redis(共有必須)。共通抽象で移行コストを最小化。
- TTL はリフレッシュトークン寿命に合わせる(アクセストークン期限にしない)。
- キー解決は fail-closed。複数 Pod の同時更新は分散リフレッシュロック + 再読込で直列化する。
- Redis は TLS・Private Endpoint・RDB/AOF 無効化・Managed Identity でハードニングする。
関連ドキュメント
- BFF パターンとトークン保護のセキュリティモデル
- 送信者拘束トークン(DPoP / mTLS)
- .NET Data Protection と認証クッキーのセキュリティ
- HTTP-only Cookie 認証