Identity Security Hardening

by DuendeSoftwarefb32edc51982No license9 starsListed Oct 8, 2026Updated Oct 8, 2026Repository updated 4 weeks ago

Security hardening for Duende IdentityServer deployments including signing key rotation, HTTPS enforcement, CORS configuration, CSP headers, rate limiting, token lifetime tuning, and security audit patterns.

Instructions onlySecurity
AI-generated overview

Hardens Duende IdentityServer deployments with HTTPS, key rotation, token, PKCE, secret and redirect URI security guidance.

What it does
Provides prescriptive hardening guidance for Duende IdentityServer deployments, covering transport security (HTTPS, HSTS, TLS, forwarded headers), signing key algorithm choice and rotation, token lifetime tuning, reference tokens, audience validation, PKCE enforcement, client secret management, and redirect URI validation. It includes annotated C# configuration examples, a hierarchy of client authentication strength, and audit-oriented checklists. Three sub-documents add CORS and CSP configuration, rate limiting, and session hardening detail.
When to use it
Use it when preparing an IdentityServer deployment for production, auditing an existing setup against OAuth 2.0 security best current practice, or configuring HTTPS, PKCE, client secrets, token lifetimes, signing key rotation, CORS, CSP, rate limiting, or session behavior.
Requirements
No scripts; instructions and documentation only. Applying the guidance assumes a Duende IdentityServer deployment on ASP.NET Core, with optional access to the referenced sub-documents and external vendor documentation.

Identity Security Hardening

When to Use This Skill

Use this skill when:

  • Hardening a Duende IdentityServer deployment before promoting to production
  • Configuring HTTPS, HSTS, and TLS requirements for the identity server host
  • Evaluating or enforcing client secret policies (shared secrets vs. certificates vs. private_key_jwt)
  • Setting PKCE requirements, restricting grant types, or locking down redirect URI validation
  • Configuring Content Security Policy (CSP) and CORS for IdentityServer UI pages and endpoints
  • Applying rate limiting to the token endpoint to protect against brute-force and enumeration attacks
  • Tuning token lifetimes, enabling reference tokens, or implementing token replay detection
  • Rotating signing keys or choosing between RS256 and ES256 algorithms
  • Hardening session lifetimes, idle timeouts, and back-channel logout behavior
  • Auditing an existing IdentityServer setup against OAuth 2.0 Security Best Current Practice (RFC 9700)

Core Principles

  1. HTTPS Everywhere — IdentityServer must only be reachable over HTTPS in production. Any HTTP request should be permanently redirected. HSTS with includeSubDomains and preload is the minimum bar.
  2. Reduce Token Blast Radius — Short access token lifetimes, reference tokens for sensitive APIs, and audience validation ensure that a stolen token can do minimal damage.
  3. PKCE is Non-Negotiable — Every authorization code flow client must use PKCE, regardless of whether it is a public or confidential client. RequirePkce = true is the default; never disable it.
  4. Asymmetric Client Authentication — Prefer certificate-based or private_key_jwt client authentication over shared secrets. Secrets that are never transmitted cannot be stolen in transit.
  5. Strict Redirect URI Matching — Wildcards in redirect URIs are a critical attack surface. Every production URI must be fully qualified and must match exactly.
  6. Restrict Grant Types Per Client — Every client should only allow the grant types it actually uses. Disabling implicit flow and unused grants is one of the highest-impact, lowest-effort hardening steps.
  7. Defense in Depth — Combine transport security, token constraints, rate limiting, CSP, and CORS into a layered defense. No single control is sufficient.

Related Skills

  • identityserver-configuration — Server-side configuration of clients, resources, and signing keys that these hardening patterns build upon
  • oauth-oidc-protocols — Protocol-level context for PKCE, PAR, DPoP, and grant type trade-offs
  • aspnetcore-authentication — Applying OIDC authentication hardening in client applications
  • aspnetcore-authorization — Enforcing authorization policies that consume the hardened tokens produced here

Docs: https://docs.duendesoftware.com/general/security-best-practices/


Sub-Documents

