| name | identity-security-hardening |
| description | 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. |
| invocable | false |
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
- 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.
- 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.
- 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.
- 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.
- Strict Redirect URI Matching โ Wildcards in redirect URIs are a critical attack surface. Every production URI must be fully qualified and must match exactly.
- 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.
- 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/identityserver/configuration/security
Sub-Documents
| Document | Description | When to Load |
|---|
| docs/cors-csp.md | CORS ICorsPolicyService implementation and CSP middleware with header examples | CORS origins, Content-Security-Policy, X-Frame-Options, clickjacking, custom CORS policy |
| docs/rate-limiting.md | ASP.NET Core AddRateLimiter configuration for token and authorization endpoints | Rate limiting, brute force, 429, sliding window, fixed window, token endpoint protection |
| docs/session-hardening.md | Server-side sessions, cookie lifetime configuration, back-channel logout client setup | Session 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.
var app = builder.Build();
app.UseHttpsRedirection();
app.UseHsts();
app.UseIdentityServer();
app.UseAuthorization();
Configure HSTS options in Program.cs before Build():
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
options.Preload = true;
});
builder.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:
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));
options.ForwardLimit = 1;
});
app.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:
builder.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.
Automatic Key Management (Recommended)
builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.RotationInterval = TimeSpan.FromDays(90);
options.KeyManagement.PropagationTime = TimeSpan.FromDays(14);
options.KeyManagement.RetentionDuration = TimeSpan.FromDays(14);
options.KeyManagement.DeleteRetiredKeys = true;
options.KeyManagement.DataProtectKeys = true;
options.KeyManagement.KeyPath = "/var/identity/keys";
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.
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/var/identity/dp-keys"))
.ProtectKeysWithCertificate(LoadProtectionCertificate())
.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:
idsvrBuilder.AddSigningCredential(oldKey, SecurityAlgorithms.RsaSha256);
idsvrBuilder.AddValidationKey(newKey, SecurityAlgorithms.RsaSha256);
idsvrBuilder.AddSigningCredential(newKey, SecurityAlgorithms.RsaSha256);
idsvrBuilder.AddValidationKey(oldKey, SecurityAlgorithms.RsaSha256);
idsvrBuilder.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
new Client
{
ClientId = "web.app",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
AccessTokenLifetime = 300,
IdentityTokenLifetime = 300,
AllowOfflineAccess = true,
RefreshTokenUsage = TokenUsage.OneTimeOnly,
RefreshTokenExpiration = TokenExpiration.Absolute,
AbsoluteRefreshTokenLifetime = 86400,
SlidingRefreshTokenLifetime = 3600,
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)
new Client
{
ClientId = "internal.api.consumer",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets = { new Secret("secret".Sha256()) },
AccessTokenType = AccessTokenType.Reference,
AllowedScopes = { "internal-api" }
}
The API must call the introspection endpoint to validate reference tokens:
builder.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:
new ApiResource("catalog-api", "Product Catalog")
{
Scopes = { "catalog.read", "catalog.write" }
},
new ApiResource("orders-api", "Order Management")
{
Scopes = { "orders.manage" }
}
Validate audience on each API:
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "catalog-api";
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.
new Client
{
ClientId = "web.app",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
ClientSecrets = { new Secret("secret".Sha256()) },
RedirectUris = { "https://app.example.com/signin-oidc" },
AllowedScopes = { "openid", "profile", "api1" }
}
new Client
{
ClientId = "legacy.app",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = false,
}
For public clients (native apps, SPAs without BFF), PKCE is the only protection since they cannot hold a secret:
new Client
{
ClientId = "native.app",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false,
RedirectUris =
{
"com.example.app:/callback",
"https://app.example.com/callback"
},
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
| Method | RFC | Strength | Secret Transmitted? |
|---|
client_secret_basic | RFC 6749 | Low | Yes (over TLS) |
client_secret_post | RFC 6749 | Low | Yes (in body) |
private_key_jwt | RFC 7523 | High | No โ only signed assertion |
tls_client_auth (mTLS) | RFC 8705 | High | No โ certificate proves identity |
Shared Secret (Minimum Baseline โ Avoid for Sensitive Clients)
new Client
{
ClientId = "basic.client",
ClientSecrets = { new Secret("my-secret".Sha256()) }
}
Store secrets outside source control. Never hash secrets inline with literals:
var 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.
new Client
{
ClientId = "secure.service",
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "api1" },
ClientSecrets =
{
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):
var 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:
new Client
{
ClientId = "my-service",
ClientSecrets =
{
new Secret(currentSecret.Sha256()),
new Secret(newSecret.Sha256())
}
}
Custom Secret Validation (ISecretValidator)
Implement ISecretValidator to enforce custom secret policies (e.g., key minimum length, algorithm restrictions):
public 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"
});
}
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:
new Client
{
ClientId = "web.app",
RedirectUris =
{
"https://app.example.com/signin-oidc"
},
PostLogoutRedirectUris =
{
"https://app.example.com/signout-callback-oidc"
}
}
new Client
{
RedirectUris = { "https://*.example.com/callback" }
}
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:
public sealed class TenantRedirectUriValidator : IRedirectUriValidator
{
private readonly ITenantRegistry _tenants;
public TenantRedirectUriValidator(ITenantRegistry tenants) => _tenants = tenants;
public async Task<bool> IsRedirectUriValidAsync(string requestedUri, Client client)
{
if (client.RedirectUris.Contains(requestedUri))
return true;
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:
builder.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:
new Client
{
AllowedGrantTypes = GrantTypes.Implicit
}
new Client
{
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true
}
Principle of Least Grant
new Client
{
ClientId = "background.worker",
AllowedGrantTypes = GrantTypes.ClientCredentials,
}
new Client
{
ClientId = "web.app",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true
}
new Client
{
ClientId = "web.app",
AllowedGrantTypes = GrantTypes.CodeAndClientCredentials
}
Custom Grant Validation
For extension grants, always validate the grant assertion rigorously:
public 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;
}
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 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 for the complete CSP middleware implementation with inline examples.
Pattern 10: Rate Limiting
Use AddRateLimiter with a sliding window policy (e.g., 20 requests/minute per IP) for /connect/token and a fixed window policy (e.g., 10 requests/minute) for /connect/authorize. Set RejectionStatusCode = 429. In load-balanced deployments, use X-Forwarded-For (after ForwardedHeaders middleware) for accurate IP partitioning.
See docs/rate-limiting.md for the complete rate limiter configuration and route application code.
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 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.
builder.Services.AddIdentityServer(options =>
{
options.InputLengthRestrictions.Scope = 300;
options.InputLengthRestrictions.ClientId = 100;
options.InputLengthRestrictions.ClientSecret = 100;
options.InputLengthRestrictions.RedirectUri = 400;
options.InputLengthRestrictions.Nonce = 300;
options.InputLengthRestrictions.CodeChallengeMinLength = 43;
options.InputLengthRestrictions.CodeChallengeMaxLength = 128;
});
Common Pitfalls
1. Disabling PKCE
new Client { RequirePkce = false }
new Client { RequirePkce = true }
2. Wildcard Redirect URIs
RedirectUris = { "https://*.example.com/*" }
RedirectUris = { "https://app.example.com/signin-oidc" }
3. Implicit Flow Still Enabled
AllowedGrantTypes = GrantTypes.Implicit
AllowedGrantTypes = GrantTypes.Code
4. Accepting ForwardedHeaders From Any Source
options.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));
5. Plaintext Secrets in Source Control
ClientSecrets = { new Secret("SuperSecret123".Sha256()) }
ClientSecrets = { new Secret(config["Services:MyClient:Secret"].Sha256()) }
6. HTTP Issuer URI
options.IssuerUri = "https://identity.example.com";
7. Long-Lived Access Tokens
AccessTokenLifetime = 28800
AccessTokenLifetime = 300
8. Missing Audience Validation at the API
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false
};
options.Audience = "my-api";
options.TokenValidationParameters.ValidateAudience = true;
9. Shared Keys Across Environments
idsvrBuilder.AddDeveloperSigningCredential();
options.KeyManagement.Enabled = true;
options.KeyManagement.DataProtectKeys = true;
Production Security Checklist
| Area | Control | Status |
|---|
| Transport | HTTPS enforced with UseHttpsRedirection() | Required |
| Transport | HSTS with IncludeSubDomains = true, MaxAge โฅ 1 year | Required |
| Transport | TLS 1.2+ minimum on Kestrel | Required |
| Transport | ForwardedHeaders restricted to known proxy IPs | Required if behind proxy |
| Keys | Automatic key management enabled (KeyManagement.Enabled = true) | Required |
| Keys | DataProtectKeys = true + Data Protection configured with durable storage | Required |
| Keys | PropagationTime โฅ 24 h and RetentionDuration โฅ token lifetime | Required |
| Keys | ES256 or RS256 (never HS256 for asymmetric signing) | Required |
| Tokens | AccessTokenLifetime โค 300 s for interactive clients | Recommended |
| Tokens | RefreshTokenUsage = OneTimeOnly | Required |
| Tokens | Audience validation enabled at every API | Required |
| Clients | RequirePkce = true on every authorization code client | Required |
| Clients | No implicit flow (GrantTypes.Implicit) in any client | Required |
| Clients | No wildcard redirect URIs | Required |
| Clients | Secrets loaded from vault/config, not source code | Required |
| Clients | Certificate or private_key_jwt auth for sensitive M2M clients | Recommended |
| CORS | AllowedCorsOrigins set per-client; no AllowAnyOrigin | Required |
| CSP | frame-ancestors 'none' and object-src 'none' on UI pages | Required |
| CSP | X-Frame-Options: DENY on all IdentityServer pages | Required |
| Sessions | CookieSlidingExpiration = false | Recommended |
| Sessions | Server-side sessions enabled with back-channel logout | Recommended |
| Sessions | CoordinateClientLifetimesWithUserSession = true | Recommended |
| Rate Limiting | Token endpoint rate-limited per client IP | Required |
| Events | RaiseErrorEvents, RaiseFailureEvents both true | Required |
Resources