Table of Contents

What the server keeps, and where

Between requests the server holds configuration it reads, grants it issued, codes it is waiting on, credentials it has already seen and messages for its pages. Persistence puts the stores in a database; this page is what is in them, what is cached in front of them, and what has to be shared once there is more than one instance. The code is included from samples/Server.State.

Caching the configuration store

/// <summary>
/// Clients and resources read from SQL Server, and held for a while rather than read on every protocol request.
/// </summary>
public static IIdentityServerBuilder AddCachedConfigurationStore(this IIdentityServerBuilder identityServer, string connectionString)
    => identityServer
        .AddDapperStores(options =>
        {
            options.Provider = DatabaseProvider.SqlServer;
            options.ConnectionString = connectionString;
        })
        .AddClientStoreCache<DapperClientStore>()                                 // 15 minutes, as Duende's
        .AddResourceStoreCache<DapperResourceStore>(TimeSpan.FromMinutes(5));

New in 0.140.0, Duende 8.0.1's, measured from its CachingClientStore<T>, CachingResourceStore<T> and the two registrations. Every protocol request reads its client and its scopes, so a store that is a database is asked far more often than its rows change. Name the store to cache it — after AddDapperStores, or in place of AddClientStore<T> / AddResourceStore<T> for your own — with AddClientStoreCache and AddResourceStoreCache:

  • A client is held by its id, behind the validating store, so what is held has been validated. An unknown or refused client is not held: a client added to the store works on its first request, and a refused one is refused, logged and raised as InvalidClientConfigurationEvent each time it is asked for, as without the cache.
  • The resources are held as one entry — the store's GetAllResourcesAsync — and every lookup is answered from it, an API resource by any of its scopes. A store that answers null is not held, and reads as no resources.
  • An edit reaches the server within the duration, not at once — a disabled client and a removed secret included. There is no duration that turns a cache off; a host that does not want one does not register it.

Both use the HybridCache under the key DynamicProviderExtensions.ConfigurationStoreCacheKey, the one the identity-provider cache uses: out of the box in memory and private to each process. A host that registers Microsoft's under that key gets a distributed second level for all three:

/// <summary>
/// Microsoft's HybridCache under the configuration stores' key: a distributed second level, shared by every
/// instance, for the client, resource and identity-provider caches.
/// </summary>
public static IServiceCollection AddSharedConfigurationCache(this IServiceCollection services)
{
    services.AddKeyedHybridCache(DynamicProviderExtensions.ConfigurationStoreCacheKey);
    return services;
}

Microsoft's holds a client serialized; every member a request reads comes back. Ours: the duration is an argument where Duende reads CachingOptions.ClientStoreExpiration and ResourceStoreExpiration — this server has no Caching group, as recorded at 0.49.0 — and Duende's AddInMemoryCaching() is not here: the registrations add the in-memory cache themselves when there is none.

IClientStore.GetAllClientsAsync — breaking in 0.140.0, Duende 8.0.1's. Every client in the store, as an IAsyncEnumerable<Client>, for a host's own enumeration — an administration page, an audit. The in-memory store yields its clients in the order given, the Dapper store by client id, the validating store each one it does not refuse (raising the event for one it does), and the cache passes it to the store uncached, as Duende's does. A host's own IClientStore must now implement it. Nothing on a request path here enumerates clients, as nothing in Duende does; its one other reader, Duende's IConnectedApplicationStore, arrived with the SAML service provider model in 0.143.0.

What is in the grant table

A persisted grant's Data is the serialized grant itself. For a refresh token, a reference token or an authorization code that is the whole claim set the subject was issued — every claim a profile service added included — so the table holds, for as long as those grants live, the identity of everybody with a session. It is protected with the host's data protection ring before it is stored, and that is on by default (PersistentGrants.DataProtectData). Turning it off:

/// <summary>
/// Persisted grants written without data protection — for a store encrypted at rest by other means, or one that
/// must be read with a query. Rows already written either way still read.
/// </summary>
public static IServiceCollection StoreGrantDataUnprotected(this IServiceCollection services)
    => services.Configure<IdentityServerOptions>(options => options.PersistentGrants.DataProtectData = false);

Turning it off does not make old rows unreadable, and neither does turning it on. The setting governs writes only. A read always tries to unprotect and falls back to treating the value as plaintext, on data protection's own format check rather than on a guess, so a table holding both shapes at once — the ordinary state during an upgrade, or after a host changes its mind — reads correctly either way. Nothing has to be migrated and no session ends at deploy.

