Skip to main content

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_id in 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 }
Cookie encryption

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

ReplicasBacking storeDescription
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:

Do not align the TTL to the access-token expiry
  • ✅ 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}";
Empty-key collision

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)
"distributed lock" is not "Redlock"

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.

ItemDetail
TLS in transitConnect with ssl=true (TLS 1.2+)
Network isolationDisable the public endpoint; reach it only via Private Endpoint / VNet
Disable persistenceDisable RDB/AOF so tokens are never written to disk (run as a volatile cache)
AuthNPrefer Managed Identity (Microsoft Entra auth) over access keys
Optional defense-in-depthEncrypt 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_id in 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.

References