Table of Contents

Persistence

In memory, everything is lost when the process stops and nothing is shared between instances. The Fdyay.IdentityServer.Dapper package keeps the server's stores in SQLite or SQL Server. The code on this page is included from samples/Persistence, which a test boots against a SQLite file of its own and asks for a token as the client it seeds.

dotnet add package Fdyay.IdentityServer.Dapper

Every store in SQLite

builder.Services
    .AddIdentityServer()
    .AddDapperStores(options =>
    {
        options.Provider = DatabaseProvider.Sqlite;
        options.ConnectionString = builder.Configuration.GetConnectionString("IdentityServer")
            ?? throw new InvalidOperationException("ConnectionStrings:IdentityServer is not set.");

        // Every store: clients and resources, grants, signing keys, PAR, device codes, sessions, CIBA.
        options.Stores = DapperStores.All;

        // Creates the tables on first run. Turn it off where a migration project or a DACPAC owns the schema.
        options.InitializeSchema = true;

        // Nothing else removes expired grants from the database.
        options.EnableTokenCleanup = true;
        options.TokenCleanupInterval = TimeSpan.FromHours(1);
    });

AddDapperStores is chained onto AddIdentityServer() and replaces the in-memory stores it selects with database ones. DapperStoreOptions chooses the provider, the stores (DapperStores is a set of flags), whether the tables are created at startup, and whether expired grants are swept. The connection string comes from configuration:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "IdentityServer": "Data Source=identityserver.db"
  }
}

With signing keys in the database, automatic key management keeps them there instead of on disk, so every instance signs with the same key.

Clients and resources as rows

// With the configuration store in the database, clients and resources are rows. IConfigurationStoreWriter writes
// them; a real deployment does this from an administration tool or a deployment step rather than at startup.
var writer = app.Services.GetRequiredService<IConfigurationStoreWriter>();
await writer.SaveApiScopeAsync(new ApiScope("inventory", "Inventory API"));
await writer.SaveClientAsync(new Client
{
    ClientId = "inventory-sync",
    ClientSecrets = { new Secret("inventory-sync-secret".Sha256()) },
    AllowedGrantTypes = { OidcConstants.GrantTypes.ClientCredentials },
    AllowedScopes = { "inventory" },
});

With Configuration selected, clients and resources are read from the database, and IConfigurationStoreWriter writes them. A client read from a database is asked for on every protocol request; AddClientStoreCache and AddResourceStoreCache hold what was read for a while.

Only what several instances must share, in SQL Server

/// <summary>
/// Keeps clients and resources in code (registered with AddInMemoryClients and friends) and puts only what several
/// instances must share into SQL Server: grants, signing keys and pushed authorization requests.
/// </summary>
public static IIdentityServerBuilder AddOperationalStores(
    this IIdentityServerBuilder identityServer, IConfiguration configuration)
{
    return identityServer.AddDapperStores(options =>
    {
        options.Provider = DatabaseProvider.SqlServer;
        options.ConnectionString = configuration.GetConnectionString("IdentityServer")
            ?? throw new InvalidOperationException("ConnectionStrings:IdentityServer is not set.");

        options.Stores = DapperStores.PersistedGrants
            | DapperStores.SigningKeys
            | DapperStores.PushedAuthorizationRequests;

        // The schema is deployed separately, so the application never runs DDL.
        options.InitializeSchema = false;

        options.EnableTokenCleanup = true;
        options.RemoveConsumedTokens = true;
    });
}

The common production shape: clients and scopes stay in code, where they are reviewed and deployed like any other code, and only the operational data — grants, signing keys, pushed authorization requests — goes to the shared database. With InitializeSchema off the application never runs DDL; the schema is deployed separately. Every option and its default is listed under DapperStoreOptions.

What a reader of the tables sees

A persisted grant's Data is data-protected (PersistentGrants.DataProtectData, on by default). Since 0.177.0 so are the pushed, device-flow and backchannel rows, through the same IGrantDataProtector, as Duende stores them (measured: its PAR serializer protects the parameters, and its device and CIBA records go through IPersistentGrantSerializer), and a backchannel request is keyed by a SHA-256 of its auth_req_id rather than the id itself. Until then the auth_req_id — a credential for the tokens, with the client's authentication — the client's notification token and the approving user's claims sat in the table in the clear. Device and user codes were already stored hashed (0.131.0). Nothing needs migrating: a row written before is read by its unhashed key and through the protector's plaintext fallback, and lives only for a request's lifetime anyway.

Security: these rows are protected with the host's data protection key ring, so every instance shares it, as for the grants. AddIdentityServer() registers data protection; a host that wires only AddIdentityServerCore() with these stores calls AddDataProtection() too, or sets PersistentGrants.DataProtectData to false — a write otherwise fails saying so. A version before 0.177.0 cannot read what 0.177.0 wrote, so a rollback loses the pushed, device and backchannel requests in flight.