DocumentDescriptionWhen to Load
docs/cors-csp.md [blocked]CORS ICorsPolicyService implementation and CSP middleware with header examplesCORS origins, Content-Security-Policy, X-Frame-Options, clickjacking, custom CORS policy
docs/rate-limiting.md [blocked]ASP.NET Core AddRateLimiter configuration for token and authorization endpointsRate limiting, brute force, 429, sliding window, fixed window, token endpoint protection
docs/session-hardening.md [blocked]Server-side sessions, cookie lifetime configuration, back-channel logout client setupSession security, CookieSlidingExpiration, BackChannelLogoutUri, session fixation, inactivity

Pattern 1: Transport Security — HTTPS, HSTS, and TLS

IdentityServer handles credentials and tokens. Every byte must travel over TLS. ASP.NET Core provides the pipeline middleware to enforce this.

csharp
// ✅ Program.cs — production pipeline orderingvar app = builder.Build();
// 1. HTTPS redirection — permanent redirect (308) for any HTTP requestapp.UseHttpsRedirection();
// 2. HSTS — tell browsers to always use HTTPS for this host// includeSubDomains: all subdomains also require HTTPS// preload: opt-in to browser preload lists (requires max-age >= 1 year)app.UseHsts();
app.UseIdentityServer();app.UseAuthorization();

Configure HSTS options in Program.cs before Build():

csharp
// ✅ Strong HSTS configurationbuilder.Services.AddHsts(options =>{    options.MaxAge = TimeSpan.FromDays(365);    options.IncludeSubDomains = true;    options.Preload = true;
    // Optionally exclude development/staging hosts    // options.ExcludedHosts.Add("localhost");});
// ✅ Force HTTPS redirect to use 443 explicitlybuilder.Services.AddHttpsRedirection(options =>{    options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect;    options.HttpsPort = 443;});

Behind a Reverse Proxy

When IdentityServer sits behind a load balancer or reverse proxy that terminates TLS, the inner request arrives as HTTP. Configure ForwardedHeaders so IdentityServer sees the correct scheme:

csharp
// ✅ Required when hosted behind a load balancer or ingressbuilder.Services.Configure<ForwardedHeadersOptions>(options =>{    options.ForwardedHeaders =        ForwardedHeaders.XForwardedFor |        ForwardedHeaders.XForwardedProto;
    // Restrict to known proxy IPs — never accept from any source    options.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));    options.ForwardLimit = 1;});
// Must be the very first middleware in the pipelineapp.UseForwardedHeaders();app.UseHttpsRedirection();app.UseHsts();app.UseIdentityServer();

Important: Without ForwardedHeaders, IdentityServer publishes an http:// issuer URI in the discovery document, causing token validation failures in every downstream API.

Kestrel TLS Configuration

For direct Kestrel hosting (no reverse proxy), configure TLS explicitly:

csharp
// ✅ Kestrel TLS — require TLS 1.2 minimumbuilder.WebHost.ConfigureKestrel(options =>{    options.ConfigureHttpsDefaults(https =>    {        https.SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;        https.ClientCertificateMode = ClientCertificateMode.NoCertificate;    });});

Pattern 2: Signing Key Security — Algorithm Selection and Rotation