Off is a real choice rather than a legacy setting. A host that keeps the store encrypted at rest by other means, or that needs to read the column with a query rather than through this server, is moving the protection rather than dropping it. What it must not do is assume the column is unreadable while this is off.

Two limits worth stating. Only Data is protected: Key is what a lookup is by, and SubjectId, ClientId, SessionId, Type and the timestamps are what PersistedGrantFilter queries on, so protecting them would leave the store unable to find its own rows. This hides the contents of a grant, not the fact that a subject has one. And the ring is the host's, so grants written by one instance are readable by another only where the ring is shared — the same requirement the signing keys and the antiforgery tokens already place on it.

This applies to whichever store is registered. The wrapper is re-applied by AddPersistedGrantStore<T>() and by AddDapperStores, because both register over what AddIdentityServer() left behind, and a protection that survived only the in-memory store would be absent from every deployment that keeps grants past a process lifetime. A store registered by hand with services.Replace after all of that is the one case it does not reach.

What is kept between requests

New in 0.131.0, Duende 8.0.1's, measured.

  • Device codes and user codes are hashed before they are stored, as authorization codes, refresh tokens and reference tokens already were: IDeviceFlowCodeService (Duende's) makes the device code and hands the store the hashes, and the device authorization endpoint, the token endpoint's device grant and the approval page all go through it. Until 0.131.0 a copy of the device flow table was a list of codes to poll the token endpoint with. An upgrade drops the device authorizations in flight — they were stored under the codes as issued — so a device polling at that moment is told its code is invalid and starts again; they last minutes, and nothing is migrated. The Dapper schema is unchanged: a hash is 43 characters and fits both columns.

  • A grant filter must name something. PersistedGrantFilter.Validate() (Duende's) throws ArgumentException, "No filter values set.", for a filter with no subject, session, client or type, and both stores call it. The Dapper store built its WHERE clause from whichever fields were set, so RemoveAllAsync with none ran DELETE FROM PersistedGrants. Nothing in this server sent one; a host's own code calling a store directly now cannot either.

  • The replay cache and the polling throttles live in IDistributedCache. DefaultReplayCache and the distributed device-flow and CIBA throttles (Duende's) are the defaults, and AddIdentityServer registers a distributed memory cache for them, as Duende's does — so a single instance behaves as before. A host with several instances registers a real one, and every instance then shares what any has seen; nothing else changes:

    /// <summary>
    /// A real IDistributedCache for a server with several instances, so the replay cache and the device-flow and CIBA
    /// polling throttles are shared: a DPoP proof replayed to another instance is not new there.
    /// </summary>
    public static IServiceCollection AddSharedReplayCache(this IServiceCollection services, string redis)
        => services.AddStackExchangeRedisCache(options => options.Configuration = redis);
    

    Until 0.131.0 each instance kept its own, so a DPoP proof or a signed CIBA request replayed to a second instance was new there, and a device polling too fast escaped slow_down by landing on another. InMemoryReplayCache and the in-memory throttles are still there for a host that registered them. The throttle keys hold the codes' hashes, not the codes.

  • A private_key_jwt assertion is accepted once. It must carry a jti (OpenID Connect Core §9 requires one), and one whose jti the replay cache has seen is refused, until five minutes past the assertion's expiry. Until 0.131.0 neither was checked, and an assertion captured from a request authenticated its client again until it expired. A client that sends no jti is now refused — every client library that implements private_key_jwt sends one.

The error page across instances

An authorize failure that cannot be returned to the client — an unknown client_id, an unregistered redirect_uri — is not written as protocol JSON at the browser. The server stores the detail server-side as an ErrorMessage and redirects to the error page carrying only an opaque errorId.

The default store is in-process (InMemoryMessageStore). Behind a load balancer the follow-up request for the error page may land on a different instance, one that never saw that errorId. When the lookup misses, the page still renders its generic message with the reference id displayed — and the same id is written to the logs, so support can still find the matching log line. What degrades across instances is the on-page detail, not diagnosability.

To show detail on the page across instances, register your own IMessageStore<ErrorMessage> (it is an interface) with a shared backing store. Rate-limit it first: a failed authorize request is unauthenticated, so a persisted-grant-backed store would let an anonymous caller write a row per request, and there is no rate limiting in the server today. The in-process default is deliberately kept as the safe out-of-the-box behaviour.

What it holds is bounded — since 0.177.0. The in-process store keeps at most DefaultCapacity (10,000) messages, the oldest giving way, and each write sweeps only the messages that have expired, oldest first; until then it kept every message for its whole lifetime and each write walked all of them, so a flood of refused requests grew memory and made every write slower. The authorize endpoint stores the requested client_id and redirect_uri only within InputLengths — a refusal for exceeding them used to store the whole value, megabytes from one anonymous request — and an ErrorMessage keeps neither past MaxValueLength (2,048 characters), whoever writes it. The same store carries consent decisions; a host whose instance takes more than 10,000 errors or decisions within a message's lifetime registers the store with a larger capacity, or uses the protected store below:

/// <summary>
/// The in-process error store holding more than its default 10,000 messages, for an instance that refuses more
/// requests than that within a message's lifetime.
/// </summary>
public static IServiceCollection AddLargerErrorStore(this IServiceCollection services)
    => services.AddSingleton<IMessageStore<ErrorMessage>>(sp => new InMemoryMessageStore<ErrorMessage>(
        sp.GetRequiredService<TimeProvider>(), TimeSpan.FromMinutes(5), capacity: 50_000));

Or carry the message in the id — new in 0.150.0, Duende's ProtectedDataMessageStore<T>:

// The error page's and the consent page's messages carried in their ids, protected with the data protection ring,
// instead of kept in this process: any instance sharing the ring reads what any other wrote.
.AddProtectedDataMessageStores();

AddProtectedDataMessageStores replaces the error page's and the consent page's stores with one that keeps nothing. The message is serialized, protected with the host's data protection ring and Base64Url-encoded as its own id, so any instance sharing the ring reads what any other wrote, and an anonymous caller fills no table. Ours: it carries the message's expiry with it (each page's lifetime from UserInteractionOptions), and a message that cannot be written throws where Duende's returns no id. Opt-in, where Duende's is the default, because the error page shows the id, and this id is as long as the message.

The rejected redirect_uri behind that reference id is written to the logs, as a structured scope property rather than in the log message text, so an attacker-supplied value cannot forge a log line. Microsoft.Extensions.Logging drops scope state unless the provider opts in (IncludeScopes defaults to false), so a provider without scope capture records everything about the failure except the redirect URI. Turn it on for whatever logging provider you deploy — for the console:

// A rejected redirect_uri is logged as a scope property, never in the message text. Providers drop scopes unless
// they opt in.
builder.Services.Configure<SimpleConsoleFormatterOptions>(options => options.IncludeScopes = true);

New in 0.161.0, Duende 8.0.1's IConsentMessageStore, measured from its assembly; this closes the last 🟡 row of the parity matrix. GrantConsentAsync and DenyAuthorizationAsync write the decision through IConsentMessageStore as Duende's Message<ConsentResponse>, stamped with the server's clock (Message<TModel> is Duende's too: Created in UTC ticks, Data), and the authorize endpoint reads it and deletes it, so a host that registers its own by Duende's name — services.AddSingleton<IConsentMessageStore, MyStore>(), before or after AddIdentityServer() — carries every decision; AddIdentityServer() registers the default only where none is.

The default sits over what was there: the decision in IMessageStore<ConsentMessage> as the subject and client it always recorded, that store's id in the consent cookie, so AddProtectedDataMessageStores() and a host's own IMessageStore<ConsentMessage> keep working, and a decision in flight across the upgrade is still read. A message a store hands back with no decision in it is answered server_error on the error page, Duende's "consent message is missing data". Nothing breaks: the interaction service and the authorize endpoint take the store through new constructor overloads, the old ones building the default over the message store they are given.

Ours: the id names the client and the subject — so a decision answers the next request that user makes of that client, as this server's always has — where Duende's is a hash of them with the request's nonce and scopes (ConsentRequest, not taken: it would narrow which request a decision answers); a store shared between instances therefore holds one decision per user and client, and the endpoint reads and deletes on every signed-in request rather than on the consent page's callback alone, deleting as soon as it reads. A refusal passed to GrantConsentAsync still is not handed on — DenyAuthorizationAsync is what answers the client — where Duende's hands on any response.