← Duende SkillsCONTENT HISTORY

Update to Duende Skills

Snapshot Sep 30, 2026 · 23:14 UTC · version 0.3.0

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "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.",
  "included_files": [
    {
      "relative_path": "docs/cors-csp.md",
      "size_in_bytes": 4117
    },
    {
      "relative_path": "docs/rate-limiting.md",
      "size_in_bytes": 4720
    },
    {
      "relative_path": "docs/session-hardening.md",
      "size_in_bytes": 1878
    }
  ],
  "skill_md_contents": "---\nname: identity-security-hardening\ndescription: 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.\ninvocable: false\n---\n\n# Identity Security Hardening\n\n## When to Use This Skill\n\nUse this skill when:\n- Hardening a Duende IdentityServer deployment before promoting to production\n- Configuring HTTPS, HSTS, and TLS requirements for the identity server host\n- Evaluating or enforcing client secret policies (shared secrets vs. certificates vs. `private_key_jwt`)\n- Setting PKCE requirements, restricting grant types, or locking down redirect URI validation\n- Configuring Content Security Policy (CSP) and CORS for IdentityServer UI pages and endpoints\n- Applying rate limiting to the token endpoint to protect against brute-force and enumeration attacks\n- Tuning token lifetimes, enabling reference tokens, or implementing token replay detection\n- Rotating signing keys or choosing between RS256 and ES256 algorithms\n- Hardening session lifetimes, idle timeouts, and back-channel logout behavior\n- Auditing an existing IdentityServer setup against OAuth 2.0 Security Best Current Practice (RFC 9700)\n\n## Core Principles\n\n1. **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.\n2. **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.\n3. **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.\n4. **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.\n5. **Strict Redirect URI Matching** — Wildcards in redirect URIs are a critical attack surface. Every production URI must be fully qualified and must match exactly.\n6. **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.\n7. **Defense in Depth** — Combine transport security, token constraints, rate limiting, CSP, and CORS into a layered defense. No single control is sufficient.\n\n## Related Skills\n\n- `identityserver-configuration` — Server-side configuration of clients, resources, and signing keys that these hardening patterns build upon\n- `oauth-oidc-protocols` — Protocol-level context for PKCE, PAR, DPoP, and grant type trade-offs\n- `aspnetcore-authentication` — Applying OIDC authentication hardening in client applications\n- `aspnetcore-authorization` — Enforcing authorization policies that consume the hardened tokens produced here\n\nDocs: https://docs.duendesoftware.com/general/security-best-practices/\n\n---\n\n## Sub-Documents\n\n| Document | Description | When to Load |\n|----------|-------------|--------------|\n| [docs/cors-csp.md](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 |\n| [docs/rate-limiting.md](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 |\n| [docs/session-hardening.md](docs/session-hardening.md) | Server-side sessions, cookie lifetime configuration, back-channel logout client setup | Session security, CookieSlidingExpiration, BackChannelLogoutUri, session fixation, inactivity |\n\n---\n\n## Pattern 1: Transport Security — HTTPS, HSTS, and TLS\n\nIdentityServer handles credentials and tokens. Every byte must travel over TLS. ASP.NET Core provides the pipeline middleware to enforce this.\n\n```csharp\n// ✅ Program.cs — production pipeline ordering\nvar app = builder.Build();\n\n// 1. HTTPS redirection — permanent redirect (308) for any HTTP request\napp.UseHttpsRedirection();\n\n// 2. HSTS — tell browsers to always use HTTPS for this host\n// includeSubDomains: all subdomains also require HTTPS\n// preload: opt-in to browser preload lists (requires max-age >= 1 year)\napp.UseHsts();\n\napp.UseIdentityServer();\napp.UseAuthorization();\n```\n\nConfigure HSTS options in `Program.cs` before `Build()`:\n\n```csharp\n// ✅ Strong HSTS configuration\nbuilder.Services.AddHsts(options =>\n{\n    options.MaxAge = TimeSpan.FromDays(365);\n    options.IncludeSubDomains = true;\n    options.Preload = true;\n\n    // Optionally exclude development/staging hosts\n    // options.ExcludedHosts.Add(\"localhost\");\n});\n\n// ✅ Force HTTPS redirect to use 443 explicitly\nbuilder.Services.AddHttpsRedirection(options =>\n{\n    options.RedirectStatusCode = StatusCodes.Status308PermanentRedirect;\n    options.HttpsPort = 443;\n});\n```\n\n### Behind a Reverse Proxy\n\nWhen 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:\n\n```csharp\n// ✅ Required when hosted behind a load balancer or ingress\nbuilder.Services.Configure<ForwardedHeadersOptions>(options =>\n{\n    options.ForwardedHeaders =\n        ForwardedHeaders.XForwardedFor |\n        ForwardedHeaders.XForwardedProto;\n\n    // Restrict to known proxy IPs — never accept from any source\n    options.KnownProxies.Add(IPAddress.Parse(\"10.0.0.1\"));\n    options.ForwardLimit = 1;\n});\n\n// Must be the very first middleware in the pipeline\napp.UseForwardedHeaders();\napp.UseHttpsRedirection();\napp.UseHsts();\napp.UseIdentityServer();\n```\n\n> **Important:** Without `ForwardedHeaders`, IdentityServer publishes an `http://` issuer URI in the discovery document, causing token validation failures in every downstream API.\n\n### Kestrel TLS Configuration\n\nFor direct Kestrel hosting (no reverse proxy), configure TLS explicitly:\n\n```csharp\n// ✅ Kestrel TLS — require TLS 1.2 minimum\nbuilder.WebHost.ConfigureKestrel(options =>\n{\n    options.ConfigureHttpsDefaults(https =>\n    {\n        https.SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;\n        https.ClientCertificateMode = ClientCertificateMode.NoCertificate;\n    });\n});\n```\n\n---\n\n## Pattern 2: Signing Key Security — Algorithm Selection and Rotation\n\nSigning 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).\n\n> **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).\n\n### Automatic Key Management (Recommended)\n\n```csharp\n// ✅ Production automatic key management\nbuilder.Services.AddIdentityServer(options =>\n{\n    // Rotate every 90 days (default); reduce for higher-security deployments\n    options.KeyManagement.RotationInterval = TimeSpan.FromDays(90);\n\n    // Announce 14 days before activation so JWKS caches refresh\n    options.KeyManagement.PropagationTime = TimeSpan.FromDays(14);\n\n    // Keep retired keys for 14 days to validate recently-issued tokens\n    options.KeyManagement.RetentionDuration = TimeSpan.FromDays(14);\n\n    // Delete keys when their retention period ends\n    options.KeyManagement.DeleteRetiredKeys = true;\n\n    // Encrypt keys at rest via ASP.NET Core Data Protection (default: true)\n    options.KeyManagement.DataProtectKeys = true;\n\n    // Store keys in a shared, durable location for load-balanced deployments\n    options.KeyManagement.KeyPath = \"/var/identity/keys\";\n\n    // ES256 first = default for new tokens; RS256 for legacy client compatibility\n    options.KeyManagement.SigningAlgorithms = new[]\n    {\n        new SigningAlgorithmOptions(SecurityAlgorithms.EcdsaSha256),\n        new SigningAlgorithmOptions(SecurityAlgorithms.RsaSha256)\n        {\n            UseX509Certificate = true\n        }\n    };\n});\n```\n\n### Key Storage — ASP.NET Data Protection\n\nAutomatic 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](https://docs.duendesoftware.com/general/data-protection/) for complete configuration guidance.\n\n> **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](https://docs.duendesoftware.com/general/data-protection/) for complete configuration guidance.\n\n```csharp\n// ✅ Data Protection for load-balanced IdentityServer\nbuilder.Services.AddDataProtection()\n    // Persist keys to a shared location accessible by all instances\n    .PersistKeysToFileSystem(new DirectoryInfo(\"/var/identity/dp-keys\"))\n    // Or: .PersistKeysToDbContext<IdentityDbContext>()\n    // Or: .PersistKeysToAzureBlobStorage(...)\n    .ProtectKeysWithCertificate(LoadProtectionCertificate())\n    // Always set an explicit application name\n    .SetApplicationName(\"identity-server\");\n```\n\n> **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.\n\n### Manual Key Rotation (Three-Phase Process)\n\nWhen using static keys, never swap them in a single deployment. Use a phased rotation to avoid breaking in-flight token validation:\n\n```csharp\n// Phase 1: Announce new key — continue signing with old key\n// Deploy and wait ≥ 24 h for JWKS caches to refresh\nidsvrBuilder.AddSigningCredential(oldKey, SecurityAlgorithms.RsaSha256);\nidsvrBuilder.AddValidationKey(newKey, SecurityAlgorithms.RsaSha256);\n\n// Phase 2: Switch to new key — retain old key for validation\n// Deploy and wait ≥ token lifetime (default 1 h) for old tokens to expire\nidsvrBuilder.AddSigningCredential(newKey, SecurityAlgorithms.RsaSha256);\nidsvrBuilder.AddValidationKey(oldKey, SecurityAlgorithms.RsaSha256);\n\n// Phase 3: Drop old key — old tokens are all expired\nidsvrBuilder.AddSigningCredential(newKey, SecurityAlgorithms.RsaSha256);\n```\n\n---\n\n## Pattern 3: Token Constraints — Lifetimes, Reference Tokens, and Audience Validation\n\nToken constraints limit the damage from token compromise and ensure tokens are only usable at their intended audience.\n\n### Token Lifetime Tuning\n\n```csharp\n// ✅ Production-tuned client — short-lived access tokens, rotating refresh tokens\nnew Client\n{\n    ClientId = \"web.app\",\n    AllowedGrantTypes = GrantTypes.Code,\n    RequirePkce = true,\n\n    // Short access token — reduces replay window\n    AccessTokenLifetime = 300,            // 5 minutes (default: 3600)\n\n    // Identity tokens are consumed immediately after login\n    IdentityTokenLifetime = 300,          // 5 minutes (default: 300)\n\n    // Refresh tokens rotate on every use — each use issues a new token\n    AllowOfflineAccess = true,\n    RefreshTokenUsage = TokenUsage.OneTimeOnly,\n    RefreshTokenExpiration = TokenExpiration.Absolute,\n    AbsoluteRefreshTokenLifetime = 86400, // 24 hours (default: 2592000 = 30 days)\n    SlidingRefreshTokenLifetime = 3600,   // 1 hour sliding window\n\n    // Revoke refresh tokens when the user's session ends\n    CoordinateLifetimeWithUserSession = true\n}\n```\n\n### Reference Tokens\n\nUse reference tokens when:\n- Tokens contain sensitive claims that must not be visible to intermediaries\n- Immediate revocation is required (JWTs remain valid until expiry)\n- Token size is a concern (reference tokens are short opaque handles)\n\n```csharp\n// ✅ Client configured for reference tokens\nnew Client\n{\n    ClientId = \"internal.api.consumer\",\n    AllowedGrantTypes = GrantTypes.ClientCredentials,\n    ClientSecrets = { new Secret(\"secret\".Sha256()) },\n\n    // Issue reference tokens instead of self-contained JWTs\n    AccessTokenType = AccessTokenType.Reference,\n\n    AllowedScopes = { \"internal-api\" }\n}\n```\n\nThe API must call the introspection endpoint to validate reference tokens:\n\n```csharp\n// ✅ API configured to validate reference tokens via introspection\nbuilder.Services\n    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)\n    .AddOAuth2Introspection(\"introspection\", options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.ClientId = \"internal-api\";\n        options.ClientSecret = \"api-secret\";\n    });\n```\n\n### Audience Validation\n\nAudience validation ensures an access token issued for one API cannot be replayed at a different API. Use `ApiResource` to set explicit `aud` claims:\n\n```csharp\n// ✅ Separate API resources = separate audiences\nnew ApiResource(\"catalog-api\", \"Product Catalog\")\n{\n    Scopes = { \"catalog.read\", \"catalog.write\" }\n},\nnew ApiResource(\"orders-api\", \"Order Management\")\n{\n    Scopes = { \"orders.manage\" }\n}\n```\n\nValidate audience on each API:\n\n```csharp\n// ✅ API validates its own audience\nbuilder.Services\n    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)\n    .AddJwtBearer(options =>\n    {\n        options.Authority = \"https://identity.example.com\";\n        options.Audience = \"catalog-api\"; // Must exactly match the ApiResource name\n        options.TokenValidationParameters.ValidateAudience = true;\n    });\n```\n\n---\n\n## Pattern 4: PKCE Enforcement\n\nPKCE prevents authorization code interception attacks. `RequirePkce = true` is the default in Duende IdentityServer and must never be disabled for any interactive client.\n\n```csharp\n// ✅ PKCE required (this is the default — shown explicitly for clarity)\nnew Client\n{\n    ClientId = \"web.app\",\n    AllowedGrantTypes = GrantTypes.Code,\n    RequirePkce = true, // DO NOT SET TO FALSE IN PRODUCTION\n    ClientSecrets = { new Secret(\"secret\".Sha256()) },\n    RedirectUris = { \"https://app.example.com/signin-oidc\" },\n    AllowedScopes = { \"openid\", \"profile\", \"api1\" }\n}\n```\n\n```csharp\n// ❌ WRONG — disabling PKCE for authorization code flow\nnew Client\n{\n    ClientId = \"legacy.app\",\n    AllowedGrantTypes = GrantTypes.Code,\n    RequirePkce = false, // Vulnerable to authorization code interception\n}\n```\n\nFor public clients (native apps, SPAs without BFF), PKCE is the *only* protection since they cannot hold a secret:\n\n```csharp\n// ✅ Public client — no secret, PKCE is mandatory\nnew Client\n{\n    ClientId = \"native.app\",\n    AllowedGrantTypes = GrantTypes.Code,\n    RequirePkce = true,\n    RequireClientSecret = false, // Public client — no secret\n\n    RedirectUris =\n    {\n        \"com.example.app:/callback\",      // Custom URI scheme for native apps\n        \"https://app.example.com/callback\" // HTTPS redirect for web\n    },\n    AllowedScopes = { \"openid\", \"profile\", \"api1\" }\n}\n```\n\n---\n\n## Pattern 5: Client Secret Management\n\nClient authentication quality directly determines the strength of the authorization boundary. Upgrade from shared secrets to asymmetric credentials wherever possible.\n\n### Hierarchy of Client Authentication Strength\n\n| Method | RFC | Strength | Secret Transmitted? |\n|--------|-----|----------|---------------------|\n| `client_secret_basic` | RFC 6749 | Low | Yes (over TLS) |\n| `client_secret_post` | RFC 6749 | Low | Yes (in body) |\n| `private_key_jwt` | RFC 7523 | High | No — only signed assertion |\n| `tls_client_auth` (mTLS) | RFC 8705 | High | No — certificate proves identity |\n\n### Shared Secret (Minimum Baseline — Avoid for Sensitive Clients)\n\n```csharp\n// ❌ Avoid — shared secrets can be extracted from config, logs, and memory\nnew Client\n{\n    ClientId = \"basic.client\",\n    ClientSecrets = { new Secret(\"my-secret\".Sha256()) }\n}\n```\n\nStore secrets outside source control. Never hash secrets inline with literals:\n\n```csharp\n// ✅ Load secret value from configuration, not code\nvar secretValue = configuration[\"IdentityServer:Clients:MyClient:Secret\"];\nnew Client\n{\n    ClientId = \"my-client\",\n    ClientSecrets = { new Secret(secretValue.Sha256()) }\n}\n```\n\n### Private Key JWT (Recommended)\n\nThe 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.\n\n```csharp\n// ✅ Register a client that authenticates with private_key_jwt\nnew Client\n{\n    ClientId = \"secure.service\",\n    AllowedGrantTypes = GrantTypes.ClientCredentials,\n    AllowedScopes = { \"api1\" },\n\n    ClientSecrets =\n    {\n        // Register the client's public key or certificate\n        new Secret\n        {\n            Type = IdentityServerConstants.SecretTypes.JsonWebKey,\n            Value = \"\"\"\n            {\n                \"kty\": \"RSA\",\n                \"use\": \"sig\",\n                \"kid\": \"my-key-id\",\n                \"n\": \"<base64url-encoded-modulus>\",\n                \"e\": \"AQAB\"\n            }\n            \"\"\"\n        }\n    }\n}\n```\n\nThe client sends a signed JWT assertion at the token endpoint (using Duende.AccessTokenManagement or IdentityModel):\n\n```csharp\n// ✅ Client-side: authenticate with a signed assertion\nvar tokenRequest = new ClientCredentialsTokenRequest\n{\n    Address = disco.TokenEndpoint,\n    ClientId = \"secure.service\",\n    ClientAssertion = new ClientAssertion\n    {\n        Type = OidcConstants.ClientAssertionTypes.JwtBearer,\n        Value = BuildClientAssertionJwt(clientId, tokenEndpoint, privateKey)\n    },\n    Scope = \"api1\"\n};\n```\n\n### Secret Rotation\n\nNever rotate secrets with a hard cut-over. Register the new secret alongside the old one, deploy clients, then remove the old secret:\n\n```csharp\n// ✅ Two active secrets during rotation window\nnew Client\n{\n    ClientId = \"my-service\",\n    ClientSecrets =\n    {\n        new Secret(currentSecret.Sha256()),\n        new Secret(newSecret.Sha256()) // New secret pre-registered\n    }\n}\n// After all clients are updated: remove currentSecret\n```\n\n### Custom Secret Validation (`ISecretValidator`)\n\nImplement `ISecretValidator` to enforce custom secret policies (e.g., key minimum length, algorithm restrictions):\n\n```csharp\n// ✅ Custom validator that rejects secrets shorter than 32 characters\npublic sealed class MinimumLengthSecretValidator : ISecretValidator\n{\n    public Task<SecretValidationResult> ValidateAsync(\n        IEnumerable<Secret> secrets, ParsedSecret parsedSecret)\n    {\n        if (parsedSecret.Type != IdentityServerConstants.ParsedSecretTypes.SharedSecret)\n            return Task.FromResult(new SecretValidationResult { Success = false });\n\n        var value = parsedSecret.Credential as string;\n        if (value is null || value.Length < 32)\n        {\n            return Task.FromResult(new SecretValidationResult\n            {\n                Success = false,\n                Error = \"Secret does not meet minimum length requirements\"\n            });\n        }\n\n        // Delegate to default validation\n        return Task.FromResult(new SecretValidationResult { Success = true });\n    }\n}\n```\n\n---\n\n## Pattern 6: Redirect URI Validation\n\nAuthorization code injection via open redirectors is one of the most critical OAuth attack vectors. Redirect URI validation must be exact-match in production.\n\n### Strict Matching (Default Behavior)\n\nDuende IdentityServer validates redirect URIs by exact string comparison. This is the correct behavior:\n\n```csharp\n// ✅ Exact URIs — no trailing slash ambiguity, no wildcards\nnew Client\n{\n    ClientId = \"web.app\",\n    RedirectUris =\n    {\n        \"https://app.example.com/signin-oidc\"\n    },\n    PostLogoutRedirectUris =\n    {\n        \"https://app.example.com/signout-callback-oidc\"\n    }\n}\n```\n\n```csharp\n// ❌ WRONG — wildcards allow an attacker to redirect to a malicious host\nnew Client\n{\n    RedirectUris = { \"https://*.example.com/callback\" } // Never do this\n}\n```\n\n### Custom Redirect URI Validator\n\nFor legitimate dynamic scenarios (e.g., multi-tenant apps with per-tenant domains), implement `IRedirectUriValidator` with explicit allow-listing from a trusted data source:\n\n```csharp\n// ✅ Custom validator that allows tenant subdomains from a verified list\npublic sealed class TenantRedirectUriValidator : IRedirectUriValidator\n{\n    private readonly ITenantRegistry _tenants;\n\n    public TenantRedirectUriValidator(ITenantRegistry tenants) => _tenants = tenants;\n\n    public async Task<bool> IsRedirectUriValidAsync(string requestedUri, Client client)\n    {\n        // Allow standard registered URIs first\n        if (client.RedirectUris.Contains(requestedUri))\n            return true;\n\n        // Allow per-tenant URIs — always validate against a trusted data source\n        var uri = new Uri(requestedUri);\n        return await _tenants.IsAllowedCallbackAsync(uri);\n    }\n\n    public async Task<bool> IsPostLogoutRedirectUriValidAsync(\n        string requestedUri, Client client)\n    {\n        if (client.PostLogoutRedirectUris.Contains(requestedUri))\n            return true;\n\n        var uri = new Uri(requestedUri);\n        return await _tenants.IsAllowedCallbackAsync(uri);\n    }\n}\n```\n\nRegister the custom validator:\n\n```csharp\n// ✅ Replace the default validator\nbuilder.Services.AddTransient<IRedirectUriValidator, TenantRedirectUriValidator>();\n```\n\n---\n\n## Pattern 7: Grant Type Restrictions\n\nEach enabled grant type expands the attack surface. Disable every grant type a client does not use.\n\n### Disable Implicit Flow Globally\n\nImplicit flow is deprecated by RFC 9700. Ensure no client uses it:\n\n```csharp\n// ❌ WRONG — implicit flow exposes tokens in browser history and referrer headers\nnew Client\n{\n    AllowedGrantTypes = GrantTypes.Implicit\n}\n\n// ✅ CORRECT — use authorization code + PKCE for all interactive clients\nnew Client\n{\n    AllowedGrantTypes = GrantTypes.Code,\n    RequirePkce = true\n}\n```\n\n### Principle of Least Grant\n\n```csharp\n// ✅ Machine-to-machine service: only client_credentials\nnew Client\n{\n    ClientId = \"background.worker\",\n    AllowedGrantTypes = GrantTypes.ClientCredentials,\n    // AllowOfflineAccess = false (default) — no refresh tokens for M2M\n}\n\n// ✅ Interactive web app: only authorization code\nnew Client\n{\n    ClientId = \"web.app\",\n    AllowedGrantTypes = GrantTypes.Code,\n    RequirePkce = true\n}\n\n// ❌ WRONG — granting more than needed\nnew Client\n{\n    ClientId = \"web.app\",\n    AllowedGrantTypes = GrantTypes.CodeAndClientCredentials // Never combine user + M2M flows\n}\n```\n\n### Custom Grant Validation\n\nFor extension grants, always validate the grant assertion rigorously:\n\n```csharp\n// ✅ Extension grant with strict validation\npublic sealed class TokenExchangeGrantValidator : IExtensionGrantValidator\n{\n    public string GrantType => \"urn:ietf:params:oauth:grant-type:token-exchange\";\n\n    public async Task ValidateAsync(ExtensionGrantValidationContext context)\n    {\n        var subjectToken = context.Request.Raw.Get(\"subject_token\");\n        if (string.IsNullOrWhiteSpace(subjectToken))\n        {\n            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest,\n                \"subject_token is required\");\n            return;\n        }\n\n        // Validate the subject token — never trust without verification\n        var principal = await ValidateSubjectTokenAsync(subjectToken);\n        if (principal is null)\n        {\n            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant,\n                \"subject_token is invalid or expired\");\n            return;\n        }\n\n        context.Result = new GrantValidationResult(\n            subject: principal.GetSubjectId(),\n            authenticationMethod: GrantType);\n    }\n}\n```\n\n---\n\n## Pattern 8: CORS Configuration\n\nSet `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.\n\n> See [docs/cors-csp.md](docs/cors-csp.md) for the complete `ICorsPolicyService` implementation and CORS configuration examples.\n\n---\n\n## Pattern 9: Content Security Policy\n\nAdd 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.\n\n> See [docs/cors-csp.md](docs/cors-csp.md) for the complete CSP middleware implementation with inline examples.\n\n---\n\n## Pattern 10: Rate Limiting\n\nDuende 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).\n\n> See [docs/rate-limiting.md](docs/rate-limiting.md) for the complete rate limiter configuration, the protocol-endpoint caveat, and the `ICustomTokenRequestValidator` approach.\n\n---\n\n## Pattern 11: Session Security\n\nEnable 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.\n\n> See [docs/session-hardening.md](docs/session-hardening.md) for the complete session configuration and back-channel logout client setup.\n\n---\n\n## Pattern 12: Input Validation and `InputLengthRestrictions`\n\nIdentityServer validates all incoming request parameters against configurable length limits. Tighten these to reduce injection and memory exhaustion risks.\n\n```csharp\n// ✅ Tightened input length restrictions\nbuilder.Services.AddIdentityServer(options =>\n{\n    // Scope values — tighten to your longest actual scope name\n    options.InputLengthRestrictions.Scope = 300;         // default: 300\n\n    // Client ID — match your longest client ID\n    options.InputLengthRestrictions.ClientId = 100;      // default: 100\n\n    // Client secret — limit to prevent memory abuse\n    options.InputLengthRestrictions.ClientSecret = 100;  // default: 100\n\n    // Redirect URI — match your longest registered URI\n    options.InputLengthRestrictions.RedirectUri = 400;   // default: 400\n\n    // Nonce — OpenID Connect replay protection\n    options.InputLengthRestrictions.Nonce = 300;         // default: 300\n\n    // Code challenge for PKCE — use the correct min/max length properties\n    // (verify exact property names against current Duende IdentityServer source,\n    //  e.g. CodeChallengeMinLength / CodeChallengeMaxLength)\n    options.InputLengthRestrictions.CodeChallengeMinLength = 43;  // RFC 7636 minimum\n    options.InputLengthRestrictions.CodeChallengeMaxLength = 128; // RFC 7636 maximum\n});\n```\n\n---\n\n## Pattern 13: Audit Logging via Events\n\nEvents 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.\n\n**Events are NOT enabled by default.** Turn them on in `AddIdentityServer`:\n\n```csharp\n// ✅ Enable audit events\nbuilder.Services.AddIdentityServer(options =>\n{\n    options.Events.RaiseSuccessEvents = true;\n    options.Events.RaiseFailureEvents = true;\n    options.Events.RaiseErrorEvents = true;\n    options.Events.RaiseInformationEvents = true;\n});\n```\n\nIdentityServer raises **protocol** events itself, but **UI actions (login success/failure) must be raised by your UI code**. Inject `IEventService` and call `RaiseAsync(...)`:\n\n```csharp\n// ✅ Raise UI login events from your account controller/page\npublic LoginModel(IEventService events) => _events = events;\n\nawait _events.RaiseAsync(\n    new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username));\n// or on failure:\nawait _events.RaiseAsync(\n    new UserLoginFailureEvent(username, \"invalid credentials\"));\n```\n\n### Custom Sink — Replaces the Default Sink\n\nImplement `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.\n\n```csharp\n// ✅ Forward audit events to Seq (Serilog.Sinks.Seq) — and keep logging\npublic sealed class SeqEventSink : IEventSink\n{\n    private readonly ILogger<SeqEventSink> _logger; // default sink is replaced — log here\n    public SeqEventSink(ILogger<SeqEventSink> logger) => _logger = logger;\n\n    public Task PersistAsync(Event evt)\n    {\n        _logger.LogInformation(\"{Name} ({Id}) {@Event}\", evt.Name, evt.Id, evt);\n        return Task.CompletedTask;\n    }\n}\n\n// Registration — replaces the built-in logger sink\nservices.AddTransient<IEventSink, SeqEventSink>();\n```\n\nCustom events derive from the base **`Event`** class with a **unique event id**.\n\n---\n\n## Common Pitfalls\n\n### 1. Disabling PKCE\n\n```csharp\n// ❌ WRONG — authorization code interception becomes trivially exploitable\nnew Client { RequirePkce = false }\n\n// ✅ CORRECT — RequirePkce = true is the default; never override it to false\nnew Client { RequirePkce = true }\n```\n\n### 2. Wildcard Redirect URIs\n\n```csharp\n// ❌ WRONG — open redirector: attacker steers code to their server\nRedirectUris = { \"https://*.example.com/*\" }\n\n// ✅ CORRECT — fully qualified, exact-match URIs only\nRedirectUris = { \"https://app.example.com/signin-oidc\" }\n```\n\n### 3. Implicit Flow Still Enabled\n\n```csharp\n// ❌ WRONG — exposes tokens in URL fragments, browser history, referrer headers\nAllowedGrantTypes = GrantTypes.Implicit\n\n// ✅ CORRECT — authorization code + PKCE replaces implicit flow entirely\nAllowedGrantTypes = GrantTypes.Code\n```\n\n### 4. Accepting `ForwardedHeaders` From Any Source\n\n```csharp\n// ❌ WRONG — attacker can spoof X-Forwarded-Proto: https from any IP\noptions.ForwardedHeaders = ForwardedHeaders.XForwardedProto;\n// KnownProxies is empty = accepts from anywhere\n\n// ✅ CORRECT — restrict to known proxy IPs\noptions.KnownProxies.Add(IPAddress.Parse(\"10.0.0.1\"));\n```\n\n### 5. Plaintext Secrets in Source Control\n\n```csharp\n// ❌ WRONG — secret is committed to git history\nClientSecrets = { new Secret(\"SuperSecret123\".Sha256()) }\n\n// ✅ CORRECT — load from secret store or environment variable\nClientSecrets = { new Secret(config[\"Services:MyClient:Secret\"].Sha256()) }\n```\n\n### 6. HTTP Issuer URI\n\n```csharp\n// ❌ WRONG — discovery document publishes http:// issuer; APIs reject all tokens\n// Caused by missing ForwardedHeaders middleware behind a TLS-terminating proxy\n\n// ✅ CORRECT — configure ForwardedHeaders before UseIdentityServer()\n// OR set the issuer explicitly\noptions.IssuerUri = \"https://identity.example.com\";\n```\n\n### 7. Long-Lived Access Tokens\n\n```csharp\n// ❌ WRONG — 8-hour access token gives attackers a huge replay window\nAccessTokenLifetime = 28800\n\n// ✅ CORRECT — 5–15 minutes; use refresh tokens for longer sessions\nAccessTokenLifetime = 300\n```\n\n### 8. Missing Audience Validation at the API\n\n```csharp\n// ❌ WRONG — API accepts any token from the issuer, regardless of audience\noptions.TokenValidationParameters = new TokenValidationParameters\n{\n    ValidateAudience = false // Dangerous — token from any client works at any API\n};\n\n// ✅ CORRECT — validate the audience matches this specific API\noptions.Audience = \"my-api\";\noptions.TokenValidationParameters.ValidateAudience = true;\n```\n\n### 9. Shared Keys Across Environments\n\n```csharp\n// ❌ WRONG — development signing key committed to source control and reused in production\nidsvrBuilder.AddDeveloperSigningCredential(); // Development only!\n\n// ✅ CORRECT — automatic key management generates and rotates keys per-environment\n// Each environment has its own isolated key material\noptions.KeyManagement.Enabled = true;\noptions.KeyManagement.DataProtectKeys = true;\n```\n\n---\n\n## Production Security Checklist\n\n| Area | Control | Status |\n|------|---------|--------|\n| Transport | HTTPS enforced with `UseHttpsRedirection()` | Required |\n| Transport | HSTS with `IncludeSubDomains = true`, `MaxAge` ≥ 1 year | Required |\n| Transport | TLS 1.2+ minimum on Kestrel | Required |\n| Transport | `ForwardedHeaders` restricted to known proxy IPs | Required if behind proxy |\n| Keys | Automatic key management enabled (`KeyManagement.Enabled = true`) | Required |\n| Keys | `DataProtectKeys = true` + Data Protection configured with durable storage | Required |\n| Keys | `PropagationTime` ≥ 24 h and `RetentionDuration` ≥ token lifetime | Required |\n| Keys | ES256 or RS256 (never HS256 for asymmetric signing) | Required |\n| Tokens | `AccessTokenLifetime` ≤ 300 s for interactive clients | Recommended |\n| Tokens | `RefreshTokenUsage = OneTimeOnly` | Required |\n| Tokens | Audience validation enabled at every API | Required |\n| Clients | `RequirePkce = true` on every authorization code client | Required |\n| Clients | No implicit flow (`GrantTypes.Implicit`) in any client | Required |\n| Clients | No wildcard redirect URIs | Required |\n| Clients | Secrets loaded from vault/config, not source code | Required |\n| Clients | Certificate or `private_key_jwt` auth for sensitive M2M clients | Recommended |\n| CORS | `AllowedCorsOrigins` set per-client; no `AllowAnyOrigin` | Required |\n| CSP | `frame-ancestors 'none'` and `object-src 'none'` on UI pages | Required |\n| CSP | `X-Frame-Options: DENY` on all IdentityServer pages | Required |\n| Sessions | `CookieSlidingExpiration = false` | Recommended |\n| Sessions | Server-side sessions enabled with back-channel logout | Recommended |\n| Sessions | `CoordinateClientLifetimesWithUserSession = true` | Recommended |\n| Rate Limiting | Token endpoint rate-limited per client IP | Required |\n| Events | `RaiseErrorEvents`, `RaiseFailureEvents` both `true` | Required |\n\n---\n\n## Resources\n\n- [Duende IdentityServer Deployment — Duende Docs](https://docs.duendesoftware.com/identityserver/deployment/)\n- [Key Management — Duende Docs](https://docs.duendesoftware.com/identityserver/fundamentals/key-management/)\n- [Client Authentication — Duende Docs](https://docs.duendesoftware.com/identityserver/tokens/client-authentication/)\n- [CORS — Duende Docs](https://docs.duendesoftware.com/identityserver/tokens/cors/)\n- [Reference Tokens — Duende Docs](https://docs.duendesoftware.com/identityserver/tokens/reference/)\n- [Server-Side Sessions — Duende Docs](https://docs.duendesoftware.com/identityserver/ui/server-side-sessions/)\n- [Pushed Authorization Requests — Duende Docs](https://docs.duendesoftware.com/identityserver/tokens/par/)\n- [IdentityServerOptions Reference — Duende Docs](https://docs.duendesoftware.com/identityserver/reference/options/)\n- [OAuth 2.0 Security Best Current Practice (RFC 9700)](https://www.rfc-editor.org/rfc/rfc9700)\n- [PKCE (RFC 7636)](https://tools.ietf.org/html/rfc7636)\n- [JWT Client Authentication (RFC 7523)](https://datatracker.ietf.org/doc/html/rfc7523)\n- [mTLS Client Authentication (RFC 8705)](https://www.rfc-editor.org/rfc/rfc8705)\n- [OWASP OAuth 2.0 Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html)\n- [ASP.NET Core Data Protection — Microsoft Docs](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview)\n- [ASP.NET Core Rate Limiting — Microsoft Docs](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit)\n"
}

SHA-256: 8deffb228181c13ed3e2978f3a2fffddf22ca2754b2bd722992e88ef6167597f