Signing keys are the root of trust for every token IdentityServer issues. The default RS256 algorithm is broadly compatible. ES256 (ECDSA) offers smaller tokens and is appropriate for new deployments. Supported signing algorithm families are RS (RSA PKCS#1), PS (RSA-PSS), and ES (ECDSA).

Rotation overlap rule: Always publish a new public key before using it to sign tokens, and keep a retired public key available until all tokens signed with it have expired. Consumers must be able to fetch the key (via JWKS) both before it starts signing and after it stops. Automatic Key Management handles this overlap automatically; manual key managers must do phased rotation (below).

Automatic Key Management (Recommended)

csharp
// ✅ Production automatic key managementbuilder.Services.AddIdentityServer(options =>{    // Rotate every 90 days (default); reduce for higher-security deployments    options.KeyManagement.RotationInterval = TimeSpan.FromDays(90);
    // Announce 14 days before activation so JWKS caches refresh    options.KeyManagement.PropagationTime = TimeSpan.FromDays(14);
    // Keep retired keys for 14 days to validate recently-issued tokens    options.KeyManagement.RetentionDuration = TimeSpan.FromDays(14);
    // Delete keys when their retention period ends    options.KeyManagement.DeleteRetiredKeys = true;
    // Encrypt keys at rest via ASP.NET Core Data Protection (default: true)    options.KeyManagement.DataProtectKeys = true;
    // Store keys in a shared, durable location for load-balanced deployments    options.KeyManagement.KeyPath = "/var/identity/keys";
    // ES256 first = default for new tokens; RS256 for legacy client compatibility    options.KeyManagement.SigningAlgorithms = new[]    {        new SigningAlgorithmOptions(SecurityAlgorithms.EcdsaSha256),        new SigningAlgorithmOptions(SecurityAlgorithms.RsaSha256)        {            UseX509Certificate = true        }    };});

Key Storage — ASP.NET Data Protection

Automatic key management encrypts signing keys at rest using ASP.NET Data Protection. Configure Data Protection to use durable, shared storage. See ASP.NET Core Data Protection for complete configuration guidance.

v8 licensing note: On IdentityServer v8, Automatic Key Management, Server-Side Sessions, and SAML throw at startup when a license is present but lacks the entitlement (other licensed features only log a warning). Run lower environments with the production license key so entitlement gaps surface before production. Also note the v8 license key is a signed JWT with a kid header and fails on v7/BFF runtimes with IDX10503. See ASP.NET Core Data Protection for complete configuration guidance.

csharp
// ✅ Data Protection for load-balanced IdentityServerbuilder.Services.AddDataProtection()    // Persist keys to a shared location accessible by all instances    .PersistKeysToFileSystem(new DirectoryInfo("/var/identity/dp-keys"))    // Or: .PersistKeysToDbContext<IdentityDbContext>()    // Or: .PersistKeysToAzureBlobStorage(...)    .ProtectKeysWithCertificate(LoadProtectionCertificate())    // Always set an explicit application name    .SetApplicationName("identity-server");

Warning: Never store Data Protection keys on ephemeral storage (e.g., container local disk). If keys are lost, all encrypted data (persisted grants, cookies, server-side sessions) becomes unreadable.

Manual Key Rotation (Three-Phase Process)

When using static keys, never swap them in a single deployment. Use a phased rotation to avoid breaking in-flight token validation:

csharp
// Phase 1: Announce new key — continue signing with old key// Deploy and wait ≥ 24 h for JWKS caches to refreshidsvrBuilder.AddSigningCredential(oldKey, SecurityAlgorithms.RsaSha256);idsvrBuilder.AddValidationKey(newKey, SecurityAlgorithms.RsaSha256);
// Phase 2: Switch to new key — retain old key for validation// Deploy and wait ≥ token lifetime (default 1 h) for old tokens to expireidsvrBuilder.AddSigningCredential(newKey, SecurityAlgorithms.RsaSha256);idsvrBuilder.AddValidationKey(oldKey, SecurityAlgorithms.RsaSha256);
// Phase 3: Drop old key — old tokens are all expiredidsvrBuilder.AddSigningCredential(newKey, SecurityAlgorithms.RsaSha256);

Pattern 3: Token Constraints — Lifetimes, Reference Tokens, and Audience Validation

Token constraints limit the damage from token compromise and ensure tokens are only usable at their intended audience.

Token Lifetime Tuning

csharp
// ✅ Production-tuned client — short-lived access tokens, rotating refresh tokensnew Client{    ClientId = "web.app",    AllowedGrantTypes = GrantTypes.Code,    RequirePkce = true,
    // Short access token — reduces replay window    AccessTokenLifetime = 300,            // 5 minutes (default: 3600)
    // Identity tokens are consumed immediately after login    IdentityTokenLifetime = 300,          // 5 minutes (default: 300)
    // Refresh tokens rotate on every use — each use issues a new token    AllowOfflineAccess = true,    RefreshTokenUsage = TokenUsage.OneTimeOnly,    RefreshTokenExpiration = TokenExpiration.Absolute,    AbsoluteRefreshTokenLifetime = 86400, // 24 hours (default: 2592000 = 30 days)    SlidingRefreshTokenLifetime = 3600,   // 1 hour sliding window
    // Revoke refresh tokens when the user's session ends    CoordinateLifetimeWithUserSession = true}

Reference Tokens

Use reference tokens when:

  • Tokens contain sensitive claims that must not be visible to intermediaries
  • Immediate revocation is required (JWTs remain valid until expiry)
  • Token size is a concern (reference tokens are short opaque handles)
csharp
// ✅ Client configured for reference tokensnew Client{    ClientId = "internal.api.consumer",    AllowedGrantTypes = GrantTypes.ClientCredentials,    ClientSecrets = { new Secret("secret".Sha256()) },
    // Issue reference tokens instead of self-contained JWTs    AccessTokenType = AccessTokenType.Reference,
    AllowedScopes = { "internal-api" }}

