Duende IdentityServer Production Deployment Checklist
Summary: Moving Duende IdentityServer from development to production requires explicit configuration to handle multi-instance environments, load balancing, and data persistence. Key configuration areas include setting up persistent, shared storage for ASP.NET Core Data Protection keys and signing keys (using the operational database), ensuring HTTPS is enforced with proper HSTS and reverse proxy settings (ForwardedHeaders middleware), and managing the database by running migrations outside of application startup and enabling token cleanup. Additionally, production readiness involves configuring robust logging/monitoring (OpenTelemetry, health checks), setting explicit CORS policies, and reviewing token lifetimes based on the application's threat model. A multi-instance deployment checklist confirms that all critical components rely on a shared, durable state like Redis or a common database.
You've built your instance of Duende IdentityServer, tested it locally, and it works. The login flow is clean, tokens are issued, and your APIs validate them correctly. But production is a different beast. In development, ASP.NET Core quietly generates ephemeral encryption keys, your single process handles every request, and your database is a warm SQLite file on your laptop. Ship that same configuration to production, and you're looking at a CryptographicException on first deploy, broken redirect URIs behind a load balancer, and an operational database that grows out of bounds.
This post walks through every configuration area you need to address before going live. Each section includes the "why it breaks" explanation alongside concrete C# code. At the bottom is a concise checklist you can print, post, and use to gate your merges.
1. ASP.NET Core Data Protection
Duende IdentityServer relies heavily on ASP.NET Core's Data Protection stack. It encrypts signing keys at rest, protects state parameters for external OIDC providers, signs server-side session data, and secures anti-forgery tokens and authentication cookies. Unfortunately, many factors can determine the default behavior of Data Protection, including the operating system, hosting environment, and more. In general, when developing a new application, Data Protection generates keys "locally", so unless steps are taken, the keys are never shared between instances.
What breaks if you skip this: Every restart invalidates all protected data. In a load-balanced environment, requests routed to the wrong instance throw CryptographicException: key not found in key ring. Signing key rotation silently fails.
Configure persistent, shared key storage in Program.cs:
Csharp
// Using Azure Blob Storage + Azure Key Vault for key protection (recommended for Azure deployments)
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(
new Uri("https://mystorageaccount.blob.core.windows.net/dataprotection/keys.xml"),
new DefaultAzureCredential())
.ProtectKeysWithAzureKeyVault(
new Uri("https://mykeyvault.vault.azure.net/keys/dataprotection-key"),
new DefaultAzureCredential())
.SetApplicationName("IdentityServer");Csharp
// Using Redis (common in Kubernetes/on-premises deployments)
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = connectionString;
});
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(ConnectionMultiplexer.Connect(connectionString), "DataProtection-Keys")
.SetApplicationName("IdentityServer");Csharp
// Using SQL Server via Entity Framework Core
builder.Services.AddDataProtection()
.PersistKeysToDbContext<ApplicationDbContext>()
.SetApplicationName("IdentityServer"); Always call .SetApplicationName("IdentityServer"). Without an explicit name, ASP.NET Core derives the name from the app's content root path, which can differ between machines and deployments and will silently break key sharing. While out of scope for this post, we also recommend encrypting any keys at rest using the ProtectKeysWithCertificate method, as described in our data protection documentation.
If you use Redis for key persistence, configure Redis with AOF (Append-Only File) or RDB (Redis Database) persistence. If Redis loses its data on restart, you lose your keys.
For developers deploying to a Kubernetes cluster or a containerized environment, you may want to consider a shared volume to store your shared state. This ensures instances can access the critical resources needed to continue working.
2. Signing Key Management
Duende IdentityServer's automatic key management is the right choice for production. It generates, rotates, and retires asymmetric signing keys without downtime, eliminating manual certificate juggling and scheduled restarts. The catch: the default storage is the local file system, which doesn't work in multi-instance or containerized deployments.
Configure the operational store (which includes ISigningKeyStore) to use your database:
Csharp
builder.Services.AddIdentityServer()
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString, sql =>
sql.MigrationsAssembly(typeof(Program).Assembly.FullName));
// Clean up expired tokens and codes automatically
options.EnableTokenCleanup = true;
options.TokenCleanupInterval = 3600; // seconds
}); Then tune the key rotation schedule via KeyManagementOptions:
Csharp
builder.Services.AddIdentityServer(options =>
{
// How long a key is active before rotation begins
options.KeyManagement.RotationInterval = TimeSpan.FromDays(90);
// How long before expiry to generate the next key (overlap window)
options.KeyManagement.PropagationTime = TimeSpan.FromDays(14);
// How long to keep retired keys to validate tokens already issued
options.KeyManagement.RetentionDuration = TimeSpan.FromDays(14);
// Protect signing keys at rest using Data Protection
options.KeyManagement.SigningAlgorithms = new[]
{
new SigningAlgorithmOptions("RS256")
};
});Signing keys stored in the operational database are protected at rest by the Data Protection stack, which is another reason correct Data Protection configuration is non-negotiable.
Never commit the ~/keys directory to source control. If you used a file-system key store during development, add it to .gitignore before moving to production.
3. HTTPS Everywhere
This one is table stakes, but the specifics matter. You need to enforce HTTPS at the application level and configure HSTS so browsers remember it.
Csharp
// Program.cs: configure HTTPS and HSTS
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
options.Preload = true;
});
// In the middleware pipeline (order matters)
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection(); If TLS is terminated at the load balancer or reverse proxy (the common pattern), Kestrel only sees HTTP internally. That's fine, but you must configure ForwardedHeaders (in the next section) so Duende IdentityServer knows that the original request was HTTPS. Without it, the discovery document publishes an http:// issuer URL, and every client and API that validates tokens will reject them.
4. Reverse Proxy Configuration
This is the most common production failure mode. When Duende IdentityServer sits behind Nginx, Azure Application Gateway, AWS ALB, or any TLS-terminating proxy, it receives requests on plain HTTP with the original scheme, host, and client IP hidden in forwarded headers.
Without a properly configured ForwardedHeaders middleware, Duende IdentityServer builds redirect URIs, the issuer URL in the discovery document, and authentication cookies based on what Kestrel sees, which is http://localhost or an internal IP.
The fix:
Csharp
// Program.cs
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost;
// Restrict to your actual proxy IPs. Do NOT leave KnownNetworks empty in production
options.KnownProxies.Add(IPAddress.Parse("10.0.0.1")); // your load balancer IP
// If running in Kubernetes with a known pod CIDR:
// options.KnownNetworks.Add(new IPNetwork(IPAddress.Parse("10.244.0.0"), 16));
options.ForwardLimit = 1; // set to the number of proxies in your chain
}); Critical: ForwardedHeaders middleware must be registered before all other middleware, including UseIdentityServer(), UseAuthentication(), and UseAuthorization().
Csharp
// Middleware order: ForwardedHeaders FIRST
app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();For cloud-hosted environments (Azure App Service, Kubernetes with a single ingress), the environment variable shortcut works well. Cloud-based environments make it more challenging to configure the known proxy servers or networks, since the list of IP networks or proxy servers can change every week.
Bash
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true After deploying, verify the discovery document at /.well-known/openid-configuration shows an https:// issuer. If it shows http://, ForwardedHeaders is not working. We've also seen folks experience intermittent success/failure, so now is a good time to double-check your known proxies and networks.
5. Database: Migrations, Pooling, and Cleanup
If you're using Entity Framework Core for configuration or operational stores, run migrations as part of your deployment pipeline rather than at startup. Applying migrations at application startup introduces race conditions in multi-instance deployments and embeds a DbContext dependency in your startup path.
Csharp
// Apply migrations from a deployment script or startup job, NOT Program.cs Main
// Separate migration application:
using var scope = app.Services.CreateScope();
var configDb = scope.ServiceProvider.GetRequiredService<ConfigurationDbContext>();
await configDb.Database.MigrateAsync();
var operationalDb = scope.ServiceProvider.GetRequiredService<PersistedGrantDbContext>();
await operationalDb.Database.MigrateAsync();Configure connection pooling explicitly to avoid exhaustion under load:
Csharp
builder.Services.AddDbContext<ConfigurationDbContext>(options =>
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsAssembly(typeof(Program).Assembly.FullName);
sql.EnableRetryOnFailure(maxRetryCount: 5);
}));Enable the token cleanup job to prevent unbounded operational database growth:
Csharp
builder.Services.AddIdentityServer()
.AddOperationalStore(options =>
{
options.EnableTokenCleanup = true;
options.TokenCleanupInterval = 3600; // every hour
options.TokenCleanupBatchSize = 100; // rows per batch
}); Without cleanup, the PersistedGrants table accumulates all authorization codes, refresh tokens, and consent records ever issued. Give it a few months in production, and you'll notice query performance degrading.
6. Logging and Monitoring
Duende IdentityServer writes structured logs under the Duende.IdentityServer category. In production, set the default log level to Warning. The Information level and below generate significant volume and can surface sensitive request data.
Json
// appsettings.Production.json
{
"Logging": {
"LogLevel": {
"Default": "Warning",
"Duende.IdentityServer": "Warning",
"Microsoft.AspNetCore": "Warning"
}
}
}Wire up OpenTelemetry for distributed tracing and metrics:
Csharp
builder.Logging.AddOpenTelemetry();
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService(builder.Environment.ApplicationName))
.WithMetrics(m => m
.AddMeter("Duende.IdentityServer")
.AddPrometheusExporter())
.WithTracing(t => t
.AddSource(IdentityServerConstants.Tracing.Basic)
.AddAspNetCoreInstrumentation()
.AddOtlpExporter());
app.UseOpenTelemetryPrometheusScrapingEndpoint(); In production, use only IdentityServerConstants.Tracing.Basic to start. The Stores, Validation, and Cache trace sources are invaluable for debugging but generate high trace volume.
Expose health check endpoints so your orchestrator (Kubernetes, Azure App Service, etc.) can detect unhealthy instances:
Csharp
builder.Services.AddHealthChecks()
.AddDbContextCheck<ConfigurationDbContext>("configuration-db")
.AddDbContextCheck<PersistedGrantDbContext>("operational-db");
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = check => check.Tags.Contains("ready")
});
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false // liveness: just proves the process is running
});Enable Duende IdentityServer's events system for high-level operation auditing (login success/failure, token issuance, consent):
Csharp
builder.Services.AddIdentityServer(options =>
{
options.Events.RaiseSuccessEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = false; // skip for lower volume
});7. CORS
If you have SPA clients (React, Angular, Blazor WASM) calling Duende IdentityServer endpoints directly, such as the authorize, token, or userinfo endpoints, you need CORS configured. Duende IdentityServer includes CORS support tied to its client configuration.
Register the allowed origins on each client:
Csharp
new Client
{
ClientId = "spa-client",
AllowedGrantTypes = GrantTypes.Code,
AllowedCorsOrigins = { "https://app.example.com" },
// ...
}⚠️Warning: Never use a wildcard (*) forAllowedCorsOriginsin production. Wildcards allow any origin to make cross-origin requests to your token endpoint, creating a meaningful attack surface for credential exfiltration via CSRF or clickjacking.
If you also need a custom CORS policy elsewhere in the pipeline, ensure it does not conflict with Duende IdentityServer's built-in CORS handling:
Csharp
builder.Services.AddCors(options =>
{
options.AddPolicy("api", policy =>
{
policy.WithOrigins("https://app.example.com")
.AllowAnyHeader()
.AllowAnyMethod();
});
});8. Token Lifetimes
The default token lifetimes in Duende IdentityServer are deliberately conservative, but review them against your application's actual threat model. Shorter lifetimes reduce the blast radius of a leaked token.
Csharp
new Client
{
ClientId = "web-app",
AllowedGrantTypes = GrantTypes.Code,
// Access tokens: short-lived; APIs revalidate on every request
AccessTokenLifetime = 300, // 5 minutes (default: 3600)
// Refresh tokens: balance UX against risk
AbsoluteRefreshTokenLifetime = 86400, // 24 hours
SlidingRefreshTokenLifetime = 3600, // 1 hour
RefreshTokenUsage = TokenUsage.ReUse,
RefreshTokenExpiration = TokenExpiration.Sliding,
// ID tokens: short, they're only used at login time
IdentityTokenLifetime = 300,
}For machine-to-machine clients using client credentials, access tokens can be slightly longer since there's no user session to protect:
Csharp
new Client
{
ClientId = "worker-service",
AllowedGrantTypes = GrantTypes.ClientCredentials,
AccessTokenLifetime = 3600, // 1 hour is reasonable for M2M
}Keep server-side session lifetimes in sync with your access token and refresh token lifetimes:
Csharp
builder.Services.AddIdentityServer(options =>
{
options.ServerSideSessions.UserDisplayNameClaimType = "name";
options.Authentication.CookieLifetime = TimeSpan.FromHours(8);
options.Authentication.CookieSlidingExpiration = true;
});9. Multi-Instance Deployment
Running multiple Duende IdentityServer instances behind a load balancer requires deliberate shared-state configuration. The rule is simple: anything that processes or validates a request on any instance must be readable by all instances.
Share across all instances:
| Component | How to Share |
|---|---|
| Data Protection keys | Redis, Azure Blob, SQL (configured via .PersistKeysTo...()) |
| Signing keys | EF Core operational store (AddOperationalStore) |
| Persisted grants (tokens, codes) | EF Core operational store |
| Configuration data | EF Core configuration store or shared config service |
| Distributed cache (for PAR, device flow) | Redis via AddStackExchangeRedisCache |
Do not rely on per-instance memory for:
- In-memory grant stores: they won't see grants created by other instances
- File-system signing key stores: each instance has its own key; tokens from one instance fail validation on another
- In-memory client/resource stores with dynamic data: changes on one instance won't propagate
Csharp
// Complete multi-instance configuration sketch
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys")
.SetApplicationName("IdentityServer");
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = redisConnectionString;
});
builder.Services.AddIdentityServer()
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = b => b.UseSqlServer(connectionString);
})
.AddConfigurationStoreCache() // L1 memory cache on top of the DB
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b => b.UseSqlServer(connectionString);
options.EnableTokenCleanup = true;
}); .AddConfigurationStoreCache() adds a short-lived in-memory cache on top of the database-backed configuration store. This dramatically reduces database load for read-heavy client/scope lookups while still using the database as the source of truth.
Production Deployment Checklist
Before you flip the switch, run through these:
Data Protection
- ✅ Data Protection keys persist to durable, shared storage (Azure Blob, Redis, SQL)
- ✅
.SetApplicationName("IdentityServer")is configured - ✅ Keys are protected at rest (Azure Key Vault, certificate)
- ✅ Redis persistence (RDB/AOF) enabled if using Redis for key storage
Signing Keys
- ✅ Signing keys stored in EF operational store (not local file system)
- ✅ Key rotation intervals configured via
KeyManagementOptions - ✅
~/keysdirectory excluded from source control and container images
HTTPS & TLS
- ✅ HSTS configured with
MaxAgeof at least 1 year - ✅
UseHttpsRedirection()is in the pipeline - ✅ Discovery document issuer shows
https://in production
Reverse Proxy
- ✅
ForwardedHeadersOptionsconfigured with known proxy IPs - ✅
UseForwardedHeaders()is the first middleware in the pipeline - ✅ Verified discovery document issuer is the public HTTPS URL
Database
- ✅ EF Core migrations applied via CI/CD pipeline (not at startup)
- ✅ Token cleanup job enabled (
EnableTokenCleanup = true) - ✅ Connection string uses production credentials (not development defaults)
- ✅ Connection resilience/retry configured for transient failures
Logging & Monitoring
- ✅ Log level set to
Warningor above for production - ✅ OpenTelemetry configured with metrics and tracing
- ✅ Health check endpoints exposed (
/health/ready,/health/live) - ✅ Duende IdentityServer events enabled for audit trail
Security
- ✅ CORS configured with explicit allowed origins (no wildcards)
- ✅ Access token lifetimes reviewed and shortened where appropriate
- ✅ Refresh token expiration and usage policies set
- ✅ Client secrets stored in secrets manager (not
appsettings.json)
Multi-Instance
- ✅ EF operational store used (not in-memory grant store)
- ✅ Distributed cache (Redis) configured for PAR, device flow, and OIDC state
- ✅
.AddConfigurationStoreCache()enabled for read performance - ✅ All instances point to the same database and Redis cluster
Wrap-Up
None of these items is exotic. They're the production basics that ASP.NET Core and Duende IdentityServer expect you to wire up yourself. The framework defaults are deliberately lightweight, so development is frictionless; production requires explicit configuration of every item on that checklist.
For deeper coverage of each area, the Duende documentation is the right starting point:
- Deployment overview: reverse proxy, data protection, multi-instance
- Key management: automatic rotation, custom key stores
- EF Core stores: migrations, configuration store, operational store
- OpenTelemetry: metrics, traces, and log correlation
Build and ship with confidence, but verify the checklist first.