Added in Unreleased.
ZeeKayDa.Auth requires an IAuthorizationCodeStore, an IRefreshTokenStore and an interaction store to be registered before the application starts. None is registered automatically; you must opt in using the builder methods on ZeeKayDaAuthBuilder. The two token stores are covered first; the interaction store, which holds authorization requests while the user signs in, has its own section below.
For the full API reference, see Token stores.
Before you start
- You have a working
AddZeeKayDaAuth(...)registration. If not, see Configure ZeeKayDa.Auth. - Choose a store option from the table below before continuing.
Choosing a store for your environment
| Environment | Recommended option |
|---|---|
| Local development | .AddInMemoryStores() |
| Integration tests | .AddInMemoryStores() |
| Production (any instance count) where concurrent-redemption race and eviction risk are explicitly accepted | .AddDistributedCacheTokenStores() — see trade-offs below |
| Production where replay attacks are in the threat model, or any deployment with an evicting cache | Custom atomic stores |
⚠️ Warning: The in-memory stores are development and testing only — they lose all tokens on restart and disable reuse detection across instances. The distributed-cache stores have two concrete atomicity limitations; see Option 2 for a full trade-off analysis so you can decide whether they are right for your deployment.
Option 1 — In-memory stores (development and testing only)
Call .AddInMemoryStores() on the builder to register all three stores — codes, refresh tokens and interactions — in one step:
using ZeeKayDa.Auth;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddZeeKayDaAuth(options =>
{
options.Issuer = "https://id.example.com";
})
.AddInMemoryStores();
var app = builder.Build();
app.MapZeeKayDaAuth();
app.Run();
At startup in Development, ZeeKayDa.Auth logs a LogLevel.Information message per store to confirm it is active and remind you not to use it in production. Outside a Development environment the application refuses to start unless you explicitly pass allowOutsideDevelopment: true to the registration call.
💡 Tip: For integration tests running under a non-
Developmentenvironment name, passallowOutsideDevelopment: trueto the registration call in the test host configuration:.AddInMemoryStores(allowOutsideDevelopment: true);
.AddInMemoryAuthorizationCodeStore()and.AddInMemoryRefreshTokenStore()each accept the same parameter and gate on it independently — see Mixing stores below.
Option 2 — Distributed-cache stores
Use .AddDistributedCacheTokenStores() when you need a store backed by IDistributedCache. You must register an IDistributedCache implementation first.
builder.Services.AddDistributedMemoryCache(); // or AddStackExchangeRedisCache(...)
builder.Services
.AddZeeKayDaAuth(options =>
{
options.Issuer = "https://id.example.com";
})
.AddDistributedCacheTokenStores();
⚠️ Warning:
AddDistributedMemoryCache()adds a single-process in-memory cache. This adds no durability or atomicity benefit over.AddInMemoryStores(). Use it only during development to exercise theIDistributedCachecode path.
Trade-offs to evaluate before using in production
The distributed-cache stores have two atomicity gaps. You need to decide whether they apply to your deployment.
TOCTOU on single-use code redemption. TryRedeemAsync performs a read-then-write as two separate cache calls. Because ASP.NET Core / Kestrel serves concurrent requests across the thread pool, two requests can both read the same code as “not yet redeemed” before either writes the tombstone, causing double-redemption. This race exists on any deployment — single or multi-instance — whenever the application handles concurrent requests. The window spans two round-trips to the cache backend and may be an acceptable risk for low-traffic internal applications where concurrent authorization code redemption is implausible, but it cannot be ruled out by instance count alone.
Tombstone and revocation marker eviction. If your cache backend evicts entries under memory pressure before their TTL expires (for example, Redis with maxmemory-policy allkeys-lru), tombstones and family revocation markers can disappear early. A replayed authorization code would then appear fresh, or a revoked refresh token family would appear valid.
Every entry the distributed-cache stores write carries an absolute expiry, making each entry “volatile” in Redis terminology. Under volatile-ttl, Redis evicts volatile keys with the nearest TTL first under memory pressure — tombstones and revocation markers are candidates for eviction, not protected from it. Only noeviction actually prevents this: instead of silently discarding data, Redis refuses writes, which the stores surface as ZeeKayDaStoreException (fail-closed). Configuring Redis with noeviction is the only policy that eliminates this risk.
Acceptable configurations (you decide):
- Any deployment where the TOCTOU concurrent-redemption window is within the accepted threat model (for example, an internal tool with trusted clients and no adversarial replay risk) AND the cache backend is configured to never evict entries under memory pressure.
- Any deployment where both limitations above are within the accepted threat model.
Not acceptable:
- Any deployment where replay attacks are in the threat model and concurrent token requests are possible — which is true of any non-trivial deployment. Use a custom atomic store instead.
- Any deployment backed by an evicting cache (e.g. Redis
allkeys-lru) without sufficient memory headroom to guarantee tombstones and revocation markers are never evicted.
For the full reference, see Distributed-cache stores — atomicity trade-offs.
If you point the distributed-cache stores at a real shared backend such as Redis, ZeeKayDa.Auth emits a LogLevel.Warning at startup. Review the trade-offs above before accepting this warning in production.
Option 3 — Custom stores (production)
Implement IAuthorizationCodeStore and IRefreshTokenStore, then register them using the typed builder methods:
builder.Services
.AddZeeKayDaAuth(options =>
{
options.Issuer = "https://id.example.com";
})
.AddAuthorizationCodeStore<MyAtomicAuthorizationCodeStore>()
.AddRefreshTokenStore<MyAtomicRefreshTokenStore>();
Your implementations receive their dependencies via constructor injection like any other singleton service.
For multi-instance production, the critical requirement is atomicity: TryRedeemAsync (authorization code) and TryConsumeAsync (refresh token) must perform the check-and-mark step in a single atomic operation. For Redis, implement the operation as a Lua script. For SQL Server or PostgreSQL, execute a single UPDATE … WHERE consumed_at IS NULL inside a serializable transaction and inspect the row count.
See Implementing a custom store in the reference for the full contract requirements.
The interaction store
An authorization request lives in the interaction store from the moment /connect/authorize accepts it until the user has signed in and, where required, consented. Each request is one entry, bound to the browser that started it by a small zkd.interaction.<id> cookie, so any number of sign-ins can be in flight in one browser at once.
The interaction store needs only set, get and remove. That is exactly what a distributed cache provides, so unlike the token stores there is nothing bespoke to write for production: register a shared IDistributedCache and point the interaction store at it.
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "...");
builder.Services
.AddZeeKayDaAuth(options => { options.Issuer = "https://id.example.com"; })
.AddAuthorizationCodeStore<MyAtomicAuthorizationCodeStore>()
.AddRefreshTokenGrantStore<MyAtomicRefreshTokenStore>()
.AddDistributedCacheInteractionStore();
In development, .AddInMemoryStores() already registers a per-process interaction store; .AddInMemoryInteractionStore() registers it on its own, with the same allowOutsideDevelopment gate as the other in-memory stores.
⚠️ Warning:
AddDistributedMemoryCache()registers a cache that, despite its name, is shared with nothing. Behind a load balancer the login POST can land on an instance that never saw the authorize request, and the failure looks like an intermittent bug in your login page. Outside aDevelopmentenvironment the framework therefore refuses to start when.AddDistributedCacheInteractionStore()resolves that cache, unless you passallowMemoryCacheOutsideDevelopment: truefor an integration test host — in which case aCriticallog entry is emitted on every start.
Mixing stores
The two token stores are independently replaceable. A common pattern during a migration is to use the in-memory authorization code store (acceptable because codes are short-lived) alongside a custom persistent refresh token store:
builder.Services
.AddZeeKayDaAuth(options =>
{
options.Issuer = "https://id.example.com";
})
.AddInMemoryAuthorizationCodeStore() // dev/test; logs at startup
.AddRefreshTokenStore<MyPersistentRefreshTokenStore>();
⚠️ Warning:
.AddInMemoryAuthorizationCodeStore()still logs its startup message and is still subject to theDevelopment-environment check. This pattern is useful during development while building a persistent refresh token store; it is not a production configuration.
Each in-memory registration method carries its own allowOutsideDevelopment parameter and is gated independently — passing it on one call has no effect on another. For example, this configuration still fails to start outside Development because .AddInMemoryRefreshTokenStore() was not opted in, even though the authorization code store was:
builder.Services
.AddZeeKayDaAuth(options => { options.Issuer = "https://id.example.com"; })
.AddInMemoryAuthorizationCodeStore(allowOutsideDevelopment: true)
.AddInMemoryRefreshTokenStore(); // allowOutsideDevelopment defaults to false — still fails closed
Configuring lifetimes
Authorization code and refresh token lifetimes are properties of AuthorizationServerOptions, not of the store implementations. Configure them in the AddZeeKayDaAuth(...) lambda:
builder.Services.AddZeeKayDaAuth(options =>
{
options.Issuer = "https://id.example.com";
// Authorization code lifetime: default 60 s, max 600 s (RFC 9700 §2.1.1)
options.AuthorizationEndpoint.AuthorizationCodeLifetime = TimeSpan.FromSeconds(60);
// Refresh token lifetime: default 14 days, no upper bound enforced
options.TokenEndpoint.RefreshTokenLifetime = TimeSpan.FromDays(14);
// Clock skew grace window for multi-node deployments: default 5 s
// Must be less than half of AuthorizationCodeLifetime
options.ClockSkewTolerance = TimeSpan.FromSeconds(5);
});
💡 Tip:
RefreshTokenLifetimeis an idle timeout, not an absolute session duration. Refresh tokens rotate on every use — each successful token refresh tombstones the old token and issues a new one with a freshRefreshTokenLifetimewindow. A user who refreshes regularly can therefore maintain their session indefinitely. To enforce an absolute session cap you would need a mechanism outsideRefreshTokenLifetime; ZeeKayDa.Auth does not currently provide one.
For the valid ranges and the security trade-offs of each value, see Token stores — Lifetime options.
Data Protection key retention
The built-in stores encrypt stored token entries using IDataProtectionProvider. Key retention must be at least RefreshTokenLifetime. Shorter retention causes entries to become unreadable after a key rotation, which surfaces as NotFound at request time and silently logs out every user holding a token issued under the rotated key.
Configure key persistence and retention before deploying to a non-ephemeral environment:
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(/* ... */)
.SetDefaultKeyLifetime(TimeSpan.FromDays(90)); // must be ≥ RefreshTokenLifetime
💡 Tip: A typical
RefreshTokenLifetimeof 14 days means keys must remain valid for at least 14 days. A key lifetime of 90 days is a safe starting point for most deployments.
Related pages
- Token stores reference — API reference, interface contracts, and custom store requirements
- AuthorizationServerOptions reference — full options reference