The API must call the introspection endpoint to validate reference tokens:

csharp
// ✅ API configured to validate reference tokens via introspectionbuilder.Services    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)    .AddOAuth2Introspection("introspection", options =>    {        options.Authority = "https://identity.example.com";        options.ClientId = "internal-api";        options.ClientSecret = "api-secret";    });

Audience Validation

Audience validation ensures an access token issued for one API cannot be replayed at a different API. Use ApiResource to set explicit aud claims:

csharp
// ✅ Separate API resources = separate audiencesnew ApiResource("catalog-api", "Product Catalog"){    Scopes = { "catalog.read", "catalog.write" }},new ApiResource("orders-api", "Order Management"){    Scopes = { "orders.manage" }}

Validate audience on each API:

csharp
// ✅ API validates its own audiencebuilder.Services    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)    .AddJwtBearer(options =>    {        options.Authority = "https://identity.example.com";        options.Audience = "catalog-api"; // Must exactly match the ApiResource name        options.TokenValidationParameters.ValidateAudience = true;    });

Pattern 4: PKCE Enforcement

PKCE prevents authorization code interception attacks. RequirePkce = true is the default in Duende IdentityServer and must never be disabled for any interactive client.

csharp
// ✅ PKCE required (this is the default — shown explicitly for clarity)new Client{    ClientId = "web.app",    AllowedGrantTypes = GrantTypes.Code,    RequirePkce = true, // DO NOT SET TO FALSE IN PRODUCTION    ClientSecrets = { new Secret("secret".Sha256()) },    RedirectUris = { "https://app.example.com/signin-oidc" },    AllowedScopes = { "openid", "profile", "api1" }}
csharp
// ❌ WRONG — disabling PKCE for authorization code flownew Client{    ClientId = "legacy.app",    AllowedGrantTypes = GrantTypes.Code,    RequirePkce = false, // Vulnerable to authorization code interception}

For public clients (native apps, SPAs without BFF), PKCE is the only protection since they cannot hold a secret:

