Server-side Token Store and Distributed Sessions (Redis, Refresh Lock)
Overview
In the BFF pattern, the access token, refresh token, and ID token are kept server-side rather than handed to the browser. This document covers the design of that "server-side token store": the choice between In-Memory and Redis, TTL design, handling concurrent-refresh races across multiple pods (distributed lock), and fail-closed key resolution.
Why store tokens server-side
- Avoid the cookie size limit: putting access + refresh + id token in the cookie easily exceeds
4 KB. Put only an opaque
session_idin the cookie and keep the tokens in the store. - Eliminate token exposure: tokens are not in the browser, so XSS theft cannot succeed.
- Centralized control: revocation, rotation, and expiry are managed centrally on the server.
__Host-bff cookie (encrypted; contents = session_id only)
│
▼
Token store (key: session_id)
└── { access_token, refresh_token, id_token, expires_at }
The cookie (containing session_id) is encrypted and signed by ASP.NET Core Data Protection. To decrypt
cookies across multiple pods, the key ring must be shared. See
.NET Data Protection and Authentication Cookie Security.
Choosing In-Memory vs. Redis
| Replicas | Backing store | Description |
|---|---|---|
| 1 (initial) | DistributedMemoryCache (In-Memory) | No external dependency. For dev / low traffic. Session lost on pod restart |
| 2+ | Redis (distributed cache) | Session sharing across replicas is required. Without it, routing to a different pod drops the session |
Going through a common store abstraction (e.g. IUserTokenStore) lets migration between phases be just a
swap of the backing store (NuGet addition + config change), moving from In-Memory to Redis without
changing the app's code structure.
TTL design (most important)
The store entry contains the refresh token, which is longer-lived than the access token. Therefore:
- ✅ Set TTL = session / refresh-token lifetime.
- ❌ Using the access-token expiry (tens of minutes) as the TTL would evict the refresh token along with it the moment the access token expires, making refresh impossible. Users would be force-logged out every few tens of minutes.
Fail-closed key resolution
When resolving the store key from session_id, reject with an exception (fail-closed) if
session_id is missing.
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}";
If an empty session_id returns the key tokens: (empty string), multiple users without the claim
share the same entry and can read each other's tokens — a serious information leak. Always fail closed.
Concurrent-refresh races and the distributed lock
Token-management libraries usually synchronize concurrent refreshes within a single process. But when the BFF runs across multiple pods, concurrent requests on different pods can refresh at the same time, and combined with refresh-token rotation this causes update races (token overwrite, refresh token invalidation).
A distributed refresh lock prevents this.
Key structure:
tokens:{session_id} # the tokens (TTL = session/refresh lifetime)
lock:refresh:{session_id} # distributed lock (TTL = 10-30 s)
Refresh procedure (inside concurrency control):
1. Acquire lock:refresh:{session_id} with SET NX PX
2. If acquired -> "re-read" the store (a peer pod may have just updated it)
If still expiring -> refresh via IdP -> store
Release the lock with compare-and-delete (don't delete someone else's lock)
3. If not acquired -> wait briefly, re-read the store, return the new token (do not refresh again)
The distributed lock here is SET NX PX + compare-and-delete against a single logical Redis (e.g. a
managed Redis primary). It is not the Redlock algorithm, which assumes multiple independent Redis
masters. Unless you run independent masters, Redlock is unnecessary and the terms should be distinguished.
The "re-read" after acquiring is essential: it prevents a double refresh when a peer pod has already rotated the token while we waited.
Store hardening
Tokens are effectively plaintext at rest, so when using Redis the following are required.
| Item | Detail |
|---|---|
| TLS in transit | Connect with ssl=true (TLS 1.2+) |
| Network isolation | Disable the public endpoint; reach it only via Private Endpoint / VNet |
| Disable persistence | Disable RDB/AOF so tokens are never written to disk (run as a volatile cache) |
| AuthN | Prefer Managed Identity (Microsoft Entra auth) over access keys |
| Optional defense-in-depth | Encrypt token values before storing to prevent plaintext exposure in a memory dump |
Summary
- A BFF keeps tokens in a server-side store and puts only an opaque
session_idin the cookie (avoiding the 4 KB limit and eliminating token exposure). - One replica: In-Memory; 2+: Redis (sharing required). A common abstraction minimizes migration cost.
- Set the TTL to the refresh-token lifetime (not the access-token expiry).
- Resolve keys fail-closed. Serialize concurrent updates across pods with a distributed refresh lock + re-read.
- Harden Redis with TLS, Private Endpoint, disabled RDB/AOF, and Managed Identity.
Related documents
- BFF Pattern and the Token-Protection Security Model
- Sender-constrained Tokens (DPoP / mTLS)
- .NET Data Protection and Authentication Cookie Security
- HTTP-only Cookie Authentication