← Duende SkillsCONTENT HISTORYWHAT CHANGED · RULE-BASED ANALYSIS
Update to Duende Skills
Snapshot Sep 30, 2026 · 23:14 UTC · version 0.3.0
Collection source: not recorded for this historical snapshot.
First saved snapshot
No earlier snapshot is available to establish a change.
Compare saved observations
Download comparison JSONFull technical diff · 0 changed fields
Full snapshot data
{
"name": "identityserver-stores",
"description": "Implement and customize Duende IdentityServer stores including configuration store, operational store, and Entity Framework Core integration. Covers migrations, custom store implementations, caching strategies, server-side sessions, signing key storage, token cleanup, and multi-tenant patterns.",
"included_files": [],
"skill_md_contents": "---\nname: identityserver-stores\ndescription: Implement and customize Duende IdentityServer stores including configuration store, operational store, and Entity Framework Core integration. Covers migrations, custom store implementations, caching strategies, server-side sessions, signing key storage, token cleanup, and multi-tenant patterns.\ninvocable: false\n---\n\n# Duende IdentityServer Stores\n\n## When to Use This Skill\n\n- You are wiring up `AddConfigurationStore()` or `AddOperationalStore()` with EF Core and need correct registration, migration assembly setup, and schema configuration.\n- You are implementing a custom `IClientStore`, `IResourceStore`, `IPersistedGrantStore`, or `ISigningKeyStore` against a non-EF data source (Redis, Mongo, external API, etc.).\n- You need to enable and tune configuration store caching (`AddConfigurationStoreCache()`, expiration windows, distributed cache setup) to reduce database load.\n- You are managing EF Core migrations across IdentityServer versions and need to correctly handle schema drift for `ConfigurationDbContext` and `PersistedGrantDbContext`.\n- You are enabling server-side sessions (`IServerSideSessionStore`) and need to understand session lifecycle, cleanup, and storage integration.\n- You are troubleshooting stale client or resource data, expired token accumulation, or signing key rotation failures tied to store configuration.\n- You are designing a multi-tenant IdentityServer deployment and need to choose between database-per-tenant and shared-database store strategies.\n\n## Core Principles\n\n**Store interfaces decouple IdentityServer from persistence.** All data access goes through store interfaces registered in the ASP.NET Core DI container. IdentityServer does not care what database backs them — EF Core, Redis, MongoDB, or a static in-memory collection are all equally valid.\n\n**Two independent store categories exist: configuration and operational.** They can be used independently or together. Configuration data is relatively static (clients, resources, CORS); operational data is dynamic and high-write (grants, sessions, signing keys). They should be sized, cached, and maintained with those distinct access patterns in mind.\n\n**Operational data is protected at rest.** The `Data` payload of persisted grants and serialized signing keys is encrypted using the ASP.NET Core Data Protection API. Key rotation and Data Protection configuration must be coordinated — a lost Data Protection key makes stored grants and signing keys unreadable.\n\n**Consumed grants are soft-deleted, not immediately removed.** One-time-use grants (e.g., authorization codes, one-time refresh tokens) are marked with a `ConsumedTime` rather than deleted. This enables threat detection in custom `IRefreshTokenService` implementations. Do not confuse consumed with expired — the token cleanup service only removes records past their `Expiration`, not consumed ones (unless `RemoveConsumedTokens` is enabled).\n\n**EF Core schema changes are your responsibility.** Duende does not ship automatic migration scripts or schema upgrade tooling. You own migration creation, application, and data migration between IdentityServer versions.\n\nDocs: https://docs.duendesoftware.com/identityserver/data\n\n---\n\n## NuGet Package\n\n```bash\ndotnet add package Duende.IdentityServer.EntityFramework\n```\n\nThis package provides EF Core implementations for all configuration and operational store interfaces.\n\n---\n\n## Store Architecture\n\nIdentityServer's data is split into two categories, each with its own set of store interfaces:\n\n```\n┌─────────────────────────────────────────────────────────────┐\n│ IdentityServer Runtime │\n├──────────────────────────┬──────────────────────────────────┤\n│ Configuration Data │ Operational Data │\n│ │ │\n│ • Clients │ • Authorization codes │\n│ • API Resources │ • Reference tokens │\n│ • API Scopes │ • Refresh tokens │\n│ • Identity Resources │ • User consent │\n│ • Identity Providers │ • Device codes │\n│ • CORS policies │ • Pushed auth. requests │\n│ │ • Signing keys │\n│ │ • Server-side sessions │\n├──────────────────────────┼──────────────────────────────────┤\n│ ConfigurationDbContext │ PersistedGrantDbContext │\n│ (IClientStore, │ (IPersistedGrantStore, │\n│ IResourceStore, │ IDeviceFlowStore, │\n│ IIdentityProviderStore,│ IPushedAuthorizationRequestStore,│\n│ ICorsPolicyService) │ IServerSideSessionStore, │\n│ │ ISigningKeyStore) │\n└──────────────────────────┴──────────────────────────────────┘\n```\n\n### Configuration Data\n\nStores static, rarely-changing data that describes how IdentityServer behaves:\n\n| Interface | Contents |\n|---|---|\n| `IClientStore` | OAuth/OIDC clients (grant types, redirect URIs, secrets, claims, scopes) |\n| `IResourceStore` | `IdentityResource`, `ApiResource`, and `ApiScope` definitions |\n| `ICorsPolicyService` | CORS allowed-origin rules (derived from client configuration) |\n| `IIdentityProviderStore` | Dynamic external identity provider registrations |\n\n### Operational Data\n\nStores dynamic, high-write runtime state that IdentityServer creates and manages during request processing:\n\n| Interface | Contents |\n|---|---|\n| `IPersistedGrantStore` | Authorization codes, refresh tokens, reference tokens, user consent records |\n| `IDeviceFlowStore` | Device authorization flow codes and user codes |\n| `ISigningKeyStore` | Dynamically managed signing keys (used by automatic key management) |\n| `IServerSideSessionStore` | Server-side authentication session data for interactive users |\n| `IPushedAuthorizationRequestStore` | Pushed authorization request (PAR) data |\n\n---\n\n## EF Core Integration\n\nThe `Duende.IdentityServer.EntityFramework` NuGet package provides EF Core-backed implementations of all store interfaces. It ships two `DbContext` types:\n\n- **`ConfigurationDbContext`** — backs `IClientStore`, `IResourceStore`, `ICorsPolicyService`, `IIdentityProviderStore`\n- **`PersistedGrantDbContext`** — backs `IPersistedGrantStore`, `IDeviceFlowStore`, `ISigningKeyStore`, `IServerSideSessionStore`\n\n### Registering Both Stores\n\n```csharp\n// ✅ Correct: register both stores with explicit migration assembly\nvar migrationsAssembly = typeof(Program).Assembly.GetName().Name;\nvar connectionString = builder.Configuration.GetConnectionString(\"IdentityServer\");\n\nbuilder.Services.AddIdentityServer()\n .AddConfigurationStore(options =>\n {\n options.ConfigureDbContext = b =>\n b.UseSqlServer(connectionString, sql =>\n sql.MigrationsAssembly(migrationsAssembly));\n })\n .AddOperationalStore(options =>\n {\n options.ConfigureDbContext = b =>\n b.UseSqlServer(connectionString, sql =>\n sql.MigrationsAssembly(migrationsAssembly));\n\n options.EnableTokenCleanup = true;\n options.TokenCleanupInterval = 3600; // seconds; default 1 hour\n });\n```\n\n```csharp\n// ❌ Wrong: omitting MigrationsAssembly when migrations live in the host project\nbuilder.Services.AddIdentityServer()\n .AddConfigurationStore(options =>\n {\n options.ConfigureDbContext = b => b.UseSqlServer(connectionString);\n // EF will look for migrations in Duende.IdentityServer.EntityFramework.dll\n // and fail to find them\n });\n```\n\n### Separate Schemas\n\nIsolate configuration and operational tables using `DefaultSchema` to avoid naming collisions and simplify backup strategies:\n\n```csharp\n// ✅ Recommended for production: dedicated schemas per store\nbuilder.Services.AddIdentityServer()\n .AddConfigurationStore(options =>\n {\n options.DefaultSchema = \"idscfg\";\n options.ConfigureDbContext = b =>\n b.UseSqlServer(connectionString, sql =>\n {\n sql.MigrationsAssembly(migrationsAssembly);\n sql.MigrationsHistoryTable(\"__ConfigMigrationsHistory\", \"idscfg\");\n });\n })\n .AddOperationalStore(options =>\n {\n options.DefaultSchema = \"idsop\";\n options.ConfigureDbContext = b =>\n b.UseSqlServer(connectionString, sql =>\n {\n sql.MigrationsAssembly(migrationsAssembly);\n sql.MigrationsHistoryTable(\"__OperationalMigrationsHistory\", \"idsop\");\n });\n });\n```\n\n---\n\n## Migrations\n\nEF Core migrations must be created in the host assembly. IdentityServer does not generate or apply migrations automatically.\n\n### Creating Migrations\n\n```shell\n# Configuration store migration\ndotnet ef migrations add InitialIdentityServerConfigurationDb \\\n --context ConfigurationDbContext \\\n --output-dir Data/Migrations/IdentityServer/ConfigurationDb\n\n# Operational store migration\ndotnet ef migrations add InitialIdentityServerOperationalDb \\\n --context PersistedGrantDbContext \\\n --output-dir Data/Migrations/IdentityServer/OperationalDb\n```\n\n### Applying Migrations at Startup\n\n```csharp\n// ✅ Apply EF migrations on startup (suitable for dev/staging; use a deploy pipeline in production)\npublic static void InitializeDatabase(IApplicationBuilder app)\n{\n using var serviceScope = app.ApplicationServices\n .GetRequiredService<IServiceScopeFactory>()\n .CreateScope();\n\n serviceScope.ServiceProvider\n .GetRequiredService<PersistedGrantDbContext>()\n .Database\n .Migrate();\n\n var configContext = serviceScope.ServiceProvider\n .GetRequiredService<ConfigurationDbContext>();\n configContext.Database.Migrate();\n\n // Seed initial configuration data if empty\n if (!configContext.Clients.Any())\n {\n foreach (var client in Config.Clients)\n configContext.Clients.Add(client.ToEntity());\n configContext.SaveChanges();\n }\n}\n```\n\n### Handling Schema Updates Across Versions\n\nWhen upgrading IdentityServer, always check the [upgrade guide](https://docs.duendesoftware.com/identityserver/upgrades/) for schema changes before applying the new package version:\n\n1. Review the changelog for any new columns or tables in `ConfigurationDbContext` or `PersistedGrantDbContext`.\n2. Scaffold a new EF migration: `dotnet ef migrations add UpgradeToV7x --context ConfigurationDbContext`.\n3. Review the generated migration SQL — especially for columns with `NOT NULL` constraints that require backfill.\n4. Apply to a staging environment and validate before production.\n\n```csharp\n// ❌ Never auto-apply migrations in production startup without a health gate\n// This causes downtime on multi-instance deployments where one instance\n// applies the migration while others still run against the old schema\napp.ApplicationServices.GetRequiredService<ConfigurationDbContext>()\n .Database.Migrate(); // Dangerous in multi-node deployments\n```\n\n---\n\n## Caching Configuration Data\n\nConfiguration data (clients, resources, CORS) is read on every token request. Without caching, every request hits the database.\n\n### EF Store Caching (Recommended)\n\n```csharp\n// ✅ Enable cache for the EF configuration store (v8: uses HybridCache)\nbuilder.Services.AddIdentityServer()\n .AddConfigurationStore(options => { /* ... */ })\n .AddConfigurationStoreCache(); // wraps EF stores with HybridCache\n```\n\n`AddConfigurationStoreCache()` wraps each configuration store with a caching decorator backed by Microsoft `HybridCache`. Cache expiration is controlled through `IdentityServerOptions.Caching`:\n\n```csharp\nbuilder.Services.AddIdentityServer(options =>\n{\n options.Caching.ClientStoreExpiration = TimeSpan.FromMinutes(5);\n options.Caching.ResourceStoreExpiration = TimeSpan.FromMinutes(5);\n options.Caching.CorsExpiration = TimeSpan.FromMinutes(5);\n options.Caching.IdentityProviderCacheDuration = TimeSpan.FromMinutes(60);\n})\n .AddConfigurationStore(options => { /* ... */ })\n .AddConfigurationStoreCache();\n```\n\n### Custom Store Caching\n\nWhen using a custom `IClientStore`, wrap it with the caching decorator explicitly:\n\n```csharp\n// ✅ Cache applied to a custom store implementation\nbuilder.Services.AddIdentityServer()\n .AddClientStore<MongoClientStore>()\n .AddResourceStore<MongoResourceStore>()\n .AddClientStoreCache<MongoClientStore>()\n .AddResourceStoreCache<MongoResourceStore>();\n```\n\n### Distributed Cache for Multi-Node Deployments\n\nIn-memory cache is node-local — a client update only invalidates the cache on the node where the change was made. For multi-node deployments, configure `HybridCache` with a distributed backend:\n\n```csharp\n// ✅ Configure HybridCache with Redis backend for multi-node scenarios\nbuilder.Services.AddHybridCache();\nbuilder.Services.AddStackExchangeRedisCache(options =>\n options.Configuration = builder.Configuration[\"Redis:ConnectionString\"]);\n\nbuilder.Services.AddIdentityServer()\n .AddConfigurationStore(options => { /* ... */ })\n .AddConfigurationStoreCache();\n```\n\n> **Note:** In v8, `ICache<T>` is replaced by Microsoft `HybridCache`. If you have custom `ICache<T>` implementations, migrate to `HybridCache` with keyed services (`ServiceProviderKeys.ConfigurationStoreCache`). See the `identityserver-upgrade-v7-to-v8` skill for migration patterns.\n> \n> After a client or resource update, explicitly evict the cache entry or wait for expiration. There is no built-in cache invalidation webhook.\n\n---\n\n## Custom Stores\n\nImplement custom stores when EF Core is unsuitable — for example, when client definitions live in an external system, or when operational data must be stored in Redis or a document database.\n\n### In-Memory Stores (Development Only)\n\nFor development and testing, in-memory stores avoid database setup entirely:\n\n```csharp\n// ✅ In-memory stores — development and testing only\nbuilder.Services.AddIdentityServer()\n .AddInMemoryClients(Config.Clients)\n .AddInMemoryApiScopes(Config.ApiScopes)\n .AddInMemoryApiResources(Config.ApiResources)\n .AddInMemoryIdentityResources(Config.IdentityResources);\n```\n\nIn-memory stores are created once at startup and cannot be updated at runtime without restarting the application. They do not survive restarts and should never be used for operational data in production.\n\n> **Version Note — CancellationToken parameters (v8+):**\n> The store interface signatures below include `CancellationToken` parameters, which were **added in Duende IdentityServer v8**. In **v7 and earlier**, these interfaces do **not** accept `CancellationToken` — omit the parameter when targeting v7. Additionally, `IClientStore.GetAllClientsAsync` is a **new method in v8**; it does not exist in v7.\n\n### `IClientStore`\n\n```csharp\n// ✅ Custom client store reading from an external API\npublic sealed class ExternalApiClientStore : IClientStore\n{\n private readonly IExternalClientApi _api;\n\n public ExternalApiClientStore(IExternalClientApi api)\n => _api = api;\n\n public async Task<Client?> FindClientByIdAsync(string clientId, CancellationToken ct = default)\n {\n var dto = await _api.GetClientAsync(clientId);\n return dto is null ? null : dto.ToIdentityServerClient();\n }\n\n public async IAsyncEnumerable<Client> GetAllClientsAsync(CancellationToken ct = default)\n {\n await foreach (var dto in _api.GetAllClientsAsync(ct))\n yield return dto.ToIdentityServerClient();\n }\n}\n```\n\n```csharp\n// ✅ Registration — use helper method, not AddTransient directly\nbuilder.Services.AddIdentityServer()\n .AddClientStore<ExternalApiClientStore>();\n```\n\n### `IResourceStore`\n\n```csharp\n// ✅ Custom resource store — must implement all five query methods\npublic sealed class DatabaseResourceStore : IResourceStore\n{\n private readonly ResourceRepository _repo;\n\n public DatabaseResourceStore(ResourceRepository repo) => _repo = repo;\n\n public Task<IEnumerable<IdentityResource>> FindIdentityResourcesByScopeNameAsync(\n IEnumerable<string> scopeNames, CancellationToken ct = default)\n => _repo.GetIdentityResourcesAsync(scopeNames);\n\n public Task<IEnumerable<ApiScope>> FindApiScopesByNameAsync(\n IEnumerable<string> scopeNames, CancellationToken ct = default)\n => _repo.GetApiScopesAsync(scopeNames);\n\n public Task<IEnumerable<ApiResource>> FindApiResourcesByScopeNameAsync(\n IEnumerable<string> scopeNames, CancellationToken ct = default)\n => _repo.GetApiResourcesByScopeAsync(scopeNames);\n\n public Task<IEnumerable<ApiResource>> FindApiResourcesByNameAsync(\n IEnumerable<string> apiResourceNames, CancellationToken ct = default)\n => _repo.GetApiResourcesByNameAsync(apiResourceNames);\n\n public Task<Resources> GetAllResourcesAsync(CancellationToken ct = default)\n => _repo.GetAllAsync();\n}\n```\n\n### `IPersistedGrantStore`\n\n```csharp\n// ✅ Custom persisted grant store — all methods must be implemented\npublic sealed class RedisPersistedGrantStore : IPersistedGrantStore\n{\n private readonly IDatabase _redis;\n\n public RedisPersistedGrantStore(IConnectionMultiplexer mux)\n => _redis = mux.GetDatabase();\n\n public async Task StoreAsync(PersistedGrant grant, CancellationToken ct = default)\n {\n var json = JsonSerializer.Serialize(grant);\n var expiry = grant.Expiration.HasValue\n ? grant.Expiration.Value - DateTimeOffset.UtcNow\n : TimeSpan.FromDays(30);\n await _redis.StringSetAsync(grant.Key, json, expiry);\n }\n\n public async Task<PersistedGrant?> GetAsync(string key, CancellationToken ct = default)\n {\n var value = await _redis.StringGetAsync(key);\n return value.IsNull ? null : JsonSerializer.Deserialize<PersistedGrant>(value!);\n }\n\n public async Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter, CancellationToken ct = default)\n {\n // Redis requires a secondary index (e.g., SET per subjectId) for filtered queries\n // Implementation depends on your indexing strategy\n throw new NotImplementedException(\"Implement with a subject-keyed index\");\n }\n\n public Task RemoveAsync(string key, CancellationToken ct = default)\n => _redis.KeyDeleteAsync(key);\n\n public Task RemoveAllAsync(PersistedGrantFilter filter, CancellationToken ct = default)\n {\n // Requires secondary index lookup\n throw new NotImplementedException(\"Implement with a subject-keyed index\");\n }\n}\n```\n\n```csharp\n// ✅ Registration for custom operational stores — register directly, not through builder helpers\nbuilder.Services.AddIdentityServer();\nbuilder.Services.AddTransient<IPersistedGrantStore, RedisPersistedGrantStore>();\nbuilder.Services.AddTransient<IDeviceFlowStore, YourCustomDeviceFlowStore>();\n```\n\n---\n\n## Server-Side Sessions Store\n\nServer-side sessions (added in IdentityServer 6.1) keep authentication session data server-side rather than in the cookie, enabling centralized session management, inactivity timeouts, and back-channel logout across all sessions for a user.\n\n### Enabling with EF Core\n\n```csharp\n// ✅ Server-side sessions backed by the EF operational store\nbuilder.Services.AddIdentityServer()\n .AddServerSideSessions() // must be called to enable the feature\n .AddOperationalStore(options =>\n {\n options.ConfigureDbContext = b =>\n b.UseSqlServer(connectionString, sql =>\n sql.MigrationsAssembly(migrationsAssembly));\n options.EnableTokenCleanup = true;\n });\n```\n\n### Custom `IServerSideSessionStore`\n\n```csharp\n// ✅ Custom server-side session store\nbuilder.Services.AddIdentityServer()\n .AddServerSideSessions<YourCustomSessionStore>();\n\n// Equivalent to:\nbuilder.Services.AddIdentityServer()\n .AddServerSideSessions()\n .AddServerSideSessionStore<YourCustomSessionStore>();\n```\n\nThe `IServerSideSessionStore` interface provides methods for `CreateSessionAsync`, `GetSessionAsync`, `UpdateSessionAsync`, `DeleteSessionAsync`, and bulk query/management methods used by session expiration and back-channel logout coordination. All methods must be implemented — there are no default no-op implementations.\n\n### Session Cleanup\n\nSession records accumulate over time. Token cleanup (`EnableTokenCleanup`) removes expired sessions from the EF operational store. For custom stores, you must implement your own cleanup background service.\n\n---\n\n## Signing Key Store\n\nDuende IdentityServer's automatic key management feature dynamically creates and rotates signing keys. Keys must be persisted across restarts and shared across nodes.\n\n### Default: File System\n\nThe default `ISigningKeyStore` persists keys to the file system. This is suitable for single-node deployments only:\n\n```csharp\n// ✅ File system key store (default) — single node only\nbuilder.Services.AddIdentityServer()\n .AddDeveloperSigningCredential(); // development only\n\n// For production single-node: nothing extra needed; file system is the default\n```\n\n### EF Core Key Store\n\n`AddOperationalStore()` automatically registers `ISigningKeyStore` against `PersistedGrantDbContext`:\n\n```csharp\n// ✅ EF-backed signing key store — required for multi-node deployments\nbuilder.Services.AddIdentityServer()\n .AddOperationalStore(options =>\n {\n options.ConfigureDbContext = b =>\n b.UseSqlServer(connectionString, sql =>\n sql.MigrationsAssembly(migrationsAssembly));\n });\n// ISigningKeyStore is now backed by PersistedGrantDbContext\n```\n\n### Custom `ISigningKeyStore`\n\n```csharp\n// ✅ Register a custom signing key store\nbuilder.Services.AddIdentityServer()\n .AddSigningKeyStore<YourCustomSigningKeyStore>();\n```\n\nThe `ISigningKeyStore` interface has three methods (CancellationToken parameters are v8+ only — see version note above):\n- `LoadKeysAsync(CancellationToken ct)` — returns all `SerializedKey` records; called on startup and periodically\n- `StoreKeyAsync(SerializedKey key, CancellationToken ct)` — persists a newly created key\n- `DeleteKeyAsync(string id, CancellationToken ct)` — removes a retired key\n\n### Data Protection Considerations\n\nThe `Data` property of `SerializedKey` may be encrypted via ASP.NET Core Data Protection (check `DataProtected == true`). When implementing a custom store:\n\n- **Do not re-encrypt** data returned from `LoadKeysAsync` — IdentityServer decrypts it internally.\n- **Ensure Data Protection keys are shared** across all nodes in a multi-node deployment. If node A encrypts a signing key and node B cannot decrypt it, token signing will fail.\n- Store Data Protection keys in a shared location (Azure Blob, SQL, Redis) and protect them with a shared certificate or key vault key.\n\n```csharp\n// ✅ Share Data Protection keys across nodes (Azure Blob + Key Vault example)\nbuilder.Services.AddDataProtection()\n .PersistKeysToAzureBlobStorage(/* blob container */)\n .ProtectKeysWithAzureKeyVault(/* key vault key id */);\n```\n\n---\n\n## Token Cleanup\n\nOperational data accumulates continuously. Without cleanup, the `PersistedGrants` table grows unbounded, degrading query performance.\n\n### Enabling Automatic Cleanup\n\n```csharp\n// ✅ Enable token cleanup in the EF operational store\nbuilder.Services.AddIdentityServer()\n .AddOperationalStore(options =>\n {\n options.ConfigureDbContext = b =>\n b.UseSqlServer(connectionString, sql =>\n sql.MigrationsAssembly(migrationsAssembly));\n\n options.EnableTokenCleanup = true;\n options.TokenCleanupInterval = 3600; // seconds; default 1 hour\n\n // Remove consumed one-time tokens (e.g., used refresh tokens with OneTime usage)\n options.RemoveConsumedTokens = true;\n options.ConsumedTokenCleanupDelay = 0; // seconds to wait before deleting consumed tokens\n\n // Fuzz startup time to reduce multi-node cleanup conflicts (default: true)\n options.FuzzTokenCleanupStart = true;\n });\n```\n\n### OperationalStoreOptions Reference\n\n| Option | Type | Default | Description |\n| --------------------------- | --------------------------------- | ------- | ---------------------------------------------------------------------------- |\n| `ConfigureDbContext` | `Action<DbContextOptionsBuilder>` | — | Configure the `PersistedGrantDbContext` |\n| `DefaultSchema` | `string` | — | Default database schema for operational tables |\n| `EnableTokenCleanup` | `bool` | `false` | Enable automatic cleanup of expired grants and pushed authorization requests |\n| `RemoveConsumedTokens` | `bool` | `false` | Also remove consumed grants during cleanup (added >= 5.1) |\n| `TokenCleanupInterval` | `int` | `3600` | Cleanup interval in seconds |\n| `TokenCleanupBatchSize` | `int` | `100` | Number of expired tokens removed per cleanup cycle |\n| `ConsumedTokenCleanupDelay` | `int` | `0` | Seconds to wait after consumption before cleaning up (added >= 6.3) |\n| `FuzzTokenCleanupStart` | `bool` | `true` | Randomize first cleanup run to avoid multi-instance conflicts (added >= 7.0) |\n\n### What Token Cleanup Removes\n\nThe `TokenCleanupService` removes:\n- Persisted grants where `Expiration < UtcNow`\n- Consumed tokens when `RemoveConsumedTokens = true` and `ConsumedTime + ConsumedTokenCleanupDelay < UtcNow`\n- Expired device flow codes\n- Expired pushed authorization requests\n- Expired server-side sessions\n\nIt does **not** remove:\n- Active (non-expired) refresh tokens that have been marked consumed — these are retained for threat detection unless `RemoveConsumedTokens = true`\n\n### Grant Lifecycle States\n\n| State | Meaning |\n| ----------------------------------------------------- | --------------------------------- |\n| Record exists, no `ConsumedTime`, within `Expiration` | Grant is valid |\n| `ConsumedTime` is set | Grant has been used (soft delete) |\n| Past `Expiration` | Grant is expired |\n| Record deleted | Grant is revoked |\n\nOne-time-use grants (authorization codes, optionally refresh tokens) use the consumption mechanism instead of immediate deletion to enable replay detection and grace periods. The `Data` property of persisted grants is the authoritative payload — other properties like `Created` and `Expiration` are read-only indices. Modifying index properties directly in the database will not change runtime behavior.\n\n### Multi-Node Cleanup Conflicts\n\nWhen multiple nodes all run cleanup at the same interval, they race to delete the same rows. `FuzzTokenCleanupStart = true` (the default) randomises the first cleanup run within the interval window. For very high-scale deployments, consider disabling cleanup on all nodes and running it as a dedicated background job:\n\n```csharp\n// ✅ Disable cleanup on web nodes; run in a dedicated worker service\n// In web node Program.cs:\noptions.EnableTokenCleanup = false;\n\n// In a dedicated worker:\npublic sealed class TokenCleanupWorker : BackgroundService\n{\n private readonly TokenCleanupService _cleanup;\n\n public TokenCleanupWorker(TokenCleanupService cleanup) => _cleanup = cleanup;\n\n protected override async Task ExecuteAsync(CancellationToken stoppingToken)\n {\n while (!stoppingToken.IsCancellationRequested)\n {\n await _cleanup.CleanupGrantsAsync();\n await Task.Delay(TimeSpan.FromHours(1), stoppingToken);\n }\n }\n}\n```\n\n---\n\n## Persisted Grant Service\n\nFor higher-level programmatic access to grants (e.g., building an admin UI or user consent management page), use `IPersistedGrantService` rather than querying `IPersistedGrantStore` directly:\n\n```csharp\n// ✅ Query and revoke grants via the high-level service\npublic sealed class GrantManagementService\n{\n private readonly IPersistedGrantService _grantService;\n\n public GrantManagementService(IPersistedGrantService grantService)\n => _grantService = grantService;\n\n public async Task<IEnumerable<Grant>> GetUserGrantsAsync(string subjectId)\n => await _grantService.GetAllGrantsAsync(subjectId);\n\n public async Task RevokeClientGrantsAsync(string subjectId, string clientId)\n => await _grantService.RemoveAllGrantsAsync(subjectId, clientId);\n}\n```\n\nThis service abstracts and aggregates different grant types (authorization codes, refresh tokens, reference tokens, consent) into a unified API. It is the recommended way to implement user-facing grant/consent management rather than querying the low-level `IPersistedGrantStore`.\n\n---\n\n## Multi-Tenant Patterns\n\nMulti-tenant IdentityServer deployments require careful consideration of store boundaries.\n\n### Shared Database (Recommended for Most Cases)\n\nA single `ConfigurationDbContext` and `PersistedGrantDbContext` shared across all tenants. Tenant isolation is enforced at the application layer by scoping queries to a `TenantId` column.\n\n```csharp\n// ✅ Shared database with tenant-scoped custom stores\npublic sealed class TenantAwareClientStore : IClientStore\n{\n private readonly AppDbContext _db;\n private readonly ITenantContext _tenantContext;\n\n public TenantAwareClientStore(AppDbContext db, ITenantContext tenantContext)\n {\n _db = db;\n _tenantContext = tenantContext;\n }\n\n public async Task<Client?> FindClientByIdAsync(string clientId)\n {\n var entity = await _db.Clients\n .Where(c => c.TenantId == _tenantContext.TenantId && c.ClientId == clientId)\n .FirstOrDefaultAsync();\n return entity?.ToIdentityServerClient();\n }\n}\n```\n\n### Database-per-Tenant\n\nEach tenant gets its own connection string and EF `DbContext` instance. This provides the strongest data isolation, is appropriate for compliance requirements (GDPR data residency, SOC2 segmentation), and simplifies tenant offboarding.\n\n```csharp\n// ✅ Database-per-tenant using a factory pattern for the DbContext\nbuilder.Services.AddIdentityServer()\n .AddClientStore<TenantRoutingClientStore>();\n\npublic sealed class TenantRoutingClientStore : IClientStore\n{\n private readonly IDbContextFactory<ConfigurationDbContext> _factory;\n private readonly ITenantConnectionStringProvider _connectionStrings;\n private readonly ITenantContext _tenantContext;\n\n public TenantRoutingClientStore(\n IDbContextFactory<ConfigurationDbContext> factory,\n ITenantConnectionStringProvider connectionStrings,\n ITenantContext tenantContext)\n {\n _factory = factory;\n _connectionStrings = connectionStrings;\n _tenantContext = tenantContext;\n }\n\n public async Task<Client?> FindClientByIdAsync(string clientId)\n {\n var connStr = await _connectionStrings.GetAsync(_tenantContext.TenantId);\n var options = new DbContextOptionsBuilder<ConfigurationDbContext>()\n .UseSqlServer(connStr)\n .Options;\n\n await using var ctx = new ConfigurationDbContext(options, new ConfigurationStoreOptions());\n var entity = await ctx.Clients\n .Include(c => c.AllowedScopes)\n .Include(c => c.RedirectUris)\n .FirstOrDefaultAsync(c => c.ClientId == clientId);\n\n return entity?.ToModel();\n }\n}\n```\n\n```csharp\n// ❌ Avoid: sharing PersistedGrantDbContext across tenants without tenant isolation\n// A token issued for tenant A can be looked up by tenant B's store — a security boundary violation\nbuilder.Services.AddOperationalStore(options =>\n{\n options.ConfigureDbContext = b => b.UseSqlServer(sharedConnectionString);\n // No tenant filtering applied — all tenants share the same grant store\n});\n```\n\n---\n\n## Store Implementation Decision Matrix\n\n| Scenario | Recommendation |\n| --------------------------------------------- | -------------------------------------------------- |\n| Prototyping / local development | In-memory stores (`AddInMemory*`) |\n| Small deployment, rare config changes | In-memory stores loaded from config files |\n| Production with relational database | EF Core stores with `AddConfigurationStoreCache()` |\n| High-traffic production | EF Core stores + caching + tuned cleanup intervals |\n| Non-relational database (Redis, Cosmos, etc.) | Custom store implementations |\n| SaaS with dynamic configuration | EF Core or custom stores with API for management |\n\n---\n\n## Common Pitfalls\n\n**Missing `MigrationsAssembly`** — The most common EF setup error. When migrations live in the host project (not in `Duende.IdentityServer.EntityFramework`), you must call `sql.MigrationsAssembly(migrationsAssembly)`. Without this, `dotnet ef migrations add` and runtime startup fail.\n\n**Calling `AddConfigurationStoreCache()` without `AddInMemoryCaching()`** — `AddConfigurationStoreCache()` wraps the EF stores automatically and includes its own `IMemoryCache` registration. `AddInMemoryCaching()` is needed when you are manually registering caching decorators on custom stores with `AddClientStoreCache<T>()`.\n\n**In-memory caching in multi-node deployments** — The default `IMemoryCache`-backed cache is node-local. If you update a client configuration and one node caches the old value, token requests on that node will use the stale configuration until the cache expires. Use a distributed cache (`IDistributedCache`) to share cache state, or set a short expiration and accept eventual consistency.\n\n**Not enabling server-side sessions before the operational store** — `AddServerSideSessions()` must be called before or alongside `AddOperationalStore()`. Reversing the order or omitting `AddServerSideSessions()` means session data is never persisted, and session management features silently degrade.\n\n**Assuming `EnableTokenCleanup = true` removes consumed tokens** — By default, consumed tokens are not cleaned up. You must also set `RemoveConsumedTokens = true`. Consumed tokens from one-time-use refresh token flows will otherwise accumulate indefinitely.\n\n**Rotating Data Protection keys without migrating encrypted grant data** — Signing keys and grant payloads encrypted with an old Data Protection key become unreadable after key rotation. Always keep retired Data Protection keys available for decryption for at least as long as the longest-lived grant (typically refresh token lifetime).\n\n**Running EF migrations in multi-instance startup** — Calling `Database.Migrate()` in `Program.cs` on every startup causes migration races in multi-node deployments. Run migrations as a deployment pre-step (e.g., a Kubernetes init container or a CI/CD migration job), not in the application startup path.\n\n**Using in-memory stores in production** — `AddInMemoryClients()`, `AddInMemoryApiResources()`, etc. are designed for development and testing only. In-memory stores cannot be updated at runtime without restarting the application and do not survive restarts.\n\n---\n\n## Resources\n\n- [Data Stores & Persistence overview](https://docs.duendesoftware.com/identityserver/data/) — authoritative top-level docs\n- [Configuration Data](https://docs.duendesoftware.com/identityserver/data/configuration/) — store interfaces, custom registration, caching, in-memory stores\n- [Operational Data](https://docs.duendesoftware.com/identityserver/data/operational/) — grants, signing keys, server-side sessions, custom store registration\n- [EF Core Integration](https://docs.duendesoftware.com/identityserver/data/ef/) — `AddConfigurationStore`, `AddOperationalStore`, `OperationalStoreOptions`, schema options, token cleanup options\n- [EF Quickstart](https://docs.duendesoftware.com/identityserver/quickstarts/4-entity-framework/) — end-to-end walkthrough including migration creation\n- [ISigningKeyStore reference](https://docs.duendesoftware.com/identityserver/reference/stores/signing-key-store/)\n- [IServerSideSessionStore reference](https://docs.duendesoftware.com/identityserver/reference/stores/server-side-sessions/)\n- [IPersistedGrantStore reference](https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/)\n- [Key Management fundamentals](https://docs.duendesoftware.com/identityserver/fundamentals/key-management/)\n- [Server-Side Sessions overview](https://docs.duendesoftware.com/identityserver/ui/server-side-sessions/)\n- [Duende EF migrations sample](https://github.com/DuendeSoftware/products/tree/main/identity-server/migrations/IdentityServerDb) — reference SQL Server migration project maintained by Duende\n- Related skill: `identityserver-configuration` — client and resource model configuration\n- Related skill: `efcore-patterns` — EF Core best practices applicable to `ConfigurationDbContext` and `PersistedGrantDbContext`\n- Related skill: `database-performance` — indexing, query optimization for high-write operational tables\n"
}SHA-256: 51e51e887be310d2d45dd12bf63ad30630feb76a7315de16e3215cd2be30ebdd