csharp
// ✅ Public client — no secret, PKCE is mandatorynew Client{    ClientId = "native.app",    AllowedGrantTypes = GrantTypes.Code,    RequirePkce = true,    RequireClientSecret = false, // Public client — no secret
    RedirectUris =    {        "com.example.app:/callback",      // Custom URI scheme for native apps        "https://app.example.com/callback" // HTTPS redirect for web    },    AllowedScopes = { "openid", "profile", "api1" }}

Pattern 5: Client Secret Management

Client authentication quality directly determines the strength of the authorization boundary. Upgrade from shared secrets to asymmetric credentials wherever possible.

Hierarchy of Client Authentication Strength

MethodRFCStrengthSecret Transmitted?
client_secret_basicRFC 6749LowYes (over TLS)
client_secret_postRFC 6749LowYes (in body)
private_key_jwtRFC 7523HighNo — only signed assertion
tls_client_auth (mTLS)RFC 8705HighNo — certificate proves identity

Shared Secret (Minimum Baseline — Avoid for Sensitive Clients)

csharp
// ❌ Avoid — shared secrets can be extracted from config, logs, and memorynew Client{    ClientId = "basic.client",    ClientSecrets = { new Secret("my-secret".Sha256()) }}

Store secrets outside source control. Never hash secrets inline with literals:

csharp
// ✅ Load secret value from configuration, not codevar secretValue = configuration["IdentityServer:Clients:MyClient:Secret"];new Client{    ClientId = "my-client",    ClientSecrets = { new Secret(secretValue.Sha256()) }}

Private Key JWT (Recommended)

The client holds a private key and signs a JWT assertion. IdentityServer validates the assertion using the client's registered public key. No secret is ever sent over the wire.

csharp
// ✅ Register a client that authenticates with private_key_jwtnew Client{    ClientId = "secure.service",    AllowedGrantTypes = GrantTypes.ClientCredentials,    AllowedScopes = { "api1" },
    ClientSecrets =    {        // Register the client's public key or certificate        new Secret        {            Type = IdentityServerConstants.SecretTypes.JsonWebKey,            Value = """            {                "kty": "RSA",                "use": "sig",                "kid": "my-key-id",                "n": "<base64url-encoded-modulus>",                "e": "AQAB"            }            """        }    }}

The client sends a signed JWT assertion at the token endpoint (using Duende.AccessTokenManagement or IdentityModel):

csharp
// ✅ Client-side: authenticate with a signed assertionvar tokenRequest = new ClientCredentialsTokenRequest{    Address = disco.TokenEndpoint,    ClientId = "secure.service",    ClientAssertion = new ClientAssertion    {        Type = OidcConstants.ClientAssertionTypes.JwtBearer,        Value = BuildClientAssertionJwt(clientId, tokenEndpoint, privateKey)    },    Scope = "api1"};

Secret Rotation

Never rotate secrets with a hard cut-over. Register the new secret alongside the old one, deploy clients, then remove the old secret:

csharp
// ✅ Two active secrets during rotation windownew Client{    ClientId = "my-service",    ClientSecrets =    {        new Secret(currentSecret.Sha256()),        new Secret(newSecret.Sha256()) // New secret pre-registered    }}// After all clients are updated: remove currentSecret

Custom Secret Validation (ISecretValidator)

Implement ISecretValidator to enforce custom secret policies (e.g., key minimum length, algorithm restrictions):

csharp
// ✅ Custom validator that rejects secrets shorter than 32 characterspublic sealed class MinimumLengthSecretValidator : ISecretValidator{    public Task<SecretValidationResult> ValidateAsync(        IEnumerable<Secret> secrets, ParsedSecret parsedSecret)    {        if (parsedSecret.Type != IdentityServerConstants.ParsedSecretTypes.SharedSecret)            return Task.FromResult(new SecretValidationResult { Success = false });
        var value = parsedSecret.Credential as string;        if (value is null || value.Length < 32)        {            return Task.FromResult(new SecretValidationResult            {                Success = false,                Error = "Secret does not meet minimum length requirements"            });        }
        // Delegate to default validation        return Task.FromResult(new SecretValidationResult { Success = true });    }}

Pattern 6: Redirect URI Validation

Authorization code injection via open redirectors is one of the most critical OAuth attack vectors. Redirect URI validation must be exact-match in production.

Strict Matching (Default Behavior)

Duende IdentityServer validates redirect URIs by exact string comparison. This is the correct behavior:

csharp
// ✅ Exact URIs — no trailing slash ambiguity, no wildcardsnew Client{    ClientId = "web.app",    RedirectUris =    {        "https://app.example.com/signin-oidc"    },    PostLogoutRedirectUris =    {        "https://app.example.com/signout-callback-oidc"    }}
csharp
// ❌ WRONG — wildcards allow an attacker to redirect to a malicious hostnew Client{    RedirectUris = { "https://*.example.com/callback" } // Never do this}

Custom Redirect URI Validator

For legitimate dynamic scenarios (e.g., multi-tenant apps with per-tenant domains), implement IRedirectUriValidator with explicit allow-listing from a trusted data source:

csharp
// ✅ Custom validator that allows tenant subdomains from a verified listpublic sealed class TenantRedirectUriValidator : IRedirectUriValidator{    private readonly ITenantRegistry _tenants;
    public TenantRedirectUriValidator(ITenantRegistry tenants) => _tenants = tenants;
    public async Task<bool> IsRedirectUriValidAsync(string requestedUri, Client client)    {        // Allow standard registered URIs first        if (client.RedirectUris.Contains(requestedUri))            return true;
        // Allow per-tenant URIs — always validate against a trusted data source        var uri = new Uri(requestedUri);        return await _tenants.IsAllowedCallbackAsync(uri);    }
    public async Task<bool> IsPostLogoutRedirectUriValidAsync(        string requestedUri, Client client)    {        if (client.PostLogoutRedirectUris.Contains(requestedUri))            return true;
        var uri = new Uri(requestedUri);        return await _tenants.IsAllowedCallbackAsync(uri);    }}

Register the custom validator:

csharp
// ✅ Replace the default validatorbuilder.Services.AddTransient<IRedirectUriValidator, TenantRedirectUriValidator>();

Pattern 7: Grant Type Restrictions

Each enabled grant type expands the attack surface. Disable every grant type a client does not use.

Disable Implicit Flow Globally

Implicit flow is deprecated by RFC 9700. Ensure no client uses it:

csharp
// ❌ WRONG — implicit flow exposes tokens in browser history and referrer headersnew Client{    AllowedGrantTypes = GrantTypes.Implicit}
// ✅ CORRECT — use authorization code + PKCE for all interactive clientsnew Client{    AllowedGrantTypes = GrantTypes.Code,    RequirePkce = true}

Principle of Least Grant

csharp
// ✅ Machine-to-machine service: only client_credentialsnew Client{    ClientId = "background.worker",    AllowedGrantTypes = GrantTypes.ClientCredentials,    // AllowOfflineAccess = false (default) — no refresh tokens for M2M}
// ✅ Interactive web app: only authorization codenew Client{    ClientId = "web.app",    AllowedGrantTypes = GrantTypes.Code,    RequirePkce = true}
// ❌ WRONG — granting more than needednew Client{    ClientId = "web.app",    AllowedGrantTypes = GrantTypes.CodeAndClientCredentials // Never combine user + M2M flows}

Custom Grant Validation

For extension grants, always validate the grant assertion rigorously:

csharp
// ✅ Extension grant with strict validationpublic sealed class TokenExchangeGrantValidator : IExtensionGrantValidator{    public string GrantType => "urn:ietf:params:oauth:grant-type:token-exchange";
    public async Task ValidateAsync(ExtensionGrantValidationContext context)    {        var subjectToken = context.Request.Raw.Get("subject_token");        if (string.IsNullOrWhiteSpace(subjectToken))        {            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest,                "subject_token is required");            return;        }
        // Validate the subject token — never trust without verification        var principal = await ValidateSubjectTokenAsync(subjectToken);        if (principal is null)        {            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant,                "subject_token is invalid or expired");            return;        }
        context.Result = new GrantValidationResult(            subject: principal.GetSubjectId(),            authenticationMethod: GrantType);    }}

