A Durable Client Store in About a Hundred Lines
Out of the box, Abblix OIDC Server keeps clients in memory: an internal store seeded from OidcOptions.Clients at startup. For statically configured clients that is all you ever need: the configuration file is the durable store, and a restart reloads it. The moment Dynamic Client Registration enters the picture, it stops being enough, twice over. The in-memory dictionary is per-process: in any multi-instance deployment (two pods behind a Kubernetes load balancer), a client registered through one instance simply does not exist on its neighbors, so the registration succeeds and the very next token request fails with invalid_client whenever the balancer picks another pod. And a restart forgets everything silently, along with every token-endpoint credential the registered client was issued.
The fix is the library's standard answer to persistence: put your own store behind the seams. Client configuration is durable data: registrations must survive restarts and failovers by construction, so its home is a persistent database. Which one is your call: the seams take any relational or document store. The worked example below uses PostgreSQL.
Two seams, one store
The client registry splits into a read seam and a write seam:
public interface IClientInfoProvider
{
Task<ClientInfo?> TryFindClientAsync(string clientId);
}
public interface IClientInfoManager
{
Task AddClientAsync(ClientInfo clientInfo);
Task UpdateClientAsync(ClientInfo clientInfo); // RFC 7592 client update
Task RemoveClientAsync(string clientId); // RFC 7592 deprovisioning
}
The server reads through IClientInfoProvider on every protocol request that names a client: authorization validation, every client-authentication method, userinfo, end-session, DCR reads. The write seam is exercised by exactly one feature: Dynamic Client Registration persists new clients through AddClientAsync and drives the RFC 7592 lifecycle through the other two methods. If you never enable DCR, a read-only provider is a complete implementation.
The split is CQRS in the small: queries and commands travel separate interfaces, and nothing requires one implementation to serve both. A host can point IClientInfoProvider at a read-optimized model (a replica, a cache, a denormalized view) while IClientInfoManager commands go to the system of record. The recipe below composes both sides into one class only because that is the simplest arrangement, not because the seams demand it; the short-TTL cache the hot-path section adds in front of the provider is exactly a separate read model growing out of this split.
ClientInfo, the record both seams exchange, is keyed by ClientId and carries the client's whole protocol policy: secrets and authentication method, redirect and logout URIs, grant and scope allow-lists, token lifetimes, per-client signing and encryption algorithm choices, and the DCR metadata. Treat it as one document rather than a table of its sixty-plus members: that shapes the storage decision below.
How the default is wired, and how to replace it
Most of the library's seams follow the TryAdd convention: pre-register your implementation before AddOidcServices, and the library's default yields. The client store is the exception worth knowing about. The library registers one internal singleton implementing both interfaces and aliases both of them to it, and the aliases are appended unconditionally, not TryAdd-ed. A pre-registered implementation is silently overridden: both registrations stay in the container, but the library's alias wins on singular resolution, and your store is never consulted. (The 2.4 development branch fixes this, and a store registered before AddOidcServices takes effect there; the pattern below works identically on both versions, which is why this guide uses it.)
The clean pattern: decorate the read side, replace the write side, after AddOidcServices:
// Program.cs, after AddOidcServices(...)
builder.Services.AddSingleton<PostgresClientStore>();
// reads: config-file clients keep working, the durable store answers for the rest
builder.Services.Decorate<IClientInfoProvider, LayeredClientInfoProvider>();
// writes: detach the in-memory manager, point DCR at the durable store
builder.Services.RemoveAll<IClientInfoManager>();
builder.Services.AddAlias<IClientInfoManager, PostgresClientStore>();
Decorate and AddAlias come with the library's DI toolkit (Abblix.DependencyInjection); RemoveAll lives in Microsoft.Extensions.DependencyInjection.Extensions, a using the implicit set does not cover. The decorator that layers the two sources is a dozen lines:
public sealed class LayeredClientInfoProvider(
IClientInfoProvider inner, // the library default: clients from OidcOptions.Clients
PostgresClientStore store) : IClientInfoProvider
{
// config-defined clients win on read, so a DCR registration can never override an
// existing first-party client, but do not let host authorization trust id naming patterns
public async Task<ClientInfo?> TryFindClientAsync(string clientId) =>
await inner.TryFindClientAsync(clientId) ?? await store.TryFindClientAsync(clientId);
}
The recipe, worked on PostgreSQL
A persistent store is the natural home for client configuration, and a relational one adds what a key-value store cannot: a queryable client inventory (list all clients, filter by name, audit registrations) inside the same durability and backup regime as your system-of-record data. Nothing below is PostgreSQL-specific beyond the SQL dialect; the same shape carries to any relational or document database your platform already trusts. Resist the urge to model ClientInfo's sixty-plus members as columns: the server only ever looks a client up by id, and a column-per-property schema buys nothing but a migration every time the library adds a policy knob. One jsonb payload column carries the document; promote to real columns only what you query:
CREATE TABLE oidc_clients (
client_id text PRIMARY KEY,
payload jsonb NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
public sealed class PostgresClientStore(NpgsqlDataSource dataSource) : IClientInfoProvider, IClientInfoManager
{
public async Task<ClientInfo?> TryFindClientAsync(string clientId)
{
await using var connection = await dataSource.OpenConnectionAsync();
var json = await connection.ExecuteScalarAsync<string?>(
"SELECT payload FROM oidc_clients WHERE client_id = @clientId",
new { clientId });
return json is null ? null : JsonSerializer.Deserialize<ClientInfo>(json);
}
public Task AddClientAsync(ClientInfo clientInfo) => UpsertAsync(clientInfo);
public Task UpdateClientAsync(ClientInfo clientInfo) => UpsertAsync(clientInfo);
private async Task UpsertAsync(ClientInfo clientInfo)
{
await using var connection = await dataSource.OpenConnectionAsync();
await connection.ExecuteAsync(
"""
INSERT INTO oidc_clients (client_id, payload, updated_at)
VALUES (@ClientId, @Payload::jsonb, now())
ON CONFLICT (client_id) DO UPDATE
SET payload = @Payload::jsonb, updated_at = now()
""",
new { clientInfo.ClientId, Payload = JsonSerializer.Serialize(clientInfo) });
}
public async Task RemoveClientAsync(string clientId)
{
await using var connection = await dataSource.OpenConnectionAsync();
await connection.ExecuteAsync(
"DELETE FROM oidc_clients WHERE client_id = @clientId", new { clientId });
}
}
The example uses Dapper for brevity; EF Core with a single mapped entity works identically. The data source comes from builder.Services.AddNpgsqlDataSource(...) in the Npgsql.DependencyInjection package. DCR expiry needs one explicit touch here: there is no store-side TTL, so persist an expires_at column and filter it in the SELECT, or run a periodic cleanup. The choice between the two is exactly the schema control the BYO model hands you. Populate the lifetime from ClientInfo.ExpiresAfter or your own registration default: the library's DCR endpoint never sets ExpiresAfter; its value is host policy.
ClientInfo.ClientSecrets carries secret hashes and, for client_secret_jwt clients, the raw secret value (HMAC verification needs it), so this store puts credential material at rest in your database and its backups. The cache-entry encryption recipe in the Production Hardening Checklist does not cover this store: it protects the operational-state path, while here the host owns serialization end to end. Protect the store itself: encrypted disk and TLS, a dedicated least-privilege role with no reporting or analytics grants, and the same retention and access discipline on backups you apply to a password database.
The hot path caveat
TryFindClientAsync sits on the hot path: the library ships no caching layer above it, so every authorization request, every token-endpoint client authentication, every userinfo call resolves the client through your adapter. Put a short-TTL in-memory cache in front, and keep the TTL short deliberately: it is the window during which an RFC 7592 update or removal is not yet visible to validation. Seconds, not minutes. Decide the miss side explicitly too: caching nulls keyed by attacker-supplied client ids lets anyone bloat the cache by spraying identifiers, while not caching them sends every unknown-client probe to the database. Bound the cache size and keep negative entries shorter-lived than positive ones. And either way, note the coupling: your store's availability is now the token endpoint's availability, so an unreachable store means client resolution fails closed, and every flow with it.
Verify it holds
Before calling the store done, run the one scenario the default fails:
- register a client through the DCR endpoint and complete a token request with it;
- restart the server;
- repeat the token request: it must still succeed, and the RFC 7592 read must still return the registration;
- confirm a config-file client authenticates as before: that proves the layering decorator, not just the store;
- run two instances behind a balancer without sticky sessions and repeat the DCR-then-token sequence: that proves the store is shared, not merely durable.
Where this sits in the bigger picture
This store covers clients. Operational state (codes, grants, device and CIBA requests) lives behind a different seam with different durability rules, covered in Choosing a Backend for Operational State; the go-live defaults that interact with DCR-registered clients (PKCE, refresh-token policy) are in the Production Hardening Checklist.