Introducing the next era of Duende IdentityServer.

Read our CEO’s announcement

Duende IdentityServer Production Deployment Checklist

Two blue circles
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 (*) for AllowedCorsOrigins in 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
  • ~/keys directory excluded from source control and container images

HTTPS & TLS

  • ✅ HSTS configured with MaxAge of at least 1 year
  • UseHttpsRedirection() is in the pipeline
  • ✅ Discovery document issuer shows https:// in production

Reverse Proxy

  • ForwardedHeadersOptions configured 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 Warning or 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:

Build and ship with confidence, but verify the checklist first.

Related Articles