Pattern 8: CORS Configuration

Set AllowedCorsOrigins per client with exact scheme+host+port — no trailing slashes, no wildcards. For dynamic tenant scenarios, implement ICorsPolicyService with a custom repository. Never use AllowAnyOrigin for IdentityServer endpoints.

See docs/cors-csp.md [blocked] for the complete ICorsPolicyService implementation and CORS configuration examples.


Pattern 9: Content Security Policy

Add a middleware that appends Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, and Referrer-Policy headers to all IdentityServer UI paths (/account, /consent, /connect, /diagnostics). Use frame-ancestors 'none' and object-src 'none' as the minimum bar.

See docs/cors-csp.md [blocked] for the complete CSP middleware implementation with inline examples.


Pattern 10: Rate Limiting

Duende IdentityServer has no built-in rate limiting. Use ASP.NET Core AddRateLimiter (set RejectionStatusCode = 429), but note a critical constraint: IdentityServer matches its protocol endpoints (/connect/token, /connect/authorize) with its own middleware, not ASP.NET Core endpoint routing. You therefore cannot attach a named per-endpoint policy to those endpoints — only the global limiter applies. Approximate per-endpoint limits by partitioning the global limiter on context.Request.Path. Named policies (RequireRateLimiting) still work on your own routed Razor Pages (login/consent). In load-balanced deployments, use X-Forwarded-For (after ForwardedHeaders middleware) for accurate IP partitioning. For identity-aware limits, add an ICustomTokenRequestValidator (runs after the client/user are known).

See docs/rate-limiting.md [blocked] for the complete rate limiter configuration, the protocol-endpoint caveat, and the ICustomTokenRequestValidator approach.


Pattern 11: Session Security

Enable server-side sessions via idsvrBuilder.AddServerSideSessions(). Set CookieSlidingExpiration = false and a fixed CookieLifetime (e.g., 8 hours). Configure ExpiredSessionsTriggerBackchannelLogout = true and CoordinateClientLifetimesWithUserSession = true. Set BackChannelLogoutUri on each client for server-to-server session termination notification.

