Identity Security Hardening

作者 DuendeSoftwarefb32edc51982無授權條款9 個星標收錄於 2026年10月8日更新於 2026年10月8日儲存庫4 週前更新

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.

僅含說明Security
AI 產生的概覽

為 Duende IdentityServer 部署提供 HTTPS、金鑰輪換、權杖、PKCE、密鑰與重新導向 URI 的安全強化指引。

功能
為 Duende IdentityServer 部署提供規範性的強化指引,涵蓋傳輸安全(HTTPS、HSTS、TLS、轉送標頭)、簽章金鑰演算法選擇與輪換、權杖存續時間調校、參照權杖、對象驗證、強制啟用 PKCE、用戶端密鑰管理與重新導向 URI 驗證。內容包含附註解的 C# 設定範例、用戶端驗證強度分級,以及面向稽核的檢查清單。三份子文件補充了 CORS 與 CSP 設定、速率限制與工作階段強化的細節。
適用情境
適用於將 IdentityServer 部署推上正式環境前的強化、依 OAuth 2.0 安全最佳實務稽核現有設定,或設定 HTTPS、PKCE、用戶端密鑰、權杖存續時間、簽章金鑰輪換、CORS、CSP、速率限制與工作階段行為。
執行需求
不含指令碼,僅為說明與文件。套用這些指引需具備以 ASP.NET Core 為基礎的 Duende IdentityServer 部署,並可選擇性地存取所引用的子文件與外部廠商文件。

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

來源與署名

來源:DuendeSoftware/duende-skills位於skills/identity-security-hardening提交fb32edc

授權條款: 無授權條款

內容歸原作者所有。SourceWeft 從公開儲存庫中收錄這些內容。

檢舉或申請下架