See docs/session-hardening.md [blocked] for the complete session configuration and back-channel logout client setup.


Pattern 12: Input Validation and InputLengthRestrictions

IdentityServer validates all incoming request parameters against configurable length limits. Tighten these to reduce injection and memory exhaustion risks.

csharp
// ✅ Tightened input length restrictionsbuilder.Services.AddIdentityServer(options =>{    // Scope values — tighten to your longest actual scope name    options.InputLengthRestrictions.Scope = 300;         // default: 300
    // Client ID — match your longest client ID    options.InputLengthRestrictions.ClientId = 100;      // default: 100
    // Client secret — limit to prevent memory abuse    options.InputLengthRestrictions.ClientSecret = 100;  // default: 100
    // Redirect URI — match your longest registered URI    options.InputLengthRestrictions.RedirectUri = 400;   // default: 400
    // Nonce — OpenID Connect replay protection    options.InputLengthRestrictions.Nonce = 300;         // default: 300
    // Code challenge for PKCE — use the correct min/max length properties    // (verify exact property names against current Duende IdentityServer source,    //  e.g. CodeChallengeMinLength / CodeChallengeMaxLength)    options.InputLengthRestrictions.CodeChallengeMinLength = 43;  // RFC 7636 minimum    options.InputLengthRestrictions.CodeChallengeMaxLength = 128; // RFC 7636 maximum});

Pattern 13: Audit Logging via Events

Events emit high-level, structured audit records (login success/failure, token issuance, consent, errors) suitable for a security audit trail. Audit events can contain usernames, subject/client ids, scopes, redirect URIs, and IPs — token values are obfuscated.

Events are NOT enabled by default. Turn them on in AddIdentityServer:

csharp
// ✅ Enable audit eventsbuilder.Services.AddIdentityServer(options =>{    options.Events.RaiseSuccessEvents = true;    options.Events.RaiseFailureEvents = true;    options.Events.RaiseErrorEvents = true;    options.Events.RaiseInformationEvents = true;});

IdentityServer raises protocol events itself, but UI actions (login success/failure) must be raised by your UI code. Inject IEventService and call RaiseAsync(...):

csharp
// ✅ Raise UI login events from your account controller/pagepublic LoginModel(IEventService events) => _events = events;
await _events.RaiseAsync(    new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username));// or on failure:await _events.RaiseAsync(    new UserLoginFailureEvent(username, "invalid credentials"));

Custom Sink — Replaces the Default Sink

Implement IEventSink and register it. IEventService sends each event to exactly ONE IEventSink, so registering a custom sink REPLACES the default (ASP.NET Core logger) sink. If you still want log output, the custom sink must forward to logging itself.

csharp
// ✅ Forward audit events to Seq (Serilog.Sinks.Seq) — and keep loggingpublic sealed class SeqEventSink : IEventSink{    private readonly ILogger<SeqEventSink> _logger; // default sink is replaced — log here    public SeqEventSink(ILogger<SeqEventSink> logger) => _logger = logger;
    public Task PersistAsync(Event evt)    {        _logger.LogInformation("{Name} ({Id}) {@Event}", evt.Name, evt.Id, evt);        return Task.CompletedTask;    }}
// Registration — replaces the built-in logger sinkservices.AddTransient<IEventSink, SeqEventSink>();

Custom events derive from the base Event class with a unique event id.


Common Pitfalls

1. Disabling PKCE

csharp
// ❌ WRONG — authorization code interception becomes trivially exploitablenew Client { RequirePkce = false }
// ✅ CORRECT — RequirePkce = true is the default; never override it to falsenew Client { RequirePkce = true }

2. Wildcard Redirect URIs

csharp
// ❌ WRONG — open redirector: attacker steers code to their serverRedirectUris = { "https://*.example.com/*" }
// ✅ CORRECT — fully qualified, exact-match URIs onlyRedirectUris = { "https://app.example.com/signin-oidc" }

3. Implicit Flow Still Enabled

csharp
// ❌ WRONG — exposes tokens in URL fragments, browser history, referrer headersAllowedGrantTypes = GrantTypes.Implicit
// ✅ CORRECT — authorization code + PKCE replaces implicit flow entirelyAllowedGrantTypes = GrantTypes.Code

4. Accepting ForwardedHeaders From Any Source

csharp
// ❌ WRONG — attacker can spoof X-Forwarded-Proto: https from any IPoptions.ForwardedHeaders = ForwardedHeaders.XForwardedProto;// KnownProxies is empty = accepts from anywhere
// ✅ CORRECT — restrict to known proxy IPsoptions.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));

5. Plaintext Secrets in Source Control

csharp
// ❌ WRONG — secret is committed to git historyClientSecrets = { new Secret("SuperSecret123".Sha256()) }
// ✅ CORRECT — load from secret store or environment variableClientSecrets = { new Secret(config["Services:MyClient:Secret"].Sha256()) }

6. HTTP Issuer URI

csharp
// ❌ WRONG — discovery document publishes http:// issuer; APIs reject all tokens// Caused by missing ForwardedHeaders middleware behind a TLS-terminating proxy
// ✅ CORRECT — configure ForwardedHeaders before UseIdentityServer()// OR set the issuer explicitlyoptions.IssuerUri = "https://identity.example.com";

7. Long-Lived Access Tokens

csharp
// ❌ WRONG — 8-hour access token gives attackers a huge replay windowAccessTokenLifetime = 28800
// ✅ CORRECT — 5–15 minutes; use refresh tokens for longer sessionsAccessTokenLifetime = 300

8. Missing Audience Validation at the API

csharp
// ❌ WRONG — API accepts any token from the issuer, regardless of audienceoptions.TokenValidationParameters = new TokenValidationParameters{    ValidateAudience = false // Dangerous — token from any client works at any API};
// ✅ CORRECT — validate the audience matches this specific APIoptions.Audience = "my-api";options.TokenValidationParameters.ValidateAudience = true;

9. Shared Keys Across Environments

csharp
// ❌ WRONG — development signing key committed to source control and reused in productionidsvrBuilder.AddDeveloperSigningCredential(); // Development only!
// ✅ CORRECT — automatic key management generates and rotates keys per-environment// Each environment has its own isolated key materialoptions.KeyManagement.Enabled = true;options.KeyManagement.DataProtectKeys = true;

Production Security Checklist

AreaControlStatus
TransportHTTPS enforced with UseHttpsRedirection()Required
TransportHSTS with IncludeSubDomains = true, MaxAge ≥ 1 yearRequired
TransportTLS 1.2+ minimum on KestrelRequired
TransportForwardedHeaders restricted to known proxy IPsRequired if behind proxy
KeysAutomatic key management enabled (KeyManagement.Enabled = true)Required
KeysDataProtectKeys = true + Data Protection configured with durable storageRequired
KeysPropagationTime ≥ 24 h and RetentionDuration ≥ token lifetimeRequired
KeysES256 or RS256 (never HS256 for asymmetric signing)Required
TokensAccessTokenLifetime ≤ 300 s for interactive clientsRecommended
TokensRefreshTokenUsage = OneTimeOnlyRequired
TokensAudience validation enabled at every APIRequired
ClientsRequirePkce = true on every authorization code clientRequired
ClientsNo implicit flow (GrantTypes.Implicit) in any clientRequired
ClientsNo wildcard redirect URIsRequired
ClientsSecrets loaded from vault/config, not source codeRequired
ClientsCertificate or private_key_jwt auth for sensitive M2M clientsRecommended
CORSAllowedCorsOrigins set per-client; no AllowAnyOriginRequired
CSPframe-ancestors 'none' and object-src 'none' on UI pagesRequired
CSPX-Frame-Options: DENY on all IdentityServer pagesRequired
SessionsCookieSlidingExpiration = falseRecommended
SessionsServer-side sessions enabled with back-channel logoutRecommended
SessionsCoordinateClientLifetimesWithUserSession = trueRecommended
Rate LimitingToken endpoint rate-limited per client IPRequired
EventsRaiseErrorEvents, RaiseFailureEvents both trueRequired

Resources

Source and attribution

Source:DuendeSoftware/duende-skillsinskills/identity-security-hardeningat commitfb32edc

License: No license

Content belongs to its original authors. SourceWeft indexes it from a public repository.

Report or request removal

Identity Security Hardening Agent Skill | SourceWeft