← Plugin catalog
Developer Tools
Duende Skills
Duende Software v0.3.0
Publisher description
From the marketplace listing
Specialized skills and agents covering Duende IdentityServer configuration, OAuth 2.0 / OpenID Connect protocols, ASP.NET Core authentication and authorization, BFF patterns, token management, SAML, key management, deployment, and security hardening.
Language: English · Automatically detected from descriptions.
Files & skills
File archives
Plugin package48 files · 191 KBBrowse files →
Skill instructions
aspnetcore-authentication19.4 KB
---
name: aspnetcore-authentication
description: ASP.NET Core authentication middleware configuration including OpenID Connect, JWT Bearer, cookie authentication, authentication schemes, challenge/forbid flows, and external identity provider integration.
invocable: false
---
# ASP.NET Core Authentication
## When to Use This Skill
Use this skill when:
- Configuring OIDC authentication in an ASP.NET Core web application
- Setting up JWT Bearer authentication for an API
- Managing authentication schemes (cookies, OIDC, JWT, external providers)
- Implementing challenge, sign-in, sign-out, and forbid flows
- Debugging authentication failures (401s, redirect loops, claim mapping issues)
- Integrating with Duende IdentityServer as an OpenID Connect provider
- Configuring token validation parameters
## Core Principles
1. **Authentication ≠ Authorization** — Authentication establishes *who* the user is. Authorization (see `aspnetcore-authorization`) determines *what* they can do.
2. **Scheme-Based Architecture** — ASP.NET Core authentication is built around named schemes. Each scheme has a handler that knows how to authenticate, challenge, and sign out.
3. **Cookies for Web Apps, JWT for APIs** — Web applications use cookie authentication (with OIDC for login). APIs use JWT Bearer or introspection.
4. **Never Roll Your Own** — Use the built-in OIDC and JWT Bearer handlers. They handle nonce validation, key rotation, token validation, and dozens of edge cases.
5. **Claim Type Mapping Matters** — The OIDC handler maps JWT claim types to .NET claim types by default. Disable this for predictable claim names.
## Related Skills
- `aspnetcore-authorization` — Policy-based authorization after authentication
- `identityserver-configuration` — Server-side client and resource configuration
- `identityserver-sessions-providers` — Server-side sessions to reduce cookie size and maintain IdP-side data
- `oauth-oidc-protocols` — Protocol fundamentals underlying these handlers
- `token-management` — Automatic token refresh with Duende.AccessTokenManagement
Docs: https://docs.duendesoftware.com/identityserver/apis/aspnetcore/jwt/
---
## Pattern 1: OIDC Authentication for Web Applications
The most common pattern — a server-rendered web app authenticating users via Duende IdentityServer:
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "Cookies";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies", options =>
{
options.Cookie.Name = "myapp";
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "web.app";
options.ClientSecret = "secret";
options.ResponseType = "code"; // Authorization code flow
// Map scopes to request
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("api1");
options.Scope.Add("offline_access"); // For refresh tokens
// Save tokens in the authentication cookie
options.SaveTokens = true;
// Disable Microsoft's JWT claim type mapping
options.MapInboundClaims = false;
// Where to get additional user claims
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role"
};
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
```
### Critical Settings Explained
| Setting | Why | Default |
|---------|-----|---------|
| `MapInboundClaims = false` | Prevents renaming `sub` → `http://schemas.xmlsoap.org/.../nameidentifier` | `true` (maps) |
| `SaveTokens = true` | Stores access/refresh tokens in the cookie for later API calls | `false` |
| `GetClaimsFromUserInfoEndpoint = true` | Fetches full profile claims from userinfo | `false` |
| `ResponseType = "code"` | Authorization code flow (PKCE is automatic in .NET 7+) | `"code"` (.NET 7+; was `"code id_token"` in earlier versions) |
---
## Pattern 2: JWT Bearer Authentication for APIs
APIs validate access tokens issued by IdentityServer:
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "catalog-api"; // Must match ApiResource name
options.MapInboundClaims = false; // Must be included
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = true,
ValidAudience = "catalog-api",
NameClaimType = "name",
RoleClaimType = "role"
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Protect endpoints
app.MapGet("/products", () => Results.Ok())
.RequireAuthorization();
```
### Multiple Audiences
When an API accepts tokens from multiple resources:
```csharp
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = true,
ValidAudiences = new[] { "catalog-api", "shared-api" }
};
```
---
## Pattern 3: Reference Token Introspection
For APIs that validate reference tokens (opaque tokens) instead of JWTs:
```csharp
builder.Services.AddAuthentication("Bearer")
.AddOAuth2Introspection("Bearer", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "catalog-api";
options.ClientSecret = "api-secret";
});
```
> Install the `Duende.AspNetCore.Authentication.JwtBearer` package which supports both JWT and reference token validation, switching automatically based on the token format.
### Combined JWT + Reference Token Support
```csharp
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://identity.example.com";
options.MapInboundClaims = false;
// The Duende JWT handler can forward to introspection for reference tokens
options.ForwardDefaultSelector = Selector.ForwardReferenceToken("introspection");
})
.AddOAuth2Introspection("introspection", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "catalog-api";
options.ClientSecret = "api-secret";
});
```
---
## Pattern 4: Understanding Authentication Schemes
ASP.NET Core uses named authentication schemes. Each scheme is handled by a specific handler.
### Default Schemes
```csharp
builder.Services.AddAuthentication(options =>
{
// Used for [Authorize] attribute and User.Identity
options.DefaultScheme = "Cookies";
// Used when authentication is required (401 → redirect to login)
options.DefaultChallengeScheme = "oidc";
// Used when access is denied (403)
options.DefaultForbidScheme = "oidc";
// Used when signing in (setting the cookie after OIDC callback)
options.DefaultSignInScheme = "Cookies";
// Used when signing out
options.DefaultSignOutScheme = "oidc";
});
```
### The Authentication Flow
```
Request → [UseAuthentication] → Cookie handler reads cookie
├─ Valid cookie → User is authenticated
└─ No cookie → User is anonymous
[UseAuthorization] → [Authorize] attribute checks
├─ Authenticated → proceed
└─ Not authenticated → Challenge
└─ OIDC handler redirects to IdentityServer
└─ User logs in → callback → cookie created
```
---
## Pattern 5: Claim Type Mapping
By default, the Microsoft OIDC handler remaps JWT claims to XML-based .NET claim types. This causes confusion:
### The Mapping Problem
| JWT Claim | .NET Default Mapping | After `MapInboundClaims = false` |
|-----------|---------------------|----------------------------------|
| `sub` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` | `sub` |
| `name` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` | `name` |
| `role` | `http://schemas.microsoft.com/ws/2008/06/identity/claims/role` | `role` |
| `email` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | `email` |
### The Fix — Always Disable Mapping
```csharp
// ✅ On the OIDC handler
options.MapInboundClaims = false;
// ✅ On the JWT Bearer handler
options.MapInboundClaims = false;
// ✅ Then tell ASP.NET Core which claims to use for Name and Role
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name", // Strongly recommended to use "name"
RoleClaimType = "role" // Strongly recommended to use "role"
};
```
> **Why this matters:** Without this, `User.FindFirst("sub")` returns `null` because the claim was renamed. You'd need to use the verbose XML URI instead.
---
## Pattern 6: OIDC Handler Events
The OIDC handler exposes events for customizing the authentication pipeline:
```csharp
.AddOpenIdConnect("oidc", options =>
{
// ... other options ...
options.Events = new OpenIdConnectEvents
{
// Customize the authorize request (e.g., add acr_values)
OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.AcrValues = "tenant:myorg";
return Task.CompletedTask;
},
// Handle tokens after successful authentication
OnTokenValidated = context =>
{
// Add custom claims to the identity
var identity = context.Principal!.Identity as ClaimsIdentity;
identity?.AddClaim(new Claim("app_version", "2.0"));
return Task.CompletedTask;
},
// Handle sign-out redirect
OnRedirectToIdentityProviderForSignOut = context =>
{
// Customize the logout redirect
return Task.CompletedTask;
},
// Handle failures
OnRemoteFailure = context =>
{
context.HandleResponse();
context.Response.Redirect("/error?message=" +
Uri.EscapeDataString(context.Failure?.Message ?? "Unknown error"));
return Task.CompletedTask;
}
};
});
```
### Common Event Use Cases
| Event | Use Case |
|-------|----------|
| `OnRedirectToIdentityProvider` | Add `acr_values`, `login_hint`, or custom parameters |
| `OnTokenValidated` | Transform claims, load additional user data |
| `OnTokenResponseReceived` | Inspect raw token response |
| `OnRemoteFailure` | Custom error handling for failed logins |
| `OnSignedOutCallbackRedirect` | Custom post-logout redirect |
---
## Pattern 7: Sign-Out
Proper sign-out must clear both the local cookie and the IdentityServer session:
```csharp
// In a Razor Page or Controller
app.MapGet("/logout", async (HttpContext ctx) =>
{
// Signs out of both the cookie and IdentityServer
await ctx.SignOutAsync("Cookies");
await ctx.SignOutAsync("oidc");
});
```
### The Sign-Out Flow
```
1. Client calls SignOutAsync("Cookies") → clears local cookie
2. Client calls SignOutAsync("oidc") → redirects to IS /connect/endsession
3. IdentityServer clears its session
4. IdentityServer notifies other clients → front-channel or back-channel logout
5. IdentityServer redirects to PostLogoutRedirectUri
```
> **Important:** Calling only `SignOutAsync("Cookies")` without `SignOutAsync("oidc")` leaves the IdentityServer session active. The user will be silently re-authenticated on the next challenge.
---
## Pattern 8: Accessing Stored Tokens
When `SaveTokens = true`, the access token, refresh token, and ID token are stored in the authentication cookie:
```csharp
// In a controller or middleware
var accessToken = await HttpContext.GetTokenAsync("access_token");
var refreshToken = await HttpContext.GetTokenAsync("refresh_token");
var idToken = await HttpContext.GetTokenAsync("id_token");
var expiresAt = await HttpContext.GetTokenAsync("expires_at");
// Use the access token to call an API
httpClient.SetBearerToken(accessToken);
```
> **Better approach:** Use `Duende.AccessTokenManagement` (see `token-management` skill) which handles token refresh, caching, and rotation automatically instead of manually managing stored tokens.
---
## Pattern 9: mTLS (Certificate-Bound Tokens) with the OIDC Handler
When IdentityServer issues certificate-bound tokens via mTLS (RFC 8705), the OIDC client must (1) present its client certificate on all back-channel calls and (2) target the mTLS endpoint aliases. The stock handler does neither on its own.
### Step 1 — Present the client certificate on back-channel calls
Set `BackchannelHttpHandler` so code redemption, refresh, and userinfo calls run over a mutually-authenticated TLS channel. No client secret is needed — the certificate authenticates the client:
```csharp
var clientCert = X509CertificateLoader.LoadPkcs12(File.ReadAllBytes("client.p12"), "password");
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "mtls.client";
// no ClientSecret — the certificate authenticates the client
options.ResponseType = "code";
options.MapInboundClaims = false;
options.SaveTokens = true;
options.BackchannelHttpHandler = new SocketsHttpHandler
{
SslOptions = new SslClientAuthenticationOptions
{
ClientCertificates = new X509CertificateCollection { clientCert }
}
};
});
```
### Step 2 — Point the handler at `mtls_endpoint_aliases`
The stock OIDC handler reads endpoints from standard discovery metadata and does **not** understand `mtls_endpoint_aliases` — it would call the non-mTLS `token_endpoint`. Wrap the standard configuration manager and rewrite the endpoints to their mTLS aliases:
```csharp
public sealed class MtlsConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>
{
private readonly ConfigurationManager<OpenIdConnectConfiguration> _inner;
public MtlsConfigurationManager(ConfigurationManager<OpenIdConnectConfiguration> inner)
=> _inner = inner;
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken ct)
{
var config = await _inner.GetConfigurationAsync(ct);
if (config.AdditionalData.TryGetValue("mtls_endpoint_aliases", out var raw)
&& raw is JsonElement aliases)
{
config.TokenEndpoint = aliases.GetProperty("token_endpoint").GetString();
config.IntrospectionEndpoint = aliases.GetProperty("introspection_endpoint").GetString();
config.DeviceAuthorizationEndpoint = aliases.GetProperty("device_authorization_endpoint").GetString();
// .NET 9+ auto-uses PAR when advertised — rewrite it too, or the handler
// pushes to the non-mTLS PAR endpoint and the certificate binding is lost
config.PushedAuthorizationRequestEndpoint = aliases.GetProperty("pushed_authorization_request_endpoint").GetString();
// revocation has no strongly-typed slot — keep the mTLS value in AdditionalData
config.AdditionalData["revocation_endpoint"] = aliases.GetProperty("revocation_endpoint").GetString();
}
return config;
}
public void RequestRefresh() => _inner.RequestRefresh();
}
// Wire it onto the handler
.AddOpenIdConnect("oidc", options =>
{
// ... options from Step 1 ...
options.ConfigurationManager = new MtlsConfigurationManager(
new ConfigurationManager<OpenIdConnectConfiguration>(
$"{options.Authority}/.well-known/openid-configuration",
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever { RequireHttps = true }));
});
```
> **PAR + mTLS on .NET 9+:** because the handler automatically uses PAR when the server advertises a `pushed_authorization_request_endpoint`, you **must** rewrite that endpoint to the mTLS alias as well. Otherwise the pushed authorization request goes to the non-mTLS endpoint and the certificate binding is lost.
---
## Common Pitfalls
### 1. Forgetting MapInboundClaims
```csharp
// ❌ WRONG — Claims have XML URIs, User.FindFirst("sub") returns null
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://identity.example.com";
// MapInboundClaims defaults to true
});
// ✅ CORRECT
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://identity.example.com";
options.MapInboundClaims = false;
});
```
### 2. Missing UseAuthentication Before UseAuthorization
```csharp
// ❌ WRONG — Authorization middleware can't see the authenticated user
app.UseAuthorization();
app.UseAuthentication(); // Too late!
// ✅ CORRECT — Authentication must come first
app.UseAuthentication();
app.UseAuthorization();
```
### 3. Not Clearing Scopes Before Adding
```csharp
// ❌ WRONG — Default scopes (openid, profile) are already added
options.Scope.Add("openid"); // Duplicate!
options.Scope.Add("profile"); // Duplicate!
options.Scope.Add("api1");
// ✅ CORRECT — Clear defaults first
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("api1");
```
### 4. Cookie Too Large (>4KB)
When `SaveTokens = true` and many claims are included, the cookie can exceed browser limits:
```csharp
// ✅ Solution 1: Use a server-side ITicketStore to move auth ticket out of the cookie.
// Implement ITicketStore backed by IDistributedCache (e.g., Redis), then register it:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
builder.Services.AddSingleton<ITicketStore, RedisTicketStore>(); // your ITicketStore impl
.AddCookie("Cookies", options =>
{
// Wire the ITicketStore so the cookie only holds a session key, not the full ticket
options.SessionStore = app.Services.GetRequiredService<ITicketStore>();
});
// Note: ITicketStore is in Microsoft.AspNetCore.Authentication.Cookies namespace.
// There is no built-in DistributedSessionStore class — you must implement ITicketStore.
// ✅ Solution 2: Filter claims stored in the cookie
.AddOpenIdConnect("oidc", options =>
{
options.ClaimActions.DeleteClaims("sid", "idp", "auth_time", "amr");
});
// ✅ Solution 3: Use Duende IdentityServer server-side sessions
```
### 5. Redirect Loop After Login
Usually caused by the cookie not being set due to SameSite restrictions:
```csharp
// ✅ Check SameSite settings
.AddCookie("Cookies", options =>
{
options.Cookie.SameSite = SameSiteMode.Lax; // Not Strict for OIDC callbacks
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
```
---
## Resources
- [ASP.NET Core Authentication — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authentication/)
- [OpenID Connect Handler — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authentication/social/)
- [JWT Bearer Handler — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authentication/configure-jwt-bearer-authentication)
- [Duende IdentityServer Quickstarts](https://docs.duendesoftware.com/identityserver/quickstarts/)
- [OIDC Handler Events — Duende Docs](https://docs.duendesoftware.com/identityserver/fundamentals/openid-connect-events/)
aspnetcore-authorization15.3 KB
---
name: aspnetcore-authorization
description: ASP.NET Core authorization patterns including policy-based authorization, IAuthorizationHandler implementations, scope-based authorization for APIs, authorization middleware configuration, and minimal API authorization.
invocable: false
---
# ASP.NET Core Authorization
## When to Use This Skill
Use this skill when:
- Implementing policy-based authorization in ASP.NET Core
- Protecting API endpoints with scope-based or claim-based checks
- Writing custom `IAuthorizationHandler` implementations
- Configuring authorization for Minimal APIs, controllers, or Razor Pages
- Enforcing role-based access control using OIDC claims
- Combining multiple authorization requirements into composite policies
## Core Principles
1. **Policy-Based Over Role-Based** — Use authorization policies instead of `[Authorize(Roles = "...")]`. Policies are composable, testable, and decoupled from claim types.
2. **Scope ≠ Permission** — OAuth scopes represent what the *client* is allowed to do. User claims represent what the *user* is allowed to do. Combine both for proper API authorization.
3. **Authorization is Separate from Authentication** — Authentication (see `aspnetcore-authentication`) establishes identity. Authorization decides access based on that identity.
4. **Fail Closed** — Default to denying access. Require explicit authorization on all endpoints.
5. **Resource-Based When Needed** — For decisions that depend on the resource being accessed (e.g., "can this user edit this document?"), use `IAuthorizationService` with resource-based authorization.
## Related Skills
- `aspnetcore-authentication` — Authentication middleware that provides the identity
- `claims-authorization` — Advanced claims transformation and authorization patterns
- `identityserver-configuration` — Server-side scope and resource configuration
- `oauth-oidc-protocols` — Understanding scopes, claims, and token contents
Docs: https://docs.duendesoftware.com/identityserver/apis/aspnetcore/authorization/
---
## Pattern 1: Basic Policy-Based Authorization
Define policies at startup and reference them on endpoints:
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization(options =>
{
// Policy that requires the user to be authenticated
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
// Policy requiring a specific scope in the access token
options.AddPolicy("read:catalog", policy =>
policy.RequireClaim("scope", "catalog.read"));
// Policy requiring a specific role
options.AddPolicy("admin", policy =>
policy.RequireRole("admin"));
// Policy combining multiple requirements
options.AddPolicy("catalog-editor", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "catalog.write");
policy.RequireClaim("department", "merchandising");
});
});
```
### Applying Policies
```csharp
// Minimal API
app.MapGet("/products", () => Results.Ok())
.RequireAuthorization("read:catalog");
// Controller
[Authorize(Policy = "catalog-editor")]
public class CatalogController : ControllerBase { }
// Razor Page
[Authorize(Policy = "admin")]
public class AdminModel : PageModel { }
```
---
## Pattern 2: Scope-Based Authorization for APIs
APIs protected by IdentityServer need to validate scopes from the access token. Scopes represent what the *client application* is permitted to do.
### Simple Scope Check
```csharp
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("api.read", policy =>
policy.RequireClaim("scope", "catalog.read"));
options.AddPolicy("api.write", policy =>
policy.RequireClaim("scope", "catalog.write"));
});
app.MapGet("/products", GetProducts).RequireAuthorization("api.read");
app.MapPost("/products", CreateProduct).RequireAuthorization("api.write");
```
### Scope as Space-Delimited String
When `EmitScopesAsSpaceDelimitedStringInJwt = true` on IdentityServer, scopes arrive as a single space-delimited string rather than an array. Use a custom handler:
```csharp
public class ScopeRequirement : IAuthorizationRequirement
{
public string Scope { get; }
public ScopeRequirement(string scope) => Scope = scope;
}
public class ScopeHandler : AuthorizationHandler<ScopeRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ScopeRequirement requirement)
{
var scopeClaim = context.User.FindFirst("scope");
if (scopeClaim is null)
{
return Task.CompletedTask; // Not handled = denied
}
// Handle both array claims and space-delimited string
var scopes = scopeClaim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (scopes.Contains(requirement.Scope))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
// Registration
builder.Services.AddSingleton<IAuthorizationHandler, ScopeHandler>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("catalog.read", policy =>
policy.Requirements.Add(new ScopeRequirement("catalog.read")));
});
```
---
## Pattern 3: Custom Authorization Handlers
For complex authorization logic, implement `IAuthorizationHandler`:
```csharp
// Requirement — what needs to be satisfied
public class MinimumTenureRequirement : IAuthorizationRequirement
{
public int MinimumYears { get; }
public MinimumTenureRequirement(int years) => MinimumYears = years;
}
// Handler — how to evaluate the requirement
public class MinimumTenureHandler : AuthorizationHandler<MinimumTenureRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MinimumTenureRequirement requirement)
{
var hireDateClaim = context.User.FindFirst("hire_date");
if (hireDateClaim is null)
{
return Task.CompletedTask;
}
if (DateTimeOffset.TryParse(hireDateClaim.Value, out var hireDate))
{
var tenure = DateTimeOffset.UtcNow - hireDate;
if (tenure.TotalDays >= requirement.MinimumYears * 365.25)
{
context.Succeed(requirement);
}
}
return Task.CompletedTask;
}
}
// Registration
builder.Services.AddSingleton<IAuthorizationHandler, MinimumTenureHandler>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("senior-staff", policy =>
policy.Requirements.Add(new MinimumTenureRequirement(5)));
});
```
### Multiple Handlers for One Requirement
When any handler succeeding should grant access (OR logic):
```csharp
// Both handlers evaluate the same requirement
// If EITHER succeeds, the requirement is satisfied
public class AdminByRoleHandler : AuthorizationHandler<AdminRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, AdminRequirement requirement)
{
if (context.User.IsInRole("admin"))
context.Succeed(requirement);
return Task.CompletedTask;
}
}
public class AdminByDepartmentHandler : AuthorizationHandler<AdminRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, AdminRequirement requirement)
{
if (context.User.HasClaim("department", "it-operations"))
context.Succeed(requirement);
return Task.CompletedTask;
}
}
```
> **Key concept:** Multiple requirements in a policy use AND logic (all must be satisfied). Multiple handlers for the same requirement use OR logic (any can satisfy it).
---
## Pattern 4: Resource-Based Authorization
When authorization depends on the resource being accessed, use `IAuthorizationService`:
```csharp
public class DocumentAuthorizationHandler
: AuthorizationHandler<OperationAuthorizationRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OperationAuthorizationRequirement requirement,
Document resource)
{
var userId = context.User.FindFirst("sub")?.Value;
if (requirement == Operations.Read)
{
// Anyone in the same department can read
if (context.User.HasClaim("department", resource.Department))
context.Succeed(requirement);
}
else if (requirement == Operations.Edit)
{
// Only the owner can edit
if (resource.OwnerId == userId)
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
public static class Operations
{
public static readonly OperationAuthorizationRequirement Read = new() { Name = nameof(Read) };
public static readonly OperationAuthorizationRequirement Edit = new() { Name = nameof(Edit) };
}
```
### Using in a Controller
```csharp
public class DocumentsController : ControllerBase
{
private readonly IAuthorizationService _authz;
private readonly IDocumentRepository _docs;
public DocumentsController(IAuthorizationService authz, IDocumentRepository docs)
{
_authz = authz;
_docs = docs;
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(string id)
{
var document = await _docs.GetAsync(id);
if (document is null) return NotFound();
var result = await _authz.AuthorizeAsync(
User,
document,
Operations.Read);
if (!result.Succeeded) return Forbid();
return Ok(document);
}
}
```
---
## Pattern 5: Minimal API Authorization
Minimal APIs use the same authorization system with a fluent API:
```csharp
// Require authentication on all endpoints by default
app.MapGet("/public", () => "Anyone can see this")
.AllowAnonymous();
app.MapGet("/products", GetProducts)
.RequireAuthorization("read:catalog");
app.MapPost("/products", CreateProduct)
.RequireAuthorization("catalog-editor");
// Inline policy
app.MapDelete("/products/{id}", DeleteProduct)
.RequireAuthorization(policy =>
policy.RequireClaim("scope", "catalog.write")
.RequireRole("admin"));
// Group-level authorization
var adminGroup = app.MapGroup("/admin")
.RequireAuthorization("admin");
adminGroup.MapGet("/users", GetUsers);
adminGroup.MapPost("/users", CreateUser);
```
---
## Pattern 6: Combining Client Scope + User Claims
In APIs protected by IdentityServer, proper authorization often requires checking *both* the client's scope and the user's claims:
```csharp
public class ApiWriteRequirement : IAuthorizationRequirement { }
public class ApiWriteHandler : AuthorizationHandler<ApiWriteRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ApiWriteRequirement requirement)
{
// Check 1: Client must have the write scope
var hasScope = context.User.HasClaim(c =>
c.Type == "scope" && c.Value.Split(' ').Contains("catalog.write"));
// Check 2: User must be in the editor role
var isEditor = context.User.IsInRole("editor");
if (hasScope && isEditor)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
```
> **Why both?** A malicious client could request broad scopes, but the user may not have permission. A privileged user operating through a restricted client should be limited by that client's scopes.
---
## Pattern 7: Fallback and Default Policies
```csharp
builder.Services.AddAuthorization(options =>
{
// DefaultPolicy: applied when [Authorize] has no policy name
options.DefaultPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
// FallbackPolicy: applied to endpoints with NO [Authorize] attribute
// Setting this makes all endpoints require authentication by default
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
```
| Policy | Applied When | Use Case |
|--------|-------------|----------|
| `DefaultPolicy` | `[Authorize]` with no policy name | Basic "must be logged in" check |
| `FallbackPolicy` | Endpoints with no `[Authorize]` attribute | Secure-by-default for APIs |
> **Tip:** Set `FallbackPolicy` to require authentication, then use `[AllowAnonymous]` only on endpoints that genuinely need it (health checks, public assets).
---
## Common Pitfalls
### 1. Using Role Strings Instead of Policies
```csharp
// ❌ WRONG — Hardcoded role strings scattered across controllers
[Authorize(Roles = "admin,superadmin,it-ops")]
public IActionResult Dashboard() { }
// ✅ CORRECT — Centralized policy
options.AddPolicy("dashboard-access", policy =>
policy.RequireRole("admin", "superadmin", "it-ops"));
[Authorize(Policy = "dashboard-access")]
public IActionResult Dashboard() { }
```
### 2. Not Registering Authorization Handlers
```csharp
// ❌ WRONG — Handler exists but never registered
// Policy silently denies because no handler evaluates the requirement
// ✅ CORRECT — Register the handler in DI
builder.Services.AddSingleton<IAuthorizationHandler, ScopeHandler>();
```
### 3. Calling context.Fail() in Handlers
`context.Fail()` **actively denies** authorization regardless of what other handlers say — it's a hard veto. Not calling `context.Succeed()` simply means "I have no opinion"; other handlers can still satisfy the requirement.
```csharp
// ❌ WRONG — Fail() is a hard veto: it denies even if another handler would succeed
protected override Task HandleRequirementAsync(...)
{
if (!context.User.HasClaim("scope", "api.read"))
context.Fail(); // Forces denial — blocks all other handlers permanently!
return Task.CompletedTask;
}
// ✅ CORRECT — Simply don't call Succeed(); let other handlers try
protected override Task HandleRequirementAsync(...)
{
if (context.User.HasClaim("scope", "api.read"))
context.Succeed(requirement);
// Not calling Succeed() means "I don't know" — other handlers may still succeed
return Task.CompletedTask;
}
```
> Only call `context.Fail()` when you need to **guarantee** denial even if other handlers would approve (e.g., a security blocklist check). In most cases, simply omit the `Succeed()` call.
### 4. Ignoring Client vs User Authorization
```csharp
// ❌ WRONG — Only checking user role, ignoring client scope
options.AddPolicy("write", p => p.RequireRole("editor"));
// A client without the write scope could still pass this check
// ✅ CORRECT — Check both scope and user claims
options.AddPolicy("write", p =>
{
p.RequireClaim("scope", "catalog.write"); // Client permission
p.RequireRole("editor"); // User permission
});
```
---
## Resources
- [Authorization in ASP.NET Core — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/introduction)
- [Policy-Based Authorization — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/policies)
- [Resource-Based Authorization — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/resourcebased)
- [Protecting APIs — Duende Docs](https://docs.duendesoftware.com/identityserver/apis/)
- [API Authorization — Duende Docs](https://docs.duendesoftware.com/identityserver/apis/aspnetcore/authorization/)
claims-authorization30.9 KB
---
name: claims-authorization
description: Claims transformation and profile service patterns for Duende IdentityServer — IProfileService, IClaimsTransformation, claim type mapping, token claim filtering, extension grant validators, and dynamic claims loading.
invocable: false
---
# Claims Transformation & Profile Service
## When to Use This Skill
- You are implementing or customizing `IProfileService` to control which claims are emitted into identity tokens, access tokens, or the userinfo endpoint.
- You need to map claims from an external identity provider (Google, Azure AD, SAML, etc.) into your IdentityServer user principal during login callback processing.
- You are configuring `IdentityResource`, `ApiScope`, or `ApiResource` `UserClaims` collections and need to understand how requested scopes drive `ProfileDataRequestContext.RequestedClaimTypes`.
- You are troubleshooting missing claims — claims are defined on resources but not appearing in tokens or on the userinfo endpoint.
- You need to load claims dynamically from a database or downstream service at token issuance time.
- You are implementing an `IExtensionGrantValidator` and need to emit custom claims into the resulting access token.
- You are consuming tokens in an ASP.NET Core API or web app and need to handle claim type mapping (`MapInboundClaims`, `JwtClaimTypes` vs. Microsoft `ClaimTypes`).
## Core Principles
- **Claims are opt-in by scope.** IdentityServer only asks your profile service for claims that have been declared on a requested `IdentityResource`, `ApiScope`, or `ApiResource`. Declaring a claim on your user store is not enough — it must be listed in a resource's `UserClaims` collection and the client must request that resource's scope.
- **`IProfileService` is the single authoritative extension point** for controlling which user claims enter tokens. Do not use `IClaimsTransformation` on the IdentityServer host to modify token claims — that interface runs during cookie authentication, not token issuance.
- **Identity tokens are for the client; access tokens are for APIs.** Keep identity tokens small. Use `AlwaysIncludeUserClaimsInIdToken` sparingly. Prefer the userinfo endpoint for full profile data.
- **`AddRequestedClaims` respects consent.** Use `context.AddRequestedClaims(claims)` rather than `context.IssuedClaims.AddRange(claims)` when you want IdentityServer to filter your claims down to only those that were requested and consented to by the user.
- **Claim serialization is type-aware.** Set `ClaimValueType` correctly (e.g. `ClaimValueTypes.Integer64`, `IdentityServerConstants.ClaimValueTypes.Json`) so numeric and structured values arrive in tokens as the right JSON type rather than strings.
- **`MapInboundClaims = false` is required** in consuming APIs and web apps. Without it, the JWT bearer handler silently renames standard OIDC claims (e.g. `sub` → `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier`), breaking `User.FindFirst(JwtClaimTypes.Subject)` lookups.
Docs: https://docs.duendesoftware.com/identityserver/apis/aspnetcore/authorization/
---
## Sub-Documents
| Document | Description | When to Load |
|----------|-------------|--------------|
| [docs/extension-grant-claims.md](docs/extension-grant-claims.md) | `IExtensionGrantValidator` implementation for custom grant types with claim propagation | Extension grants, token exchange, custom grant type, IExtensionGrantValidator, GrantValidationResult |
| [docs/external-provider-claims.md](docs/external-provider-claims.md) | External provider login callback with claim mapping, Google/AAD normalization, and ClaimActions | External provider, Google, Azure AD, OIDC callback, claim mapping, ExternalCookieAuthenticationScheme |
---
## Claims Pipeline Overview
Claims travel through several distinct stages between the user's identity and an API's authorization check. Understanding where each transformation occurs prevents duplicate work and subtle bugs.
```
External IdP ──► IdentityServer login callback
│
▼
Cookie principal (ClaimsPrincipal)
– built during SignInAsync
– stored in authentication session
│
▼
IProfileService.GetProfileDataAsync
– called at token issuance time
– selects/augments claims for each token type
│
┌──────┴──────┐
▼ ▼
Identity Token Access Token
(for client) (for API)
│
▼
API JWT bearer handler
– IClaimsTransformation (optional)
– MapInboundClaims = false
│
▼
HttpContext.User
– used by [Authorize], policies, handlers
```
**Stage 1 — Login callback**: Claims from the external provider (or local user store) are incorporated into the `IdentityServerUser` and persisted in the session cookie. This is where you map external IdP claims to internal claim types.
**Stage 2 — Token issuance**: When a client requests a token, IdentityServer calls `IProfileService.GetProfileDataAsync`. The `ProfileDataRequestContext` tells you which claims are requested (derived from scopes/resources) and what token type is being built. This is where you load dynamic claims from your database.
**Stage 3 — Token consumption**: APIs receive the JWT and validate it. `IClaimsTransformation` can augment the `ClaimsPrincipal` after validation — useful for adding application-specific roles or denormalized data that doesn't belong in the token itself.
---
## IProfileService
`IProfileService` is the primary extensibility point for claims in Duende IdentityServer. Register your implementation with `AddProfileService<T>()` during startup.
### Interface Contract
```csharp
// Duende.IdentityServer.Services
public interface IProfileService
{
// Called to get claims for a token or the userinfo endpoint.
Task GetProfileDataAsync(ProfileDataRequestContext context);
// Called to check whether the user is still active (e.g. not disabled).
// context.Caller is a ProfileIsActiveCallers constant that tells you WHY
// the check is being made (e.g. AuthorizeEndpoint, Token, RefreshTokenValidation).
Task IsActiveAsync(IsActiveContext context);
}
```
### ProfileDataRequestContext Key Members
| Member | Description |
|---|---|
| `Subject` | The `ClaimsPrincipal` from the authentication session (or from the access token for userinfo calls). |
| `Client` | The `Client` making the request — use for per-client filtering. |
| `Caller` | What triggered this call: `ClaimsProviderAccessToken`, `ClaimsProviderIdentityToken`, `UserInfoEndpoint`. |
| `RequestedClaimTypes` | Claim types requested by the client, built from the `UserClaims` of the resources (`IdentityResource`/`ApiScope`/`ApiResource`) resolved for the request. |
| `IssuedClaims` | Populate this collection with claims to include in the token. |
| `AddRequestedClaims(IEnumerable<Claim>)` | Helper that filters your claims to only those in `RequestedClaimTypes`. |
### Minimal Implementation
```csharp
// ✅ Correct: extend DefaultProfileService, use AddRequestedClaims
public sealed class ApplicationProfileService : DefaultProfileService
{
private readonly IUserRepository _users;
private readonly ILogger<ApplicationProfileService> _logger;
public ApplicationProfileService(
IUserRepository users,
ILogger<ApplicationProfileService> logger)
: base(logger)
{
_users = users;
_logger = logger;
}
public override async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
// Source claims from Subject (cheap — already in memory)
var subjectId = context.Subject.GetSubjectId();
// Load additional claims from the database
var user = await _users.FindBySubjectIdAsync(subjectId);
if (user is null)
{
_logger.LogWarning("Profile service: user {SubjectId} not found", subjectId);
return;
}
var claims = new List<Claim>
{
new(JwtClaimTypes.Name, user.DisplayName),
new(JwtClaimTypes.Email, user.Email),
new("tenant_id", user.TenantId),
new("subscription_tier", user.SubscriptionTier),
};
// Only emit claims that were requested by the client's scopes
context.AddRequestedClaims(claims);
}
public override async Task IsActiveAsync(IsActiveContext context)
{
var subjectId = context.Subject.GetSubjectId();
var user = await _users.FindBySubjectIdAsync(subjectId);
context.IsActive = user is { IsEnabled: true };
}
}
```
> **`ProfileIsActiveCallers`**: `IsActiveContext.Caller` is a `ProfileIsActiveCallers` constant indicating **why** the check is being made — e.g. `AuthorizeEndpoint`, `Token`, `RefreshTokenValidation`, `UserInfoRequestValidation`. Use it to apply different strictness levels; for example, you might allow a soft-disabled account to complete an in-flight refresh but deny new interactive logins.
```csharp
// Program.cs
builder.Services.AddIdentityServer()
.AddProfileService<ApplicationProfileService>();
```
### Emitting Claims Unconditionally
Use `context.IssuedClaims.AddRange(...)` when a claim must always appear regardless of requested scopes — for example, a mandatory `tenant_id` that APIs rely on for multi-tenancy:
```csharp
// ✅ Always emit tenant_id, regardless of requested scopes
public override async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var subjectId = context.Subject.GetSubjectId();
var user = await _users.FindBySubjectIdAsync(subjectId);
// Mandatory claim — bypasses scope-based filtering
context.IssuedClaims.Add(new Claim("tenant_id", user.TenantId));
// Scope-filtered claims
var profileClaims = BuildProfileClaims(user);
context.AddRequestedClaims(profileClaims);
}
```
```csharp
// ❌ Wrong: adding all claims directly bypasses consent and scope filtering
public override Task GetProfileDataAsync(ProfileDataRequestContext context)
{
// This ignores RequestedClaimTypes and consent — user agreed to share only
// the claims associated with the requested scopes.
context.IssuedClaims.AddRange(GetAllUserClaims());
return Task.CompletedTask;
}
```
### Differentiating by Caller
The `Caller` property lets you tailor claims for each token type:
```csharp
public override async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var user = await _users.FindBySubjectIdAsync(context.Subject.GetSubjectId());
if (context.Caller == IdentityServerConstants.ProfileDataCallers.ClaimsProviderIdentityToken)
{
// Identity tokens go to the browser — keep them small
context.IssuedClaims.Add(new Claim(JwtClaimTypes.Name, user.DisplayName));
return;
}
// Access tokens and userinfo can include richer application claims
var claims = BuildFullClaimSet(user);
context.AddRequestedClaims(claims);
}
```
### Detecting Userinfo Endpoint Calls
When called for the userinfo endpoint, `Subject` is populated from the **access token** rather than the session principal. Guard against assuming session-only data is available:
```csharp
public override async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
// context.Subject.GetSubjectId() works for all callers
var subjectId = context.Subject.GetSubjectId();
// For userinfo, context.Subject contains access-token claims only —
// not the full session principal. Load from database instead.
var user = await _users.FindBySubjectIdAsync(subjectId);
context.AddRequestedClaims(BuildProfileClaims(user));
}
```
### Profile Service Invocation Count (Lifecycle)
For an authorization-code + userinfo flow, `GetProfileDataAsync` can be called **up to three times** per login, distinguished by `context.Caller`:
| Caller | When | Notes |
|---|---|---|
| `ClaimsProviderIdentityToken` | Building the id_token | Called with `includeAllIdentityClaims = false` → the id_token is **minimal** by default |
| `ClaimsProviderAccessToken` | Building the access token | |
| `UserInfoEndpoint` | Client calls `/connect/userinfo` | `Subject` comes from the access token |
The ASP.NET Core OIDC handler fetches userinfo only when `options.GetClaimsFromUserInfoEndpoint = true`. Setting `Client.AlwaysIncludeUserClaimsInIdToken = true` puts all identity claims in the id_token and skips the userinfo round-trip.
### Refresh Token Claim Updates
On refresh, `Client.UpdateAccessTokenClaimsOnRefresh` (default `false`) controls whether `GetProfileDataAsync` is re-invoked for fresh access-token claims:
- `false` (default): the original claims are reused; only `IsActiveAsync` is called.
- `true`: `GetProfileDataAsync` runs again so access-token claims reflect current state.
---
## Claims in Tokens: Identity vs. Access
### Identity Token
- Purpose: tells the client application what happened during authentication.
- Audience: the client application only — **never send to an API**.
- Keep small: the client validates it immediately; large tokens stress browsers and PKCE flows.
- Standard claims: `sub`, `auth_time`, `amr`, `idp`, `sid`, `nonce`.
- User profile claims (name, email) are typically fetched via userinfo rather than embedded.
```csharp
// ✅ Prefer userinfo for profile data — keep id_token lean
// On the client (ASP.NET Core OIDC handler):
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
```
### AlwaysIncludeUserClaimsInIdToken
Setting `AlwaysIncludeUserClaimsInIdToken = true` on a client forces all profile claims into the identity token, bypassing the userinfo endpoint. Use only when the client cannot make the userinfo call (e.g. native apps with no back-channel).
```csharp
// ⚠️ Use sparingly — increases id_token size significantly
var client = new Client
{
ClientId = "native_app",
AlwaysIncludeUserClaimsInIdToken = true,
AllowedScopes = { "openid", "profile", "email" },
};
```
### Access Token
- Purpose: authorizes API calls.
- Audience: the resource server (API).
- Contains: `sub`, `client_id`, `scope`, `jti`, `iss`, `exp`, + any user claims from profile service.
- Resource-based filtering applies: claims associated with a specific `ApiResource` only appear when that resource is requested via resource indicator.
### Resource-Based Claim Filtering
Declare claims on `ApiResource` to scope them to that specific API:
```csharp
// ✅ Claims on ApiResource are only emitted when that resource is requested
new ApiResource("invoicing", "Invoicing API")
{
Scopes = { "invoicing.read", "invoicing.write" },
UserClaims = { "cost_center", "approval_limit" } // Only in tokens for this API
}
new ApiScope("invoicing.read")
{
UserClaims = { "department" } // Emitted when this scope is requested
}
```
### Claim Value Types
Set `ClaimValueType` to ensure correct JSON serialization in the JWT:
```csharp
// ✅ Numeric and boolean claims serialize as JSON primitives
var claims = new List<Claim>
{
new("account_id", "42",
ClaimValueTypes.Integer64),
new("is_verified", "true",
ClaimValueTypes.Boolean),
new("permissions", """["read","write"]""",
IdentityServerConstants.ClaimValueTypes.Json),
};
```
```csharp
// ❌ Without ClaimValueType, all values serialize as JSON strings
// { "account_id": "42" } ← wrong, should be 42
new Claim("account_id", "42")
```
---
## Claim Types and Mapping
### JwtClaimTypes vs. System ClaimTypes
The `Duende.IdentityModel` (or `IdentityModel`) library provides `JwtClaimTypes` with the short JWT/OIDC claim names:
| JwtClaimTypes | Long Microsoft ClaimTypes |
|---|---|
| `sub` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier` |
| `name` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` |
| `email` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` |
| `role` | `http://schemas.microsoft.com/ws/2008/06/identity/claims/role` |
| `given_name` | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname` |
Always use `JwtClaimTypes` constants in IdentityServer code and in APIs that validate JWTs directly.
### MapInboundClaims = false (Required in APIs)
The default JWT bearer handler maps short JWT claim names to long Microsoft WS-Federation names. Disable this:
```csharp
// ✅ In your API — keep standard OIDC short names
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "my_api";
options.MapInboundClaims = false; // Keep "sub", not the long name
});
```
```csharp
// ✅ In web app OIDC handler — same principle
builder.Services.AddAuthentication(...)
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://identity.example.com";
options.MapInboundClaims = false;
options.TokenValidationParameters.NameClaimType = JwtClaimTypes.Name;
options.TokenValidationParameters.RoleClaimType = JwtClaimTypes.Role;
});
```
```csharp
// ❌ Without MapInboundClaims = false:
// User.FindFirst("sub") → null
// User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier") → found
```
---
## IClaimsTransformation (API-Side)
`IClaimsTransformation` is an ASP.NET Core interface that runs in consuming applications **after** authentication but **before** authorization. Use it in APIs and web apps — never in the IdentityServer host itself for token content.
### When to Use IClaimsTransformation
- Enriching the `ClaimsPrincipal` with application-specific roles from a local database, after validating a token from IdentityServer.
- Mapping external department/group memberships to application roles without putting that data in the token.
- Adding denormalized claims (e.g. resolved tenant name from `tenant_id`) for use in authorization policies.
```csharp
// ✅ In an API project — augment principal after token validation
public sealed class TenantClaimsTransformation : IClaimsTransformation
{
private readonly ITenantRepository _tenants;
public TenantClaimsTransformation(ITenantRepository tenants)
{
_tenants = tenants;
}
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var tenantId = principal.FindFirstValue("tenant_id");
if (tenantId is null)
{
return principal;
}
var tenant = await _tenants.GetByIdAsync(tenantId);
if (tenant is null)
{
return principal;
}
// Clone before mutating — ClaimsPrincipal can be reused across calls
var identity = new ClaimsIdentity();
identity.AddClaim(new Claim("tenant_name", tenant.DisplayName));
identity.AddClaim(new Claim("tenant_region", tenant.Region));
foreach (var role in tenant.ApplicationRoles)
{
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
principal.AddIdentity(identity);
return principal;
}
}
```
```csharp
// Program.cs — in the API
builder.Services.AddTransient<IClaimsTransformation, TenantClaimsTransformation>();
```
> **Do not use `IClaimsTransformation` on the IdentityServer host** to modify token claims. It runs during cookie sign-in/validation and does not affect token content — use `IProfileService` there instead.
---
## Extension Grant Validators
`IExtensionGrantValidator` handles custom OAuth grant types at the token endpoint (e.g. token exchange, assertion grants). Implement `ValidateAsync` to validate the incoming token/assertion, then call `new GrantValidationResult(subject, grantType, customClaims)`. `IProfileService` is called subsequently and can augment claims further. Register with `AddExtensionGrantValidator<T>()`.
> See [docs/extension-grant-claims.md](docs/extension-grant-claims.md) for a full token exchange validator implementation with error handling and custom claim propagation.
---
## Claims from External Providers
When a user authenticates through an external provider, IdentityServer receives claims in a temporary external cookie. In the login callback: read via `HttpContext.AuthenticateAsync(ExternalCookieAuthenticationScheme)`, extract the provider user ID, find or provision the local user, build an `IdentityServerUser` with `AdditionalClaims = MapProviderClaims(...)`, then call `SignInAsync` + `SignOutAsync` for the external cookie. For OIDC handlers, use `ClaimActions.Clear()` followed by explicit `MapJsonKey` calls to whitelist only the claims you need.
> See [docs/external-provider-claims.md](docs/external-provider-claims.md) for the full callback controller implementation with Google/AAD claim mapping and `ClaimActions` examples.
---
## Dynamic Claims Loading
Loading claims dynamically at token issuance time — rather than storing them in the session cookie — keeps your session lean and ensures claims reflect the current state of your database. This is the recommended pattern for role assignments and feature flags that change frequently.
```csharp
public sealed class DynamicProfileService : DefaultProfileService
{
private readonly IUserPermissionService _permissions;
private readonly IFeatureFlagService _features;
private readonly ILogger<DynamicProfileService> _logger;
public DynamicProfileService(
IUserPermissionService permissions,
IFeatureFlagService features,
ILogger<DynamicProfileService> logger)
: base(logger)
{
_permissions = permissions;
_features = features;
_logger = logger;
}
public override async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var subjectId = context.Subject.GetSubjectId();
// Run database calls concurrently
var (permissionsTask, featuresTask) = (
_permissions.GetForUserAsync(subjectId, context.Client.ClientId),
_features.GetEnabledForUserAsync(subjectId)
);
await Task.WhenAll(permissionsTask, featuresTask);
var claims = new List<Claim>();
// Role claims from permission service
foreach (var permission in permissionsTask.Result)
{
claims.Add(new Claim(JwtClaimTypes.Role, permission));
}
// Feature flag claims — serialize as JSON array
var featuresJson = System.Text.Json.JsonSerializer.Serialize(featuresTask.Result);
claims.Add(new Claim(
"features",
featuresJson,
IdentityServerConstants.ClaimValueTypes.Json));
context.AddRequestedClaims(claims);
}
public override async Task IsActiveAsync(IsActiveContext context)
{
// Guard against token use after account suspension
var subjectId = context.Subject.GetSubjectId();
context.IsActive = await _permissions.IsUserActiveAsync(subjectId);
}
}
```
> **Performance note**: `GetProfileDataAsync` is called on every token issuance, including refresh token redemptions. Use caching (`IMemoryCache`, `IDistributedCache`) for expensive lookups, keyed by `subjectId + clientId`. Cache TTL should be shorter than your access token lifetime.
### Caching Dynamic Claims
```csharp
public override async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var subjectId = context.Subject.GetSubjectId();
var cacheKey = $"profile:{subjectId}:{context.Client.ClientId}";
if (!_cache.TryGetValue(cacheKey, out IReadOnlyList<Claim>? cachedClaims))
{
cachedClaims = await LoadClaimsFromDatabaseAsync(subjectId, context.Client.ClientId);
_cache.Set(cacheKey, cachedClaims, TimeSpan.FromMinutes(5));
}
context.AddRequestedClaims(cachedClaims!);
}
```
---
## Client Claims
Client claims are static claims attached to a `Client` definition and emitted into access tokens. They are prefixed with `client_` by default to prevent collision with user claims.
```csharp
var client = new Client
{
ClientId = "billing-service",
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "invoicing.api" },
// Prefixed as "client_customer_id" in the token
Claims =
{
new ClientClaim("customer_id", "acme-corp"),
new ClientClaim("region", "us-east"),
},
// Remove the prefix: emit as "customer_id" (use carefully)
// ClientClaimsPrefix = ""
};
```
> Client claims are only emitted in the **client credentials flow** by default. For other flows set `AlwaysSendClientClaims = true` on the client definition.
For dynamic client claims (e.g. set based on runtime context), implement a custom token request validator:
```csharp
public sealed class DynamicClientClaimsValidator : ICustomTokenRequestValidator
{
private readonly IClientContextService _clientContext;
public DynamicClientClaimsValidator(IClientContextService clientContext)
{
_clientContext = clientContext;
}
public async Task ValidateAsync(CustomTokenRequestValidationContext context)
{
if (context.Result.ValidatedRequest.GrantType != GrantType.ClientCredentials)
{
return;
}
var clientId = context.Result.ValidatedRequest.Client.ClientId;
var tier = await _clientContext.GetSubscriptionTierAsync(clientId);
context.Result.ValidatedRequest.ClientClaims.Add(
new Claim("subscription_tier", tier));
}
}
```
---
## Common Pitfalls
### Claims Not Appearing in Tokens
1. **Claim not in `UserClaims`**: The claim type must be listed in the `UserClaims` collection of the `IdentityResource`, `ApiScope`, or `ApiResource` that the client requests.
```csharp
// ❌ "department" never requested — won't appear even if profile service emits it
new ApiScope("api.read"); // no UserClaims
// ✅ Declare the claim on the scope
new ApiScope("api.read")
{
UserClaims = { "department", "cost_center" }
}
```
2. **Client not requesting the scope**: The client must include the scope in `AllowedScopes` and request it at authorization time.
3. **`AddRequestedClaims` filtered it out**: If you use `context.AddRequestedClaims(claims)`, only claims whose types are in `context.RequestedClaimTypes` pass through. Check whether the scope was requested.
4. **Check the userinfo endpoint first**: the identity token is minimal by default, so a "missing" claim is often available at `/connect/userinfo`. Verify there before modifying the profile service — the fix may just be `GetClaimsFromUserInfoEndpoint = true` on the client.
5. **Enable debug logging**: turn on `Duende.IdentityServer` debug logging. The default profile service logs requested vs. issued claim types, which reveals whether a claim was filtered out by `RequestedClaimTypes` rather than never emitted.
Adding a claim to the user/subject is **not** enough. To surface a new claim you must: (1) define a resource whose `UserClaims` include it (e.g. `new IdentityResource("department_info", ["department"], "Your department")`), (2) add that scope to the client's `AllowedScopes`, and (3) have the request include that scope. Adding a claim directly to `IssuedClaims` bypasses this filter — do that only when a claim must always be sent.
### Wrong Claim Names in APIs
Caused by not setting `MapInboundClaims = false`. The JWT bearer handler renames `sub` to the long WS-Federation URI. Fix:
```csharp
// ✅ Always set this in APIs consuming IdentityServer tokens
options.MapInboundClaims = false;
```
### Mutating ClaimsPrincipal in IClaimsTransformation
`ClaimsPrincipal` instances can be cached and reused. Always create a new `ClaimsIdentity` and add it to the principal rather than mutating an existing identity:
```csharp
// ✅ Create a new identity, add to existing principal
var identity = new ClaimsIdentity();
identity.AddClaim(new Claim("app_role", "admin"));
principal.AddIdentity(identity);
return principal;
// ❌ Never mutate the principal's existing identities in-place
((ClaimsIdentity)principal.Identity!).AddClaim(new Claim("app_role", "admin"));
```
### AlwaysIncludeUserClaimsInIdToken Overuse
Setting `AlwaysIncludeUserClaimsInIdToken = true` embeds all profile claims in the identity token. This:
- Increases token size (can exceed header/cookie limits).
- Caches profile data in the client until the token expires (stale claims).
- Bypasses the userinfo endpoint's on-demand freshness.
Prefer `options.GetClaimsFromUserInfoEndpoint = true` in the client OIDC handler.
### Storing Too Many Claims in the Session Cookie
The IdentityServer session cookie stores the `ClaimsPrincipal` from `SignInAsync`. Large claim sets (e.g. hundreds of AD groups) bloat this cookie, breaking requests with 431 or 400 errors. Keep the session principal minimal — load bulk claims dynamically in `IProfileService` instead.
### Forgetting IsActiveAsync
`IsActiveAsync` is called on refresh token redemption. If you block token issuance via `context.IsActive = false` but don't revoke the refresh token, the user sees token request failures without a helpful error. Ensure your user deactivation flow also revokes persisted grants.
---
## Resources
- [Duende IdentityServer — Claims fundamentals](https://docs.duendesoftware.com/identityserver/fundamentals/claims/)
- [Duende IdentityServer — Profile Service reference](https://docs.duendesoftware.com/identityserver/reference/services/profile-service/)
- [Duende IdentityServer — Identity Resources](https://docs.duendesoftware.com/identityserver/fundamentals/resources/identity/)
- [Duende IdentityServer — API Scopes](https://docs.duendesoftware.com/identityserver/fundamentals/resources/api-scopes/)
- [Duende IdentityServer — API Resources](https://docs.duendesoftware.com/identityserver/fundamentals/resources/api-resources/)
- [Duende IdentityServer — Extension Grants](https://docs.duendesoftware.com/identityserver/tokens/extension-grants/)
- [Duende IdentityServer — External Providers](https://docs.duendesoftware.com/identityserver/ui/login/external/)
- [Duende IdentityServer — Token types overview](https://docs.duendesoftware.com/identityserver/tokens/)
- [Duende IdentityServer — Custom Token Request Validator](https://docs.duendesoftware.com/identityserver/tokens/dynamic-validation/)
- [ASP.NET Core — IClaimsTransformation](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/claims)
- [OpenID Connect Core spec — Standard scope/claim mappings](https://openid.net/specs/openid-connect-core-1_0.html#scopeclaims)
### Related Skills
- `aspnetcore-authorization` — policy-based authorization, `IAuthorizationRequirement`, resource-based authorization using claims in the `ClaimsPrincipal`
- `identityserver-configuration` — configuring `IdentityResource`, `ApiScope`, `ApiResource`, and `Client` definitions that drive which claims are requested
- `aspnetcore-authentication` — cookie authentication, OIDC handler configuration, `MapInboundClaims`, and `GetClaimsFromUserInfoEndpoint`
Referenced files: 2
duende-bff37.1 KB
---
name: duende-bff
description: Duende BFF (Backend for Frontend) security framework for securing SPAs. Covers session management, API endpoint proxying, token management, anti-forgery protection, and integration with React/Angular/Blazor frontends.
invocable: false
---
# Duende BFF (Backend for Frontend)
## When to Use This Skill
- Building or securing a SPA (React, Angular, Vue, Blazor WASM) that calls APIs requiring authentication
- Implementing the Backend-for-Frontend security pattern to keep access tokens out of the browser
- Configuring BFF session management, login/logout endpoints, and server-side sessions
- Proxying requests from a frontend to remote APIs while automatically attaching access tokens
- Adding CSRF/anti-forgery protection to APIs consumed by browser-based applications
- Integrating `Duende.BFF` with `Duende.AccessTokenManagement` for automatic token refresh
- Deploying a BFF behind a reverse proxy or configuring same-site cookie behavior
## Core Principles
1. **Tokens Never Touch the Browser** — The BFF holds all OAuth tokens server-side; the browser only ever sees an HTTP-only, Secure, SameSite cookie
2. **CSRF Protection Is Mandatory** — Every BFF API endpoint must require the `X-CSRF: 1` header; use `.AsBffApiEndpoint()` or `MapRemoteBffApiEndpoint` — never skip it without an explicit alternative
3. **Cookie Configuration Determines Security Posture** — `SameSite=Strict` is preferred when the IDP is on the same site; `Lax` is acceptable when cross-site redirects are required after login
4. **Server-Side Sessions for Production** — The default in-memory cookie session is unsuitable for production; persist sessions with `Duende.BFF.EntityFramework`
5. **Token Management Is Automatic** — BFF integrates with `Duende.AccessTokenManagement`; never manually refresh tokens or pass raw access tokens to the frontend
Docs: https://docs.duendesoftware.com/bff/
---
## Pattern 1: Setup and Registration (BFF v4)
BFF v4 uses a streamlined registration API that auto-configures OpenID Connect and cookie authentication with recommended defaults.
```csharp
// ✅ v4: AddBff() with fluent OIDC and cookie configuration
builder.Services.AddBff()
.ConfigureOpenIdConnect(options =>
{
options.Authority = "https://your-idp.example.com";
options.ClientId = "my-bff-client";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.ResponseMode = "query";
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
options.MapInboundClaims = false;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("offline_access"); // Required for refresh tokens
})
.ConfigureCookies(options =>
{
// Use Strict when your IDP is on the same site as the BFF.
// Use Lax when a cross-site redirect is required (e.g., IDP on a different domain).
options.Cookie.SameSite = SameSiteMode.Lax;
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseBff(); // Adds CSRF anti-forgery enforcement middleware
app.UseAuthorization();
app.Run();
```
```csharp
// ❌ v4: Do NOT manually wire AddCookie + AddOpenIdConnect when using AddBff()
// ConfigureOpenIdConnect and ConfigureCookies handle this correctly
builder.Services.AddAuthentication()
.AddCookie("cookie")
.AddOpenIdConnect("oidc", ...); // Bypasses BFF's recommended defaults
```
### BFF v3 Registration
For projects still on v3, explicit scheme setup is required and `MapBffManagementEndpoints()` must be called manually:
```csharp
// ✅ v3: explicit authentication scheme wiring
builder.Services.AddBff();
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
options.DefaultSignOutScheme = "oidc";
})
.AddCookie("cookie", options =>
{
options.Cookie.Name = "__Host-bff";
options.Cookie.SameSite = SameSiteMode.Strict;
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://your-idp.example.com";
options.ClientId = "my-bff-client";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.SaveTokens = true;
options.Scope.Add("offline_access");
});
// ...
app.MapBffManagementEndpoints(); // ✅ Required in v3
```
### Key Differences: V4 vs V3
| Feature | V4 | V3 |
| --------------------- | ------------------------------------------------- | ------------------------------------------- |
| Auth handler setup | `ConfigureOpenIdConnect()` / `ConfigureCookies()` | Manual `AddCookie()` / `AddOpenIdConnect()` |
| Management endpoints | Auto-registered | `MapBffManagementEndpoints()` required |
| Remote API token type | `.WithAccessToken(RequiredTokenType.User)` | `.RequireAccessToken(TokenType.User)` |
| Session cleanup | `.AddSessionCleanupBackgroundProcess()` | `EnableSessionCleanup` option |
| Token retriever | `IAccessTokenRetriever` (implement directly) | `DefaultAccessTokenRetriever` (inheritable) |
| Multi-frontend | Built-in `AddFrontend()` API | Not supported |
| Middleware control | `AutomaticallyRegisterBffMiddleware` option | Always automatic |
---
## Pattern 2: Login and Logout Endpoints
In BFF v4, management endpoints (`/bff/login`, `/bff/logout`, `/bff/user`, `/bff/backchannel-logout`) are registered automatically by `AddBff()` with the implicit default frontend. In v3, they require an explicit call to `MapBffManagementEndpoints()`.
**Login** — A browser navigation to `/bff/login` initiates an OIDC Authorization Code flow. After the IDP redirects back, the BFF sets an HTTP-only authentication cookie.
```csharp
// ✅ Trigger login from the SPA (browser navigation, not fetch)
// React example:
// window.location.href = '/bff/login?returnUrl=/dashboard';
// ✅ Optional: supply a returnUrl to redirect after login
// GET /bff/login?returnUrl=/dashboard
// The returnUrl must be a local path; absolute URLs are rejected.
```
**Logout** — A browser navigation to `/bff/logout` signs the user out locally and initiates an OIDC end_session flow. It also revokes the refresh token automatically.
```csharp
// ✅ The sid claim from /bff/user must be passed as a query parameter
// GET /bff/logout?sid=<session-id>
// This is required to prevent CSRF attacks on the logout endpoint.
```
```csharp
// ❌ Do NOT call /bff/logout via fetch() without the sid parameter.
// The logout endpoint validates the sid to prevent cross-site logout attacks.
```
---
## Pattern 3: CSRF / Anti-Forgery Protection
The BFF enforces a custom `X-CSRF` header on every protected endpoint. This triggers a CORS preflight for cross-origin requests, effectively preventing CSRF attacks. The header value is irrelevant — its presence is sufficient.
### Local (Embedded) API Endpoints
```csharp
// ✅ Minimal API: decorate with AsBffApiEndpoint()
app.MapGet("/api/data", (HttpContext ctx) => Results.Ok("data"))
.RequireAuthorization()
.AsBffApiEndpoint();
// ✅ MVC Controllers: apply to the entire controller via attribute
[Route("api/data")]
[BffApi]
public class DataController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok("data");
}
// ✅ MVC Controllers: apply at mapping time
app.MapControllers()
.RequireAuthorization()
.AsBffApiEndpoint();
```
```csharp
// ❌ Do NOT expose BFF API endpoints without AsBffApiEndpoint() or BffApi attribute.
// Without it, the x-csrf header is not enforced and the endpoint is CSRF-vulnerable.
app.MapGet("/api/data", () => Results.Ok("data"))
.RequireAuthorization(); // Missing .AsBffApiEndpoint()
```
### Middleware Order
`UseBff()` must appear **after** `UseRouting()` but **before** `UseAuthorization()`. Incorrect order silently disables anti-forgery enforcement.
```csharp
// ✅ Correct middleware order
app.UseRouting();
app.UseAuthentication();
app.UseBff(); // Must be here
app.UseAuthorization();
app.MapControllers().AsBffApiEndpoint();
// ❌ Wrong: UseBff() after UseAuthorization() — anti-forgery is not applied
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseBff(); // Too late
```
### Skipping Anti-Forgery
For specific endpoints that cannot send the anti-forgery header (e.g., webhook receivers), use `.SkipAntiforgery()`:
```csharp
// ✅ Webhook receiver: skip anti-forgery for endpoints that cannot send the header
app.MapPost("/api/webhook", (WebhookPayload payload) => Results.Ok())
.AsBffApiEndpoint()
.SkipAntiforgery();
```
### Skipping Response Handling (V4)
By default, BFF converts 401/403 responses from local API endpoints into JSON-friendly responses (no redirect). Use `.SkipResponseHandling()` to bypass this and trigger normal ASP.NET Core authentication redirects:
```csharp
// ✅ Skip BFF's automatic 401/403 conversion — triggers actual OIDC redirect on challenge
app.MapGet("/api/interactive", () => Results.Ok("data"))
.RequireAuthorization()
.AsBffApiEndpoint()
.SkipResponseHandling();
```
### Conditional Anti-Forgery (V4)
In v4, `DisableAntiForgeryCheck` is a delegate that allows conditionally skipping anti-forgery per-request:
```csharp
builder.Services.AddBff(options =>
{
options.DisableAntiForgeryCheck = context =>
context.Request.Path.StartsWithSegments("/api/webhook");
});
```
---
## Pattern 4: Remote API Proxying
The BFF can act as a reverse proxy to APIs deployed on separate hosts. Requests carry only the session cookie; the BFF exchanges it for an access token before forwarding.
Install the YARP integration package:
```
dotnet add package Duende.BFF.Yarp
```
```csharp
// ✅ Direct forwarding via MapRemoteBffApiEndpoint
builder.Services.AddBff()
.AddRemoteApis();
// Maps /api/orders and all sub-paths to https://orders-service/orders
app.MapRemoteBffApiEndpoint("/api/orders", new Uri("https://orders-service/orders"))
.WithAccessToken(RequiredTokenType.User); // Attach the user's access token
app.MapRemoteBffApiEndpoint("/api/public", new Uri("https://content-service/public"))
.WithAccessToken(RequiredTokenType.None); // Anonymous remote API
app.MapRemoteBffApiEndpoint("/api/internal", new Uri("https://internal-service/api"))
.WithAccessToken(RequiredTokenType.Client); // Client credentials token (machine-to-machine)
```
### Token Type Options
| `RequiredTokenType` | Behavior |
|---|---|
| `None` | No token attached; anonymous passthrough |
| `User` | Forwards the current user's access token; challenges if unauthenticated |
| `Client` | Forwards a client credentials token; works even without a logged-in user |
| `UserOrClient` | Forwards user token if available, falls back to client token |
| `UserOrNone` | Forwards user token if logged in, no token if anonymous (no challenge). Replaces v3's `OptionalUserToken` |
### Custom Access Token Retriever (V4)
Implement `IAccessTokenRetriever` to customize per-route token retrieval. In v4, `DefaultAccessTokenRetriever` is internal — implement the interface directly:
```csharp
// ✅ Custom token retriever: select token based on route or request context
public class MyTokenRetriever : IAccessTokenRetriever
{
public Task<AccessTokenResult> GetAccessToken(GetAccessTokenContext context)
{
// Custom logic — e.g., choose token based on route or header
return Task.FromResult<AccessTokenResult>(
new BearerTokenResult(context.UserToken, "Bearer"));
}
}
// Register per-endpoint
app.MapRemoteBffApiEndpoint("/api/custom", new Uri("https://api.example.com"))
.WithAccessToken(RequiredTokenType.User)
.WithAccessTokenRetriever<MyTokenRetriever>();
```
### ForwarderRequestConfig (V4)
Configure per-endpoint activity timeout and response buffering for remote API proxying:
```csharp
app.MapRemoteBffApiEndpoint("/api/long-running", new Uri("https://api.example.com"))
.WithAccessToken(RequiredTokenType.User)
.WithForwarderRequestConfig(new ForwarderRequestConfig
{
ActivityTimeout = TimeSpan.FromMinutes(5),
AllowResponseBuffering = true
});
```
```csharp
// ✅ Restrict access in addition to token requirements
app.MapRemoteBffApiEndpoint("/api/admin", new Uri("https://admin-service/api"))
.WithAccessToken(RequiredTokenType.User)
.RequireAuthorization("AdminPolicy");
```
```csharp
// ❌ MapRemoteBffApiEndpoint opens the entire sub-path namespace.
// Do NOT use broad paths like "/" or "/api" unless all sub-routes should be exposed.
app.MapRemoteBffApiEndpoint("/", new Uri("https://backend-service")); // Exposes everything
```
---
## Pattern 5: Session Management
### Server-Side Sessions
Default cookie-based sessions embed claims and tokens in the cookie. For production, move session data server-side: the cookie only carries a session ID, keeping cookie size small and enabling server-initiated revocation.
> **Tokens never touch the cookie with server-side sessions.** All tokens — including **refresh tokens** — live in the server-side session store; the cookie holds only the session id. This is why the store choice is a security/availability decision, not just a size optimization.
>
> **The in-memory store is not durable and not shared:** sessions are lost on process restart, and in a load-balanced deployment a request routed to a *different* instance won't find the session (the user appears logged out). For any multi-node BFF, use a **persistent, shared** store — the EF store from `Duende.BFF.EntityFramework` (`AddEntityFrameworkServerSideSessions`).
```csharp
// ✅ In-memory server-side sessions (development/testing only)
builder.Services.AddBff()
.AddServerSideSessions();
// ✅ Production: persist with Entity Framework
// dotnet add package Duende.BFF.EntityFramework
builder.Services.AddBff()
.AddEntityFrameworkServerSideSessions(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("BffSessions"));
});
```
```csharp
// ✅ Session cleanup (v4): manual registration required
builder.Services.AddBff(options =>
{
options.SessionCleanupInterval = TimeSpan.FromMinutes(5);
})
.AddEntityFrameworkServerSideSessions(options =>
{
options.UseSqlServer(connectionString);
})
.AddSessionCleanupBackgroundProcess();
```
```csharp
// ❌ In-memory sessions are NOT suitable for production.
// Sessions are lost on restart; BFF horizontal scaling requires a shared store.
builder.Services.AddBff()
.AddServerSideSessions(); // No EF store — data lives only in process memory
```
### EF Migrations for Session Store
```bash
dotnet ef migrations add UserSessions -o Migrations -c SessionDbContext
dotnet ef database update
```
---
## Pattern 6: Token Management Integration
BFF integrates with `Duende.AccessTokenManagement` (ATM) automatically when `SaveTokens = true` is set on the OIDC handler. Tokens are stored in the server-side session and refreshed transparently.
```csharp
// ✅ Retrieve the current user access token in a local API endpoint
app.MapGet("/api/data", async (HttpContext ctx, IHttpClientFactory factory) =>
{
// ATM handles refresh automatically if the token is expired
var token = await ctx.GetUserAccessTokenAsync();
var client = factory.CreateClient();
client.SetBearerToken(token);
var response = await client.GetAsync("https://remote-service/data");
return Results.Text(await response.Content.ReadAsStringAsync());
})
.AsBffApiEndpoint();
```
```csharp
// ✅ Named HttpClient with automatic token management (preferred pattern)
builder.Services.AddUserAccessTokenHttpClient("apiClient", configureClient: client =>
{
client.BaseAddress = new Uri("https://remote-service/");
});
app.MapGet("/api/proxy", async (IHttpClientFactory factory) =>
{
var client = factory.CreateClient("apiClient"); // Token attached automatically
return Results.Text(await (await client.GetAsync("data")).Content.ReadAsStringAsync());
})
.AsBffApiEndpoint();
```
```csharp
// ✅ Typed HttpClient with token handler
builder.Services.AddHttpClient<RemoteApiClient>(client =>
{
client.BaseAddress = new Uri("https://remote-service/");
})
.AddUserAccessTokenHandler();
```
```csharp
// ❌ Do NOT manually read tokens from the session and store them in JavaScript.
// This defeats the entire purpose of BFF. Tokens must stay server-side.
var token = await ctx.GetUserAccessTokenAsync();
return Results.Json(new { accessToken = token }); // ❌ Exposes token to browser
```
### Refresh Token Revocation
BFF revokes refresh tokens automatically at logout. Configure rotation behavior on IdentityServer — BFF clients are confidential clients and do **not** need rotating (one-time-use) refresh tokens.
```csharp
// ✅ Manually revoke if needed (e.g., on account compromise)
await HttpContext.RevokeUserRefreshTokenAsync();
```
---
## Pattern 7: SPA Integration
### Session Check Endpoint (`/bff/user`)
The `/bff/user` endpoint returns the current user's claims or `401`. Use it on SPA startup to determine authentication state.
```javascript
// ✅ React: check session on app load
async function getUser() {
const response = await fetch('/bff/user', {
headers: { 'X-CSRF': '1' } // Required anti-forgery header
});
if (response.ok) {
return await response.json();
}
return null; // 401 = not authenticated
}
```
### Fetch Wrapper for CSRF Header
Every `fetch()` call to a BFF API endpoint must include `X-CSRF: 1`. Wrap `fetch` globally rather than adding it to every call site.
```javascript
// ✅ Fetch wrapper that automatically appends the required CSRF header
function bffFetch(url, options = {}) {
return fetch(url, {
...options,
headers: {
'X-CSRF': '1',
...options.headers,
},
});
}
// Usage
const data = await bffFetch('/api/orders').then(r => r.json());
```
```javascript
// ❌ Missing X-CSRF header — BFF will return 401
const data = await fetch('/api/orders').then(r => r.json());
```
### Handling 401 and Session Expiry
BFF API endpoints return `401` (not a redirect) when the session has expired. The SPA must detect this and redirect to `/bff/login`.
```javascript
// ✅ Centralized 401 handling in fetch wrapper
async function bffFetch(url, options = {}) {
const response = await fetch(url, {
...options,
headers: { 'X-CSRF': '1', ...options.headers },
});
if (response.status === 401) {
// Session expired — redirect to BFF login endpoint
window.location.href = `/bff/login?returnUrl=${encodeURIComponent(window.location.pathname)}`;
return;
}
return response;
}
```
### Login and Logout Links
Login and logout are browser navigations, not `fetch` calls. Do not use `fetch` or `XMLHttpRequest` for these flows.
```javascript
// ✅ Navigate to login (triggers OIDC redirect)
window.location.href = '/bff/login';
// ✅ Navigate to logout — must include sid from /bff/user response
const user = await bffFetch('/bff/user').then(r => r.json());
const sid = user.find(c => c.type === 'sid')?.value;
window.location.href = `/bff/logout?sid=${sid}`;
```
---
## Pattern 8: Deployment Considerations
### SameSite Cookie Configuration
| Scenario | Recommended `SameSite` |
|---|---|
| IDP on same site as BFF (e.g., `auth.example.com` and `app.example.com`) | `Strict` |
| IDP on a different domain (e.g., Duende demo, Auth0, Azure AD) | `Lax` |
| Embedded in iframe or third-party context | Not supported — BFF requires first-party cookie |
```csharp
// ✅ Strict (preferred when IDP is same-site)
options.Cookie.SameSite = SameSiteMode.Strict;
// ✅ Lax (required when IDP is on a different domain)
options.Cookie.SameSite = SameSiteMode.Lax;
// ❌ None requires Secure=true and is only appropriate for third-party contexts
// which are fundamentally incompatible with the BFF pattern
options.Cookie.SameSite = SameSiteMode.None;
```
### Reverse Proxy / Path Base
When the BFF is hosted behind a reverse proxy (e.g., nginx, Azure Application Gateway), configure forwarded headers and path base so authentication callbacks resolve correctly.
```csharp
// ✅ Trust forwarded headers from proxy (add before UseAuthentication)
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
// ✅ If the BFF is mounted at a sub-path (e.g., /app)
app.UsePathBase("/app");
```
### CORS Policy
The BFF serves the SPA from the same origin, so CORS is typically not needed between the SPA and BFF. CORS should be configured only for cross-origin scenarios.
```csharp
// ✅ Restrict CORS to known origins if the BFF and SPA are on different origins
builder.Services.AddCors(options =>
{
options.AddPolicy("SpaPolicy", policy =>
{
policy.WithOrigins("https://app.example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials(); // Required for cookie-based auth across origins
});
});
app.UseCors("SpaPolicy");
```
### Data Protection in Clustered Deployments
When running multiple BFF instances, cookies and anti-forgery tokens must be decryptable by all nodes. Configure a shared Data Protection key store. See [ASP.NET Core Data Protection](https://docs.duendesoftware.com/general/data-protection/) for comprehensive configuration guidance — BFF depends on Data Protection equally to IdentityServer.
```csharp
// ✅ Shared key ring (e.g., Azure Blob Storage + Key Vault)
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(/* ... */)
.ProtectKeysWithAzureKeyVault(/* ... */);
// ✅ Shared key ring via database (e.g., Entity Framework)
builder.Services.AddDataProtection()
.PersistKeysToDbContext<ApplicationDbContext>();
```
```csharp
// ❌ Default in-memory key ring in multi-instance deployments
// Each instance generates its own keys; cookies from one instance
// cannot be decrypted by another.
builder.Services.AddDataProtection(); // No persistence — broken in clusters
```
---
## Pattern 9: YARP Reverse Proxy Integration
For complex proxying scenarios, BFF integrates with YARP (Yet Another Reverse Proxy) via the `Duende.BFF.Yarp` package, which provides full BFF token management and anti-forgery enforcement inside the YARP pipeline.
```bash
dotnet add package Duende.BFF.Yarp
```
### Setup with In-Code Configuration
```csharp
// ✅ YARP with BFF extensions — in-code route/cluster configuration
builder.Services.AddBff();
var proxyBuilder = builder.Services.AddReverseProxy()
.AddBffExtensions(); // Register BFF token management for YARP
// Configure routes in code using LoadFromMemory
proxyBuilder.LoadFromMemory(
routes:
[
new RouteConfig
{
RouteId = "api",
ClusterId = "api-cluster",
Match = new RouteMatch { Path = "/api/{**catch-all}" }
}
.WithAccessToken(TokenType.User) // Note: YARP uses TokenType, not RequiredTokenType
.WithAntiforgeryCheck()
],
clusters:
[
new ClusterConfig
{
ClusterId = "api-cluster",
Destinations = new Dictionary<string, DestinationConfig>
{
["default"] = new DestinationConfig
{
Address = "https://upstream-api.example.com"
}
}
}
]
);
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseBff();
app.UseAuthorization();
// ✅ UseAntiforgeryCheck() must be explicitly added inside MapReverseProxy
app.MapReverseProxy(proxyApp =>
{
proxyApp.UseAntiforgeryCheck();
});
app.Run();
```
```csharp
// ❌ Do NOT omit UseAntiforgeryCheck() in the YARP pipeline —
// anti-forgery is not automatically applied to YARP routes
app.MapReverseProxy(); // Missing UseAntiforgeryCheck()
```
### YARP Configuration via appsettings.json
When using JSON configuration instead of `LoadFromMemory`, set BFF behavior via route metadata:
```json
{
"ReverseProxy": {
"Routes": {
"api-route": {
"ClusterId": "api-cluster",
"Match": { "Path": "/api/{**catch-all}" },
"Metadata": {
"Duende.Bff.Yarp.TokenType": "User",
"Duende.Bff.Yarp.AntiforgeryCheck": "true"
}
}
},
"Clusters": {
"api-cluster": {
"Destinations": {
"default": { "Address": "https://upstream-api.example.com" }
}
}
}
}
}
```
> **Warning:** Metadata keys (`Duende.Bff.Yarp.TokenType`, `Duende.Bff.Yarp.AntiforgeryCheck`) are case-sensitive strings. Typos fail silently — no token is attached and no anti-forgery check is performed.
### YARP Code Configuration Extensions
Note: YARP routes use `TokenType` (not `RequiredTokenType` which is used by `MapRemoteBffApiEndpoint`).
| Extension | Purpose |
| --------------------------------- | ------------------------------ |
| `WithAccessToken(TokenType.User)` | Attach user access token |
| `WithAntiforgeryCheck()` | Enable anti-forgery validation |
| `WithOptionalUserAccessToken()` | Attach user token if available |
---
## Pattern 10: Multi-Frontend (V4)
BFF v4 supports serving multiple frontends from a single BFF host. Each frontend gets its own OIDC, cookie, and API configuration. The default single-frontend behavior is an implicit multi-frontend setup with one frontend.
### AutomaticallyRegisterBffMiddleware
By default, BFF middleware is auto-registered. In multi-frontend scenarios, disable this for manual control:
```csharp
builder.Services.AddBff(options =>
{
options.AutomaticallyRegisterBffMiddleware = false;
});
var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
// ✅ Register BFF middleware components individually for multi-frontend control
app.UseBffPreProcessing();
app.UseBffFrontendSelection();
app.UseBffPathMapping();
app.UseBffOpenIdCallbacks();
app.UseBffStaticFileProxying();
app.UseAuthorization();
```
### Frontend Configuration (Code)
```csharp
builder.Services.AddBff()
.AddFrontend("admin", frontend =>
{
frontend.MatchingPath = "/admin";
frontend.CdnIndexHtmlUrl = new Uri("https://cdn.example.com/admin/index.html");
frontend.ConfigureOpenIdConnect(options =>
{
options.Authority = "https://idp.example.com";
options.ClientId = "admin-client";
options.ClientSecret = "secret";
});
frontend.AddRemoteApi("api", remote =>
{
remote.PathMatch = "/api/admin";
remote.TargetUri = new Uri("https://admin-api.example.com");
remote.RequiredTokenType = RequiredTokenType.User;
});
});
```
### IIndexHtmlTransformer
Implement `IIndexHtmlTransformer` to inject frontend-specific configuration into the `index.html` before serving:
```csharp
public class FrontendConfigTransformer : IIndexHtmlTransformer
{
public Task<string> TransformAsync(string indexHtml, HttpContext context)
{
// Inject runtime configuration into the SPA's index.html
var config = $"<script>window.__CONFIG__ = {{ api: '/api' }};</script>";
return Task.FromResult(indexHtml.Replace("</head>", $"{config}</head>"));
}
}
```
### IndexHtmlDefaultCacheDuration
Control CDN index.html cache duration (default 5 minutes):
```csharp
builder.Services.AddBff(options =>
{
options.IndexHtmlDefaultCacheDuration = TimeSpan.FromMinutes(10);
});
```
---
## Pattern 11: Blazor Integration
### Blazor Server
```csharp
// ✅ Blazor Server: AddBlazorServer() integrates BFF session management with the circuit model
builder.Services.AddBff()
.ConfigureOpenIdConnect(options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "blazor-server";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.Scope.Add("api1");
options.Scope.Add("offline_access");
options.SaveTokens = true;
})
.AddBlazorServer();
```
`AddBlazorServer()` integrates BFF session management with Blazor Server's circuit model. Long-lived circuits may encounter expired sessions — configure appropriate polling intervals via `BffBlazorServerOptions`.
### Blazor WASM (Client)
```csharp
// ✅ Server-side Program.cs
builder.Services.AddBff()
.ConfigureOpenIdConnect(options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "blazor-wasm";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.Scope.Add("api1");
options.Scope.Add("offline_access");
options.SaveTokens = true;
})
.AddBffBlazorClient();
```
```csharp
// ✅ Client-side Program.cs (WASM project)
builder.Services.AddBffBlazorClient(options =>
{
options.RemoteApiPath = "/api/remote";
options.Polling = new BffBlazorClientPollingOptions
{
Interval = TimeSpan.FromSeconds(30) // Default is 5 seconds
};
});
// AddLocalApiHttpClient<T>() creates a typed HTTP client that routes through the BFF host
builder.Services.AddLocalApiHttpClient<WeatherClient>();
```
### BffBlazorServerOptions
| Option | Default | Purpose |
| ----------------- | --------- | --------------------------------- |
| `PollingInterval` | 5 seconds | How often to check session status |
### BffBlazorClientOptions
| Option | Default | Purpose |
| ------------------ | ------------- | ------------------------------- |
| `RemoteApiPath` | `/api/remote` | Base path for remote API calls |
| `BaseAddress` | (from host) | Base address for API calls |
| `Polling.Interval` | 5 seconds | Session status polling interval |
---
## BffOptions Reference
| Option | Default | Purpose |
| ----------------------------------- | ----------- | ---------------------------------------------------- |
| `AntiForgeryHeaderName` | `"X-CSRF"` | Name of the anti-forgery header |
| `AntiForgeryHeaderValue` | `"1"` | Expected value of the anti-forgery header |
| `ManagementBasePath` | `"/bff"` | Base path for management endpoints |
| `RevokeRefreshTokenOnLogout` | `true` | Revoke refresh tokens on logout |
| `AnonymousSessionResponse` | (null) | Response for `/bff/user` when anonymous |
| `BackchannelLogoutAllUserSessions` | `false` | Logout all sessions on backchannel notification |
| `SessionCleanupInterval` | 10 minutes | Interval for expired session cleanup |
| `AutomaticallyRegisterBffMiddleware`| `true` | V4: Auto-register BFF middleware; set `false` for multi-frontend manual control |
| `DisableAntiForgeryCheck` | (null) | V4: Delegate to conditionally skip anti-forgery per-request |
| `IndexHtmlDefaultCacheDuration` | 5 minutes | V4: CDN index.html cache duration |
| `Diagnostics.LogFrequency` | (default) | V4: How often BFF logs diagnostic information |
| `Diagnostics.ChunkSize` | (default) | V4: Size of diagnostic log chunks |
> **V4 Breaking Change:** `EnableSessionCleanup` has been removed. Use `.AddSessionCleanupBackgroundProcess()` on the BFF builder instead.
---
## Extensibility: Logout Endpoint (V4)
Customize the logout endpoint by implementing `ILogoutEndpoint`:
```csharp
public class CustomLogoutEndpoint : ILogoutEndpoint
{
private readonly ILogoutEndpoint _inner;
public CustomLogoutEndpoint(ILogoutEndpoint inner) => _inner = inner;
public async Task<IResult> ProcessRequestAsync(HttpContext context)
{
// Pre-processing: audit log, cleanup, etc.
var result = await _inner.ProcessRequestAsync(context);
// Post-processing
return result;
}
}
```
Validate return URLs with `IReturnUrlValidator` to prevent open redirector attacks.
---
## Extensibility: Session Store (V4)
V4 uses `UserSessionKey` and `PartitionKey` types instead of raw strings. The `IUserSessionStore` interface:
```csharp
public interface IUserSessionStore
{
Task<UserSession?> GetUserSessionAsync(UserSessionKey key, CancellationToken ct);
Task CreateUserSessionAsync(UserSession session, CancellationToken ct);
Task UpdateUserSessionAsync(UserSessionKey key, UserSessionUpdate session, CancellationToken ct);
Task DeleteUserSessionAsync(UserSessionKey key, CancellationToken ct);
Task<IReadOnlyCollection<UserSession>> GetUserSessionsAsync(
PartitionKey partitionKey, UserSessionsFilter filter, CancellationToken ct);
Task DeleteUserSessionsAsync(
PartitionKey partitionKey, UserSessionsFilter filter, CancellationToken ct);
}
```
Register a custom store: `.AddServerSideSessions<YourCustomStore>()`
Session cleanup is a separate concern — implement `IUserSessionStoreCleanup` and register with `.AddSessionCleanupBackgroundProcess()`.
---
## Common Pitfalls
- **Calling `/bff/login` or `/bff/logout` via `fetch()`** — These endpoints trigger OIDC redirects and must be browser navigations (`window.location.href`), not AJAX calls.
- **Omitting `offline_access` scope** — Without a refresh token, BFF cannot automatically renew expired access tokens. The user will receive 401 errors from remote APIs when their access token expires.
- **Using in-memory sessions in production** — `AddServerSideSessions()` without EF means sessions vanish on restart and cannot be shared across instances. Always use `AddEntityFrameworkServerSideSessions()` in production.
- **Forgetting `SaveTokens = true`** — Without this, OIDC tokens are not stored in the session, and `GetUserAccessTokenAsync()` returns nothing. Token management silently fails.
- **Missing `X-CSRF: 1` header in SPA fetch calls** — BFF returns 401 for API requests without the header. Centralize header injection in a fetch wrapper rather than adding it to each call site.
- **Incorrect middleware order** — `UseBff()` must come after `UseRouting()` and before `UseAuthorization()`. Any deviation silently breaks anti-forgery enforcement without a clear error.
- **Exposing access tokens to the frontend** — Returning token values from a local API endpoint to JavaScript completely defeats the BFF pattern and its token-theft protections.
- **Using `SameSite=Strict` with a cross-site IDP** — After the OIDC redirect back from the IDP, the browser won't send the post-login session cookie on the first request because it was a cross-site navigation. Use `Lax` when the IDP is on a different site.
- **Forgetting to revoke the refresh token on logout** — BFF does this automatically, but if `RevokeRefreshTokenOnLogout = false` is set, abandoned sessions retain valid refresh tokens indefinitely.
- **Not configuring Data Protection in multi-instance deployments** — Cookie decryption failures manifest as users being perpetually logged out in load-balanced environments.
- **YARP metadata key typos** — When using appsettings.json configuration for YARP, the metadata keys (`Duende.Bff.Yarp.TokenType`, `Duende.Bff.Yarp.AntiforgeryCheck`) are case-sensitive strings. A typo causes silent failure: no token is attached and no anti-forgery check is performed.
- **Forgetting `UseAntiforgeryCheck()` in the YARP pipeline** — Unlike `MapRemoteBffApiEndpoint`, YARP's anti-forgery enforcement is not automatic. `proxyApp.UseAntiforgeryCheck()` must be explicitly added inside `MapReverseProxy`; omitting it leaves YARP routes unprotected.
---
## Resources
- [Duende BFF Overview](https://docs.duendesoftware.com/bff/)
- [Getting Started: Single Frontend](https://docs.duendesoftware.com/bff/getting-started/single-frontend/)
- [Embedded (Local) APIs](https://docs.duendesoftware.com/bff/fundamentals/apis/local/)
- [Proxying Remote APIs](https://docs.duendesoftware.com/bff/fundamentals/apis/remote/)
- [Multi-Frontend](https://docs.duendesoftware.com/bff/fundamentals/multi-frontend/)
- [Server-Side Sessions](https://docs.duendesoftware.com/bff/fundamentals/session/server-side-sessions/)
- [Token Management](https://docs.duendesoftware.com/bff/fundamentals/tokens/)
- [Extensibility: Tokens](https://docs.duendesoftware.com/bff/extensibility/tokens/)
- [Extensibility: HTTP Forwarder](https://docs.duendesoftware.com/bff/extensibility/http-forwarder/)
- [Session Management Endpoints](https://docs.duendesoftware.com/bff/fundamentals/session/management/)
- [BFF Options Reference](https://docs.duendesoftware.com/bff/fundamentals/options/)
- [ASP.NET Core Data Protection](https://docs.duendesoftware.com/general/data-protection/)
- [BFF v3 → v4 Upgrade Guide](https://docs.duendesoftware.com/bff/upgrading/bff-v3-to-v4/)
- [NuGet: Duende.BFF](https://www.nuget.org/packages/Duende.BFF)
- [NuGet: Duende.BFF.Yarp](https://www.nuget.org/packages/Duende.BFF.Yarp)
- [NuGet: Duende.BFF.EntityFramework](https://www.nuget.org/packages/Duende.BFF.EntityFramework)
- Related skills: `aspnetcore-authentication`, `token-management`, `identityserver-configuration`
identity-security-hardening36.2 KB
---
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
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
| Document | Description | When to Load |
|----------|-------------|--------------|
| [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 |
| [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 |
| [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 |
---
## 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 ordering
var app = builder.Build();
// 1. HTTPS redirection — permanent redirect (308) for any HTTP request
app.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 configuration
builder.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 explicitly
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:
```csharp
// ✅ Required when hosted behind a load balancer or ingress
builder.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 pipeline
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:
```csharp
// ✅ Kestrel TLS — require TLS 1.2 minimum
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. 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 management
builder.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](https://docs.duendesoftware.com/general/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](https://docs.duendesoftware.com/general/data-protection/) for complete configuration guidance.
```csharp
// ✅ Data Protection for load-balanced IdentityServer
builder.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 refresh
idsvrBuilder.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 expire
idsvrBuilder.AddSigningCredential(newKey, SecurityAlgorithms.RsaSha256);
idsvrBuilder.AddValidationKey(oldKey, SecurityAlgorithms.RsaSha256);
// Phase 3: Drop old key — old tokens are all expired
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
```csharp
// ✅ Production-tuned client — short-lived access tokens, rotating refresh tokens
new 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 tokens
new 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 introspection
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:
```csharp
// ✅ Separate API resources = separate audiences
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:
```csharp
// ✅ API validates its own audience
builder.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 flow
new 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 mandatory
new 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
| 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)
```csharp
// ❌ Avoid — shared secrets can be extracted from config, logs, and memory
new 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 code
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.
```csharp
// ✅ Register a client that authenticates with private_key_jwt
new 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 assertion
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:
```csharp
// ✅ Two active secrets during rotation window
new 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 characters
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"
});
}
// 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 wildcards
new 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 host
new 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 list
public 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 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:
```csharp
// ❌ WRONG — implicit flow exposes tokens in browser history and referrer headers
new Client
{
AllowedGrantTypes = GrantTypes.Implicit
}
// ✅ CORRECT — use authorization code + PKCE for all interactive clients
new Client
{
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true
}
```
### Principle of Least Grant
```csharp
// ✅ Machine-to-machine service: only client_credentials
new Client
{
ClientId = "background.worker",
AllowedGrantTypes = GrantTypes.ClientCredentials,
// AllowOfflineAccess = false (default) — no refresh tokens for M2M
}
// ✅ Interactive web app: only authorization code
new Client
{
ClientId = "web.app",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true
}
// ❌ WRONG — granting more than needed
new 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 validation
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;
}
// 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](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](docs/cors-csp.md) 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](docs/rate-limiting.md) 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](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.
```csharp
// ✅ Tightened input length restrictions
builder.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 events
builder.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/page
public 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 logging
public 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 sink
services.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 exploitable
new Client { RequirePkce = false }
// ✅ CORRECT — RequirePkce = true is the default; never override it to false
new Client { RequirePkce = true }
```
### 2. Wildcard Redirect URIs
```csharp
// ❌ WRONG — open redirector: attacker steers code to their server
RedirectUris = { "https://*.example.com/*" }
// ✅ CORRECT — fully qualified, exact-match URIs only
RedirectUris = { "https://app.example.com/signin-oidc" }
```
### 3. Implicit Flow Still Enabled
```csharp
// ❌ WRONG — exposes tokens in URL fragments, browser history, referrer headers
AllowedGrantTypes = GrantTypes.Implicit
// ✅ CORRECT — authorization code + PKCE replaces implicit flow entirely
AllowedGrantTypes = GrantTypes.Code
```
### 4. Accepting `ForwardedHeaders` From Any Source
```csharp
// ❌ WRONG — attacker can spoof X-Forwarded-Proto: https from any IP
options.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
// KnownProxies is empty = accepts from anywhere
// ✅ CORRECT — restrict to known proxy IPs
options.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));
```
### 5. Plaintext Secrets in Source Control
```csharp
// ❌ WRONG — secret is committed to git history
ClientSecrets = { new Secret("SuperSecret123".Sha256()) }
// ✅ CORRECT — load from secret store or environment variable
ClientSecrets = { 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 explicitly
options.IssuerUri = "https://identity.example.com";
```
### 7. Long-Lived Access Tokens
```csharp
// ❌ WRONG — 8-hour access token gives attackers a huge replay window
AccessTokenLifetime = 28800
// ✅ CORRECT — 5–15 minutes; use refresh tokens for longer sessions
AccessTokenLifetime = 300
```
### 8. Missing Audience Validation at the API
```csharp
// ❌ WRONG — API accepts any token from the issuer, regardless of audience
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = false // Dangerous — token from any client works at any API
};
// ✅ CORRECT — validate the audience matches this specific API
options.Audience = "my-api";
options.TokenValidationParameters.ValidateAudience = true;
```
### 9. Shared Keys Across Environments
```csharp
// ❌ WRONG — development signing key committed to source control and reused in production
idsvrBuilder.AddDeveloperSigningCredential(); // Development only!
// ✅ CORRECT — automatic key management generates and rotates keys per-environment
// Each environment has its own isolated key material
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
- [Duende IdentityServer Deployment — Duende Docs](https://docs.duendesoftware.com/identityserver/deployment/)
- [Key Management — Duende Docs](https://docs.duendesoftware.com/identityserver/fundamentals/key-management/)
- [Client Authentication — Duende Docs](https://docs.duendesoftware.com/identityserver/tokens/client-authentication/)
- [CORS — Duende Docs](https://docs.duendesoftware.com/identityserver/tokens/cors/)
- [Reference Tokens — Duende Docs](https://docs.duendesoftware.com/identityserver/tokens/reference/)
- [Server-Side Sessions — Duende Docs](https://docs.duendesoftware.com/identityserver/ui/server-side-sessions/)
- [Pushed Authorization Requests — Duende Docs](https://docs.duendesoftware.com/identityserver/tokens/par/)
- [IdentityServerOptions Reference — Duende Docs](https://docs.duendesoftware.com/identityserver/reference/options/)
- [OAuth 2.0 Security Best Current Practice (RFC 9700)](https://www.rfc-editor.org/rfc/rfc9700)
- [PKCE (RFC 7636)](https://tools.ietf.org/html/rfc7636)
- [JWT Client Authentication (RFC 7523)](https://datatracker.ietf.org/doc/html/rfc7523)
- [mTLS Client Authentication (RFC 8705)](https://www.rfc-editor.org/rfc/rfc8705)
- [OWASP OAuth 2.0 Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/OAuth2_Cheat_Sheet.html)
- [ASP.NET Core Data Protection — Microsoft Docs](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview)
- [ASP.NET Core Rate Limiting — Microsoft Docs](https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit)
Referenced files: 3
identityserver4-migration31.9 KB
---
name: identityserver4-migration
description: Migrating from IdentityServer4 to Duende IdentityServer v8. Covers NuGet package replacement, namespace changes, API surface changes, EF Core database schema migrations, .NET target framework upgrade, license configuration, signing key migration, data protection, and UI template updates.
invocable: false
---
# Migrating from IdentityServer4 to Duende IdentityServer
> **Scope: IdentityServer4 only.** This skill covers migrating from **IdentityServer4** (v3.x and v4.x) to Duende IdentityServer. It does **not** cover migrating from **IdentityServer3** (the older Thinktecture/`IdentityServer3` NuGet package that ran on OWIN/Katana and .NET Framework). IdentityServer3 is a fundamentally different product with a different API surface, configuration model, and hosting stack. If you are on IdentityServer3, you must first port to IdentityServer4 on ASP.NET Core before using this guide.
## When to Use This Skill
- Planning a migration from IdentityServer4 to Duende IdentityServer — running the migration analysis tool
- Upgrading a project from IdentityServer4 (v3.x or v4.x) to Duende IdentityServer v8
- Replacing IdentityServer4 NuGet packages with Duende equivalents
- Updating `IdentityServer4.*` namespaces to `Duende.IdentityServer.*`
- Migrating EF Core database schemas from IdentityServer4 to Duende IdentityServer
- Upgrading the .NET target framework from `netcoreapp3.1` or `net5.0` to a current LTS version
- Resolving breaking API changes between IdentityServer4 and Duende IdentityServer
- Converting `Startup.cs`/`Program.cs` hosting patterns from Generic Host to minimal hosting
- Configuring the Duende IdentityServer license key after migration
- Determining the right Duende license edition based on client inventory (interactive vs. non-interactive)
- Preserving the issuer URI to maintain token and client trust continuity
- Migrating signing keys from IdentityServer4 developer signing credential to Duende automatic key management
- Verifying third-party authentication scheme compatibility with the new .NET version
- Updating UI templates (login, logout, consent) from IdentityServer4 Quickstart UI to Duende templates
## Core Principles
**Migration has two stages if starting from v3.x.** IdentityServer4 v3 → v4 introduced breaking changes in the `ApiResource`/`ApiScope` relationship (parent-child to many-to-many). If you are on v3, first migrate to v4 semantics, then migrate from v4 to Duende. If you are already on IdentityServer4 v4.x, you can go directly to Duende.
**Duende IdentityServer is the direct successor to IdentityServer4.** The API surface is intentionally similar — most code changes are namespace and package renames. Behavioral changes are minimal, but the database schema has new tables and columns for features like automatic key management, server-side sessions, DPoP, and PAR.
**The .NET target framework must be upgraded alongside the IdentityServer migration.** IdentityServer4 ran on `netcoreapp3.1` or `net5.0`. Duende IdentityServer v8 requires `net10.0`. You must follow Microsoft's ASP.NET Core migration guides for each major version jump.
**Database migrations require careful handling to avoid data loss.** The v3 → v4 schema change renames tables and restructures relationships. A naive EF Core migration will drop and recreate tables, losing data. Use the provided delta SQL scripts or manually craft migrations that preserve data.
**Licensing is required for production use.** Duende IdentityServer requires a valid license key for production. Without one, it runs in community/trial mode and logs a warning on startup.
Docs: https://docs.duendesoftware.com/identityserver/upgrades
---
## Migration Path Overview
```
IdentityServer4 v3.x or v4.x
│
▼ (Step 0: Run Migration Analysis Tool against running instance)
│
▼ (Stage 1: v3 → v4 API changes + DB migration — skip if already on v4)
IdentityServer4 v4.x
│
▼ (Stage 2: packages + namespaces + .NET upgrade + DB migration)
Duende IdentityServer v8.x
```
If already on IdentityServer4 v4.x, skip directly to Stage 2.
---
## Step 0: Run the Migration Analysis Tool (Recommended)
Before making any code changes, run the **Migration Analysis Tool** against your running IdentityServer4 instance. This tool inspects your live configuration and produces a report with specific recommendations.
The tool is a single file, [`MigrationAnalysisController.cs`](https://docs.duendesoftware.com/identityserver/upgrades/identityserver4-upgrade-analysis/), that you drop into your existing IdentityServer4 project. It does not require additional NuGet packages.
### What the tool inspects
| Data Point | Why It Matters |
|------------|---------------|
| **.NET runtime version** | Flags if you need to upgrade to .NET 10 |
| **IdentityServer4 version** | Determines if Stage 1 (v3 → v4) is needed before proceeding |
| **Client inventory** | Counts interactive (authorization code) vs. non-interactive (client credentials) clients — this determines which [Duende license edition](https://duendesoftware.com/products/identityserver) you need |
| **Issuer URI** | Reports the configured `IssuerUri` — must be preserved in Duende to avoid breaking existing tokens and client trust relationships |
| **Signing credential store type** | Identifies custom signing stores that may need compatibility updates |
| **Signing credential key ID** | Records the current key ID for signing key migration planning |
| **Data protection application name** | Flags missing or path-based discriminators that will break after .NET upgrade |
| **Data protection repository type** | Warns if keys are stored ephemerally (lost on restart) instead of a persistent store |
| **Authentication schemes** | Lists all registered authentication handlers — third-party handlers (non-Microsoft, non-IdentityServer4) may need version updates for the new ASP.NET Core version |
### Usage
1. Download `MigrationAnalysisController.cs` and add it to your IdentityServer4 project
2. **Update the authorization check** in the `Index()` method — the default placeholder checks for username `"scott"` which you must replace with your own authorization logic
3. Build, run, and navigate to `/MigrationAnalysis` while authenticated
4. Review the report and use it to plan your migration
The tool loads clients from in-memory configuration or EF Core stores automatically. If you use a custom client store, you will need to modify the constructor to wire up your client retrieval.
> **Note:** Duende also offers a [free IdentityServer4 upgrade assessment](https://duendesoftware.com) to walk through your upgrade path.
---
## Stage 1: IdentityServer4 v3.x → v4.x
Skip this section if you are already on IdentityServer4 v4.x.
### Step 1.1: Update NuGet Packages to v4
```xml
<!-- Old (v3) -->
<PackageReference Include="IdentityServer4" Version="3.1.4" />
<PackageReference Include="IdentityServer4.EntityFramework" Version="3.1.4" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="3.1.4" />
<!-- New (v4) -->
<PackageReference Include="IdentityServer4" Version="4.1.2" />
<PackageReference Include="IdentityServer4.EntityFramework" Version="4.1.2" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="4.1.2" />
```
### Step 1.2: Register API Scopes Separately
In v3, `ApiScope` was a child of `ApiResource`. In v4, scopes are independent top-level objects with a many-to-many relationship to API resources. You must register them separately:
```csharp
// v3: Scopes nested inside ApiResource
new ApiResource("api1", "My API")
{
Scopes = { new Scope("api1.read"), new Scope("api1.write") }
}
// v4: Scopes are independent; ApiResource references scope names
public static IEnumerable<ApiScope> ApiScopes => new[]
{
new ApiScope("api1.read", "Read access to API 1"),
new ApiScope("api1.write", "Write access to API 1")
};
public static IEnumerable<ApiResource> ApiResources => new[]
{
new ApiResource("api1", "My API")
{
Scopes = { "api1.read", "api1.write" } // string references, not Scope objects
}
};
// Register both:
services.AddIdentityServer()
.AddInMemoryApiScopes(Config.ApiScopes) // NEW in v4
.AddInMemoryApiResources(Config.ApiResources)
.AddInMemoryClients(Config.Clients);
```
### Step 1.3: Fix Breaking API Changes (v3 → v4)
**HttpContext.SignInAsync signature change:**
```csharp
// v3
await HttpContext.SignInAsync(user.SubjectId, user.Username, props);
// v4
var isuser = new IdentityServerUser(user.SubjectId)
{
DisplayName = user.Username
};
await HttpContext.SignInAsync(isuser, props);
```
**AuthorizationRequest property changes:**
```csharp
// v3
var clientId = request.ClientId;
var scopes = request.ScopesRequested;
var isPkce = await _clientStore.IsPkceClientAsync(context.ClientId);
// v4
var clientId = request.Client.ClientId;
var scopes = request.ValidatedResources.RawScopeValues;
var isPkce = context.IsNativeClient();
```
**Consent response changes:**
```csharp
// v3
var grantedConsent = new ConsentResponse
{
ScopesConsented = consentedScopes
};
// v4
var grantedConsent = new ConsentResponse
{
ScopesValuesConsented = consentedScopes // renamed property
};
```
**Grant management method renames:**
```csharp
// v3
await _interaction.GetAllUserConsentsAsync();
// v4
await _interaction.GetAllUserGrantsAsync();
```
**External provider callback consolidation:**
```csharp
// v3: separate methods per protocol
ProcessLoginCallbackForOidc();
ProcessLoginCallbackForWsFed();
ProcessLoginCallbackForSaml2p();
// v4: single unified method
ProcessLoginCallback();
```
### Step 1.4: Migrate the Database (v3 → v4)
**PersistedGrantDbContext** — Standard EF migration:
```bash
dotnet ef migrations add Grants_v4 -c PersistedGrantDbContext -o Migrations/PersistedGrantDb
dotnet ef database update -c PersistedGrantDbContext
```
New columns added: `ConsumedTime`, `Description`, `SessionId` on `PersistedGrants` and `DeviceCodes`.
**ConfigurationDbContext** — Requires custom SQL to preserve data:
The v3 → v4 schema change renames tables:
- `ApiClaims` → `ApiResourceClaims`
- `ApiProperties` → `ApiResourceProperties`
- `ApiSecrets` → `ApiResourceSecrets`
- `IdentityClaims` → `IdentityResourceClaims`
- `IdentityProperties` → `IdentityResourceProperties`
And restructures the `ApiScopes` relationship (scopes become independent, linked via `ApiResourceScopes` join table).
**Do not rely on auto-generated EF migrations for this step — they will drop and recreate tables, losing data.** Instead:
1. Create the migration scaffold:
```bash
dotnet ef migrations add Config_v4 -c ConfigurationDbContext -o Migrations/ConfigurationDb
```
2. Embed a custom delta SQL script that migrates data before dropping old tables:
```sql
-- Move data from old tables to new tables
INSERT INTO ApiResourceClaims (Id, [Type], ApiResourceId)
SELECT Id, [Type], ApiResourceId FROM ApiClaims;
INSERT INTO ApiResourceProperties (Id, [Key], [Value], ApiResourceId)
SELECT Id, [Key], [Value], ApiResourceId FROM ApiProperties;
INSERT INTO ApiResourceSecrets (Id, [Description], [Value], [Expiration], [Type], [Created], ApiResourceId)
SELECT Id, [Description], [Value], [Expiration], [Type], [Created], ApiResourceId FROM ApiSecrets;
INSERT INTO IdentityResourceClaims (Id, [Type], IdentityResourceId)
SELECT Id, [Type], IdentityResourceId FROM IdentityClaims;
INSERT INTO IdentityResourceProperties (Id, [Key], [Value], IdentityResourceId)
SELECT Id, [Key], [Value], IdentityResourceId FROM IdentityProperties;
-- Migrate scope-resource relationship to join table
INSERT INTO ApiResourceScopes ([Scope], [ApiResourceId])
SELECT [Name], [ApiResourceId] FROM ApiScopes;
-- Remove old foreign key column from ApiScopes
-- (handled by EF migration after data is moved)
```
3. Modify the generated migration to execute the SQL script before the destructive operations.
4. Apply: `dotnet ef database update -c ConfigurationDbContext`
Reference implementation: [UpgradeSample-IdentityServer4-v3](https://github.com/DuendeSoftware/UpgradeSample-IdentityServer4-v3)
---
## Stage 2: IdentityServer4 v4.x → Duende IdentityServer v8.x
### Step 2.1: Update .NET Target Framework
Update from `netcoreapp3.1` or `net5.0` to `net10.0` (required by Duende IdentityServer v8):
```xml
<!-- Old -->
<TargetFramework>netcoreapp3.1</TargetFramework>
<!-- New -->
<TargetFramework>net10.0</TargetFramework>
```
Follow the Microsoft ASP.NET Core migration guides for each major version jump. Key changes include:
- Minimal hosting model (`WebApplication.CreateBuilder` replaces `Startup.cs` + `Program.cs` pattern)
- Nullable reference types enabled by default
- `ImplicitUsings` enabled by default
- Updated `Microsoft.EntityFrameworkCore.*` packages to match the .NET version
### Step 2.2: Replace NuGet Packages
```xml
<!-- Old (IdentityServer4) -->
<PackageReference Include="IdentityServer4" Version="4.1.2" />
<PackageReference Include="IdentityServer4.EntityFramework" Version="4.1.2" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="4.1.2" />
<PackageReference Include="IdentityModel" Version="5.2.0" />
<!-- New (Duende) -->
<PackageReference Include="Duende.IdentityServer" Version="8.0.0" />
<PackageReference Include="Duende.IdentityServer.EntityFramework" Version="8.0.0" />
<PackageReference Include="Duende.IdentityServer.AspNetIdentity" Version="8.0.0" />
<PackageReference Include="Duende.IdentityModel" Version="8.0.0" />
```
Also update EF Core and other ASP.NET Core packages to match the new target framework:
```xml
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" />
```
### Step 2.3: Update Namespaces
Search and replace all `IdentityServer4` namespaces with `Duende.IdentityServer`:
```csharp
// Old
using IdentityServer4;
using IdentityServer4.Models;
using IdentityServer4.Services;
using IdentityServer4.Stores;
using IdentityServer4.Extensions;
using IdentityServer4.Events;
using IdentityServer4.Test;
using IdentityServer4.Validation;
using IdentityServer4.EntityFramework.DbContexts;
using IdentityServer4.EntityFramework.Mappers;
using IdentityServer4.EntityFramework.Options;
using IdentityModel;
// New
using Duende.IdentityServer;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Events;
using Duende.IdentityServer.Test;
using Duende.IdentityServer.Validation;
using Duende.IdentityServer.EntityFramework.DbContexts;
using Duende.IdentityServer.EntityFramework.Mappers;
using Duende.IdentityServer.EntityFramework.Options;
using Duende.IdentityModel;
```
Also update any fully-qualified type references in code and configuration files.
### Step 2.4: Convert to Minimal Hosting (Recommended)
If migrating from `netcoreapp3.1`, convert the `Startup.cs` + `Program.cs` pattern to minimal hosting:
```csharp
// Old: Startup.cs + Program.cs pattern
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddIdentityServer()
.AddConfigurationStore(options => { /* ... */ })
.AddOperationalStore(options => { /* ... */ });
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();
app.UseEndpoints(e => e.MapDefaultControllerRoute());
}
}
// New: Minimal hosting in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddIdentityServer()
.AddConfigurationStore(options => { /* ... */ })
.AddOperationalStore(options => { /* ... */ });
var app = builder.Build();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Run();
```
### Step 2.5: Preserve the Issuer URI
The issuer URI (`iss` claim) must remain identical after migration. If it changes, all existing tokens become invalid and client trust relationships break.
```csharp
// If you had an explicit IssuerUri in IdentityServer4, keep it:
builder.Services.AddIdentityServer(options =>
{
options.IssuerUri = "https://identity.example.com";
});
// If the issuer was inferred from the request URL in IS4 (no explicit IssuerUri set),
// verify that the Duende host uses the same URL/port/scheme.
```
If your IS4 instance inferred the issuer from the request (no explicit `IssuerUri` configured), check the `/.well-known/openid-configuration` of your old instance, note the `issuer` value, and explicitly set it in the Duende configuration to be safe.
### Step 2.6: Configure the Duende License Key
Add the license key configuration — required for production:
```csharp
builder.Services.AddIdentityServer(options =>
{
options.LicenseKey = builder.Configuration["IdentityServer:LicenseKey"];
});
```
Store the license key in a secret manager, environment variable, or key vault — never in source-controlled `appsettings.json`.
Without a license key, IdentityServer runs in community/trial mode and logs a warning on startup. This is acceptable for local development.
**Choosing the right edition:** The license edition depends on your client inventory. Count interactive clients (those using `authorization_code` grant type — typically web apps, SPAs, native apps) vs. non-interactive clients (those using `client_credentials` — typically machine-to-machine). Run the Migration Analysis Tool (Step 0) to get these counts automatically. See [Duende IdentityServer Pricing](https://duendesoftware.com/products/identityserver) for edition thresholds.
### Step 2.7: Remove AddDeveloperSigningCredential
IdentityServer4 projects commonly used `AddDeveloperSigningCredential()` for development signing keys. Duende IdentityServer includes automatic key management (Business/Enterprise editions):
```csharp
// Old (remove)
services.AddIdentityServer()
.AddDeveloperSigningCredential();
// New: Automatic key management is built-in (Business/Enterprise)
// No explicit call needed — keys are created and rotated automatically
// Or for Community edition, configure a static signing credential:
builder.Services.AddIdentityServer()
.AddSigningCredential(new X509Certificate2("signing.pfx", "password"));
```
### Step 2.8: Migrate the Database Schema (v4 → Duende v8)
Create EF Core migrations for both contexts:
```bash
dotnet ef migrations add UpdateToDuende_v8 -c PersistedGrantDbContext \
-o Data/Migrations/IdentityServer/PersistedGrantDb
dotnet ef migrations add UpdateToDuende_v8 -c ConfigurationDbContext \
-o Data/Migrations/IdentityServer/ConfigurationDb
```
Apply:
```bash
dotnet ef database update -c PersistedGrantDbContext
dotnet ef database update -c ConfigurationDbContext
```
**New tables and columns in Duende IdentityServer v8:**
| Context | Change | Purpose |
|---------|--------|---------|
| Operational | `Keys` table (new) | Automatic key management storage |
| Operational | `ServerSideSessions` table (new) | Server-side session management |
| Operational | `PushedAuthorizationRequests` table (new) | PAR support |
| Operational | `SamlSignInStates` table (new) | SAML SSO state |
| Operational | `SamlLogoutSessions` table (new) | SAML SLO session tracking |
| Operational | `ConsumedTime` index on `PersistedGrants` | Performance optimization |
| Configuration | `IdentityProviders` table (new) | Dynamic OIDC provider configuration |
| Configuration | `SamlServiceProviders` table (new) | SAML SP registration |
| Configuration | `RequireResourceIndicator` column on `ApiResources` | Resource indicator support |
| Configuration | Timestamp columns on entities | Created, updated, last accessed tracking |
| Configuration | Unique constraints on child tables | Prevent duplicate entries |
| Client | `InitiateLoginUri` | Third-party initiated login |
| Client | `RequireDPoP`, `DPoPValidationMode`, `DPoPClockSkew` | DPoP enforcement |
| Client | `RequirePushedAuthorization`, `PushedAuthorizationLifetime` | PAR requirement |
**Note on redirect URI column length:** The `RedirectUri` column length was reduced from 2000 to 400 characters. This is safe unless you use redirect URIs longer than 400 characters, which is extremely uncommon.
### Step 2.9: Configure Data Protection
Set an explicit application name to prevent data protection key invalidation when paths change between .NET versions. See [ASP.NET Core Data Protection](https://docs.duendesoftware.com/general/data-protection/) for comprehensive guidance — this is a cross-cutting concern for all Duende SDKs.
```csharp
builder.Services.AddDataProtection()
.PersistKeysToDbContext<DataProtectionKeyContext>()
.SetApplicationName("YourIdentityServer");
```
**Why this matters:** The default application name (content root path) changed between .NET versions:
- .NET 3.1–5: content root without trailing separator
- .NET 6: content root with trailing separator (breaking change)
- .NET 7+: content root without trailing separator
If you relied on the default, tokens encrypted before the .NET upgrade will not decrypt after it.
**Persistent key storage is required in production.** If data protection has no explicit repository configured (`PersistKeysToDbContext`, `PersistKeysToFileSystem`, `PersistKeysToAzureBlobStorage`, etc.), keys are stored in-memory and lost on restart — meaning all encrypted data (persisted grants, cookies, antiforgery tokens) becomes unreadable. The Migration Analysis Tool (Step 0) flags this as `(not set)` in the data protection repository type check. If you see this, add persistent key storage before migrating.
### Step 2.10: Migrate Signing Keys
**Decision tree for signing key migration:**
1. **Can you restart all client applications and APIs?** → Remove old key, use automatic key management. All clients will fetch the new key from the discovery document.
2. **Cannot restart everything?** → Export the old signing key and configure it alongside automatic key management so existing tokens remain valid during the transition period.
```csharp
// Transitional: keep old key while automatic key management creates new keys
builder.Services.AddIdentityServer()
.AddSigningCredential(existingRsaKey) // old key for validation
// automatic key management handles new token signing
```
### Step 2.11: Verify Authentication Scheme Compatibility
Third-party authentication handlers registered in your IdentityServer4 project may need updates for the target ASP.NET Core version. The Migration Analysis Tool (Step 0) lists all registered authentication schemes and flags non-Microsoft, non-IdentityServer4 handlers.
**Common handlers that need updates:**
| Old Handler | Action |
|-------------|--------|
| WS-Federation (`Microsoft.AspNetCore.Authentication.WsFederation`) | Update NuGet package to match target .NET version |
| SAML2P (e.g., Sustainsys.Saml2, ITfoxtec.Identity.Saml2) | Update to a version compatible with .NET 10; note that Duende v8 has built-in SAML 2.0 IdP support (see `identityserver-saml` skill) |
| Social providers (Google, Facebook, Twitter, etc.) | Update `Microsoft.AspNetCore.Authentication.*` packages to match target framework |
| Custom `IAuthenticationHandler` implementations | Verify interface compatibility — `AuthenticateAsync`, `ChallengeAsync`, `ForbidAsync` signatures are stable, but constructor-injected types may have changed |
After migration, verify all external login flows work end-to-end. Missing or incompatible handlers will cause runtime errors when users attempt to authenticate via those schemes.
### Step 2.12: Update UI Templates
Not all IdentityServer4 projects have a UI layer. Projects that only configure stores, clients, and resources (e.g., headless API-only hosts or database migration utilities) have **no UI to migrate** — skip this step entirely.
If your project includes the IdentityServer4 Quickstart UI (login, logout, consent, error pages — typically MVC controllers with Razor views under `Views/` and `Controllers/`, or Razor Pages under `Pages/`), those templates must be updated. The IdentityServer4 Quickstart UI and the Duende IdentityServer UI templates have diverged significantly since 2018.
#### What Changed in the UI
- **Controller → Razor Pages migration**: Duende's newer templates use Razor Pages (`Pages/`) instead of MVC controllers (`Controllers/` + `Views/`). Your existing MVC-based UI will still compile and work after the namespace update, but you will miss newer UI flows.
- **New pages**: Duende templates include pages for device flow authorization, CIBA (Client-Initiated Backchannel Authentication), dynamic identity provider management, and server-side session management that did not exist in IdentityServer4.
- **API changes in view code**: Razor views that use `IIdentityServerInteractionService` must be updated for v4 API changes (e.g., `request.ClientId` → `request.Client.ClientId`, `ScopesConsented` → `ScopesValuesConsented`, `GetAllUserConsentsAsync` → `GetAllUserGrantsAsync`).
- **Namespace updates in views**: Any `@using IdentityServer4` directives in `.cshtml` files must become `@using Duende.IdentityServer`. Check `_ViewImports.cshtml` and individual view files.
- **Updated CSS and JavaScript**: Layout, styling, and client-side scripts have been refreshed.
#### Recommended Approaches
1. **Preferred: Start fresh** with Duende templates and port your customizations:
```bash
dotnet new install Duende.Templates
dotnet new duende-is-ui
```
This scaffolds the current Duende UI pages into your project. Diff the output against your existing UI to identify where your customizations belong.
2. **Alternative: Incremental** — use a diff tool to compare your current UI with the Duende templates and apply changes surgically. This is practical when your UI has heavy customizations and starting fresh would lose too much work.
3. **Minimum viable update** (for projects that just need to compile): Update namespaces in all `.cshtml` files and `_ViewImports.cshtml`, fix v4 API changes in controllers/page models, and defer the full UI refresh. This gets you running but leaves you on the older layout.
---
## Migration Checklist
Use this checklist to track your migration progress:
- [ ] **Run the Migration Analysis Tool** (Step 0) to get a baseline report of your current configuration
- [ ] **Determine starting version** — v3.x requires Stage 1 first; v4.x goes directly to Stage 2
- [ ] **Inventory clients** — count interactive vs. non-interactive for license edition selection
- [ ] **Update .NET target framework** to `net10.0`
- [ ] **Replace NuGet packages** — `IdentityServer4.*` → `Duende.IdentityServer.*`
- [ ] **Update all namespaces** — `IdentityServer4` → `Duende.IdentityServer`; `IdentityModel` → `Duende.IdentityModel`
- [ ] **Fix breaking API changes** (v3 → v4 if applicable)
- [ ] **Convert to minimal hosting** (if migrating from `netcoreapp3.1`)
- [ ] **Preserve issuer URI** — set `IssuerUri` explicitly to match existing deployment
- [ ] **Configure license key** via configuration/secret manager
- [ ] **Remove `AddDeveloperSigningCredential`** — use automatic key management or static credential
- [ ] **Create and apply database migrations** for both `ConfigurationDbContext` and `PersistedGrantDbContext`
- [ ] **Configure data protection** with explicit `SetApplicationName` and persistent key storage
- [ ] **Migrate or rotate signing keys**
- [ ] **Verify authentication scheme compatibility** — update third-party auth handlers for new .NET version
- [ ] **Update UI templates** — fresh start or incremental diff (skip if project has no UI layer)
- [ ] **Verify discovery document** at `/.well-known/openid-configuration`
- [ ] **Test token issuance and validation** end-to-end
- [ ] **Check application logs** for warnings or errors
---
## Common Migration Issues
**`IdentityServer4` namespace not found after package update** — You replaced the NuGet package but didn't update namespaces. Search and replace `using IdentityServer4` with `using Duende.IdentityServer` across all files.
**`Scope` type not found in v4** — In v4, `Scope` was removed as a nested type. API scopes are now top-level `ApiScope` objects. Update `ApiResource.Scopes` from `Scope` objects to string scope names.
**EF migration drops and recreates tables (v3 → v4)** — The auto-generated migration will destroy data. Use the custom delta SQL script approach described in Step 1.4.
**Data protection keys invalid after .NET upgrade** — The default application discriminator changed between .NET versions. Set `SetApplicationName()` explicitly to maintain key continuity.
**Data protection keys lost on restart** — If no persistent key repository is configured, data protection uses an ephemeral in-memory store. All encrypted data (persisted grants, cookies) becomes unreadable after restart. Configure `PersistKeysToDbContext`, `PersistKeysToFileSystem`, or `PersistKeysToAzureBlobStorage`.
**Issuer URI changed after migration** — If the `iss` claim in tokens no longer matches what clients/APIs expect, all existing tokens and trust relationships break. Set `options.IssuerUri` explicitly to match the value from your old `/.well-known/openid-configuration`.
**Third-party authentication handler fails at runtime** — External auth handlers (WS-Fed, SAML2P, social providers) compiled against older ASP.NET Core versions may fail to load. Update their NuGet packages to versions compatible with your target .NET version.
**Discovery document shows HTTP instead of HTTPS** — If behind a reverse proxy, configure forwarded headers. This is not migration-specific but commonly surfaces during deployment changes.
**`AddDeveloperSigningCredential` method not found** — This method still exists in Duende but is intended for development only. For production, use automatic key management or a static signing credential.
**`IsPkceClientAsync` method not found** — This was removed in v4. Use `context.IsNativeClient()` or check `request.Client.RequirePkce` directly.
**`ConsentResponse.ScopesConsented` property not found** — Renamed to `ScopesValuesConsented` in v4.
**Existing persisted grants fail to decrypt after migration** — Ensure ASP.NET Core Data Protection keys from the old deployment are still available. Data protection encrypts the `Data` column in persisted grants. If keys are lost, stored grants become unreadable.
---
## Version Compatibility Reference
| IdentityServer Version | .NET Version | EF Core Version |
|------------------------|-------------|-----------------|
| IdentityServer4 v3.x | .NET Core 3.1 | EF Core 3.1 |
| IdentityServer4 v4.x | .NET Core 3.1 / .NET 5 | EF Core 3.1 / 5.0 |
| Duende IdentityServer v5.x | .NET 5 / .NET 6 | EF Core 5.0 / 6.0 |
| Duende IdentityServer v6.x | .NET 6 / .NET 7 | EF Core 6.0 / 7.0 |
| Duende IdentityServer v7.x | .NET 8 | EF Core 8.0 |
| Duende IdentityServer v8.x | .NET 10 | EF Core 10.0 |
---
## Resources
- [Migration Analysis Tool](https://docs.duendesoftware.com/identityserver/upgrades/identityserver4-upgrade-analysis/) — pre-migration configuration inspector
- [Official Duende Migration Guide: IdentityServer4 to Duende v8](https://docs.duendesoftware.com/identityserver/upgrades/identityserver4-to-duende-identityserver-v8/)
- [UpgradeSample-IdentityServer4-v3 (reference project)](https://github.com/DuendeSoftware/UpgradeSample-IdentityServer4-v3)
- [Duende IdentityServer Upgrade Overview](https://docs.duendesoftware.com/identityserver/upgrades/)
- [Microsoft ASP.NET Core Migration Guides](https://learn.microsoft.com/en-us/aspnet/core/migration/)
- [Duende IdentityServer Templates](https://www.nuget.org/packages/Duende.Templates)
- Related skill: `identityserver-hosting-setup` — setting up and hosting Duende IdentityServer
- Related skill: `identityserver-stores` — EF Core store configuration and migrations
- Related skill: `identityserver-configuration` — client and resource configuration
- Related skill: `identityserver-key-management` — signing key management and rotation
- Related skill: `identityserver-upgrade-v7-to-v8` — additional v8 breaking changes (HybridCache, TimeProvider, CancellationToken on all interfaces)
identityserver-api-protection18.7 KB
---
name: identityserver-api-protection
description: "Protecting APIs with Duende IdentityServer: JWT bearer authentication, reference token introspection, scope-based authorization, DPoP/mTLS proof-of-possession validation, local API authentication, and multi-audience scenarios."
invocable: false
---
# Protecting APIs with IdentityServer
## When to Use This Skill
- Configuring JWT bearer authentication in an ASP.NET Core API to validate tokens from IdentityServer
- Setting up reference token introspection with `AddOAuth2Introspection`
- Handling both JWT and reference tokens in the same API using `ForwardReferenceToken`
- Implementing scope-based authorization policies
- Validating Proof-of-Possession tokens (DPoP and mTLS `cnf` claim)
- Protecting APIs hosted in the same application as IdentityServer (local API authentication)
- Securing multi-audience API deployments
Docs: https://docs.duendesoftware.com/identityserver/apis/
## Core Concepts
APIs are the resources that IdentityServer protects. Clients obtain access tokens from IdentityServer, then present those tokens to APIs. The API must validate the token and enforce authorization based on the token's claims (scopes, audience, subject, etc.).
### Token Formats at the API
| Format | Validation Method | Revocable | Network Dependency |
| -------------- | ------------------------------------------ | ---------------------- | ------------------------------------ |
| JWT (`at+jwt`) | Signature verification using issuer's JWKS | No (expires naturally) | None at validation time |
| Reference | Introspection endpoint call | Yes (immediate) | Requires IdentityServer availability |
## JWT Bearer Authentication
### Basic Setup
Install the standard Microsoft JWT bearer package:
```bash
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
```
Configure the authentication handler:
```csharp
// Program.cs
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
});
```
### Critical: JWT Type Validation
Always set `ValidTypes` to `["at+jwt"]` to protect against JWT confusion attacks. Without this, an attacker could present an identity token (which is also a JWT signed by the same issuer) to an API:
```csharp
// ❌ WRONG: No type validation — vulnerable to JWT confusion
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateAudience = true
};
// ✅ CORRECT: Validate the at+jwt type header
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
```
IdentityServer sets the `typ` header to `at+jwt` on all access token JWTs (per RFC 9068). This is controlled by `IdentityServerOptions.AccessTokenJwtType`.
### Audience Validation
The `Audience` property on `JwtBearerOptions` validates the `aud` claim in the access token. The audience value comes from the `ApiResource` name in IdentityServer:
```csharp
// IdentityServer configuration
var apiResource = new ApiResource("api1")
{
Scopes = { "api1.read", "api1.write" }
};
// API configuration
options.Audience = "api1";
```
If `Audience` is not set, audience validation is skipped (not recommended for production).
### Multi-Audience APIs
When an API belongs to multiple logical resources, configure multiple valid audiences:
```csharp
options.TokenValidationParameters.ValidAudiences = ["api1", "api2"];
```
## Reference Token Introspection
For APIs that receive reference tokens (opaque strings rather than JWTs), use the OAuth 2.0 introspection package:
```bash
dotnet add package Duende.IdentityServer.AccessTokenValidation
```
Or use the introspection handler directly:
```bash
dotnet add package Duende.AspNetCore.Authentication.OAuth2Introspection
```
```csharp
// Program.cs
builder.Services.AddAuthentication("token")
.AddOAuth2Introspection("token", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "api1";
options.ClientSecret = "api1_secret";
});
```
The `ClientId` and `ClientSecret` correspond to the `ApiResource` name and secret configured in IdentityServer:
```csharp
// IdentityServer configuration
var apiResource = new ApiResource("api1")
{
ApiSecrets = { new Secret("api1_secret".Sha256()) },
Scopes = { "api1.read" }
};
```
### Common Pitfall: Missing ApiSecrets
```csharp
// ❌ WRONG: No secret configured — introspection will fail with 401
var apiResource = new ApiResource("api1")
{
Scopes = { "api1.read" }
};
// ✅ CORRECT: ApiSecrets required for introspection
var apiResource = new ApiResource("api1")
{
ApiSecrets = { new Secret("secret".Sha256()) },
Scopes = { "api1.read" }
};
```
## Handling Both JWT and Reference Tokens
Use `ForwardReferenceToken` from the `Duende.AspNetCore.Authentication.JwtBearer` package to support both token formats in a single API. This selector inspects the token: if it contains a dot (`.`) it is treated as a JWT; otherwise it is forwarded to the introspection handler.
```bash
dotnet add package Duende.AspNetCore.Authentication.JwtBearer
```
```csharp
// Program.cs
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
// Forward reference tokens to the introspection handler
options.ForwardDefaultSelector =
Selector.ForwardReferenceToken("introspection");
})
.AddOAuth2Introspection("introspection", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "api1";
options.ClientSecret = "api1_secret";
});
```
### How ForwardReferenceToken Works
The selector checks whether the incoming Bearer token string contains a dot (`.`):
- **Contains a dot** → treated as a JWT, validated by `AddJwtBearer`
- **No dot** → treated as a reference token, forwarded to `AddOAuth2Introspection`
This is a simple heuristic: JWTs always contain dots (header.payload.signature), while reference tokens are opaque identifiers.
## Scope-Based Authorization
### Scope Claim Format
IdentityServer can emit scopes in two formats, controlled by `EmitScopesAsSpaceDelimitedStringInJwt`:
| Setting | Claim Format | Example |
| ----------------- | ---------------------- | -------------------------------------- |
| `false` (default) | JSON array | `"scope": ["api1.read", "api1.write"]` |
| `true` | Space-delimited string | `"scope": "api1.read api1.write"` |
### Normalizing Scope Claims
When scopes are emitted as a space-delimited string, the `scope` claim appears as a single string value. To normalize it back to individual claims for easier policy checks, implement a custom `IClaimsTransformation`:
```csharp
// Program.cs
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
});
// Register a custom claims transformation to split space-delimited scopes
builder.Services.AddTransient<IClaimsTransformation, ScopeClaimsTransformation>();
```
```csharp
// ScopeClaimsTransformation.cs
public class ScopeClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity!;
var scopeClaim = identity.FindFirst("scope");
if (scopeClaim != null && scopeClaim.Value.Contains(' '))
{
identity.RemoveClaim(scopeClaim);
foreach (var scope in scopeClaim.Value.Split(' '))
{
identity.AddClaim(new Claim("scope", scope));
}
}
return Task.FromResult(principal);
}
}
```
This transformation converts a space-delimited `scope` claim into individual `scope` claims, so authorization policies work consistently regardless of the format.
### Defining Authorization Policies
```csharp
// Program.cs
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "api1.read");
});
options.AddPolicy("write", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "api1.write");
});
});
```
Apply policies to endpoints:
```csharp
app.MapGet("/data", () => Results.Ok(data))
.RequireAuthorization("read");
app.MapPost("/data", (DataModel model) => Results.Created())
.RequireAuthorization("write");
```
Or with controllers:
```csharp
[Authorize(Policy = "read")]
[ApiController]
[Route("api/[controller]")]
public class DataController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(data);
[HttpPost]
[Authorize(Policy = "write")]
public IActionResult Post(DataModel model) => Created();
}
```
## Proof-of-Possession (PoP) Token Validation
Proof-of-Possession binds an access token to a specific client's cryptographic key, preventing token theft/replay. IdentityServer supports two PoP mechanisms: Mutual TLS (mTLS) and DPoP.
### mTLS Confirmation (cnf Claim)
When mTLS is used, the access token contains a `cnf` claim with the SHA-256 thumbprint of the client certificate:
```json
{
"cnf": {
"x5t#S256": "bBBDDeEFSS..."
}
}
```
To validate at the API, confirm the `cnf` thumbprint matches the client certificate presented on the TLS connection:
```csharp
// Program.cs
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
var cnfClaim = context.Principal?.FindFirst("cnf");
if (cnfClaim != null)
{
var certificate = context.HttpContext.Connection.ClientCertificate;
if (certificate == null)
{
context.Fail("Client certificate required for mTLS tokens");
return Task.CompletedTask;
}
var thumbprint = Base64UrlEncoder.Encode(
certificate.GetCertHash(HashAlgorithmName.SHA256));
var cnf = JsonDocument.Parse(cnfClaim.Value);
var expectedThumbprint = cnf.RootElement
.GetProperty("x5t#S256").GetString();
if (thumbprint != expectedThumbprint)
{
context.Fail("Certificate thumbprint does not match cnf claim");
}
}
return Task.CompletedTask;
}
};
});
```
### DPoP Validation
DPoP (Demonstration of Proof-of-Possession) uses a separate proof JWT in the `DPoP` HTTP header. Use the `Duende.AspNetCore.Authentication.JwtBearer` package which provides built-in DPoP validation:
```bash
dotnet add package Duende.AspNetCore.Authentication.JwtBearer
```
```csharp
// Program.cs
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
});
// Configure DPoP on the service collection, NOT inside AddJwtBearer
builder.Services.ConfigureDPoPTokensForScheme("token");
// DPoP replay detection requires a distributed cache
builder.Services.AddDistributedMemoryCache();
```
#### DPoP Validation Details
The `ConfigureDPoPTokensForScheme` extension is called on `IServiceCollection`, **not** inside the `AddJwtBearer` options lambda. It:
1. Validates the `DPoP` proof JWT in the request header
2. Confirms the `jkt` (JWK thumbprint) in the access token's `cnf` claim matches the proof key
3. Verifies the proof is bound to the correct HTTP method and URL
4. Uses `IDistributedCache` for nonce/replay detection
```csharp
// ❌ WRONG: DPoP configured inside AddJwtBearer lambda — this is not valid
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.ConfigureDPoPTokensForScheme("token"); // ← wrong location
});
// ✅ CORRECT: ConfigureDPoPTokensForScheme on IServiceCollection, plus distributed cache
builder.Services.AddDistributedMemoryCache(); // or Redis, SQL, etc.
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
});
builder.Services.ConfigureDPoPTokensForScheme("token");
```
## Local API Authentication
When your API is hosted in the same application as IdentityServer, use local API authentication to avoid the overhead of a network call to the token endpoint:
```csharp
// Program.cs (in the IdentityServer host)
builder.Services.AddIdentityServer()
.AddInMemoryClients(Config.Clients)
.AddInMemoryApiScopes(Config.ApiScopes);
builder.Services.AddLocalApiAuthentication();
```
### What AddLocalApiAuthentication Configures
`AddLocalApiAuthentication()` sets up:
- An authentication handler named `IdentityServerAccessToken` (available as `IdentityServerConstants.LocalApi.AuthenticationScheme`)
- An authorization policy named `IdentityServerConstants.LocalApi.PolicyName` that requires the `IdentityServerApi` scope
### Requiring the IdentityServerApi Scope
Clients that access local APIs must include `IdentityServerApi` in their allowed scopes:
```csharp
// IdentityServer configuration
var client = new Client
{
ClientId = "local_client",
AllowedScopes = { "openid", "profile", "IdentityServerApi" }
};
```
### Protecting Local API Endpoints
```csharp
// Using the built-in policy
app.MapGet("/local-api/data", () => Results.Ok(data))
.RequireAuthorization(IdentityServerConstants.LocalApi.PolicyName);
// Or with controllers
[Authorize(Policy = IdentityServerConstants.LocalApi.PolicyName)]
[ApiController]
[Route("local-api/[controller]")]
public class LocalDataController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(data);
}
```
### Custom Claims Transformation for Local APIs
You can add custom claims from the user store when using local API authentication:
```csharp
builder.Services.AddLocalApiAuthentication(principal =>
{
principal.Identities.First().AddClaim(
new Claim("additional_claim", "value"));
return Task.FromResult(principal);
});
```
## Complete Example: API with JWT, Reference Tokens, and Scope-Based Policies
```csharp
// Program.cs
using Duende.AspNetCore.Authentication.JwtBearer;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "api1";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
options.ForwardDefaultSelector =
Selector.ForwardReferenceToken("introspection");
})
.AddOAuth2Introspection("introspection", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "api1";
options.ClientSecret = "api1_secret";
});
// Custom claims transformation to normalize space-delimited scope claims
builder.Services.AddTransient<IClaimsTransformation, ScopeClaimsTransformation>();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("read", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "api1.read");
});
options.AddPolicy("write", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "api1.write");
});
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/data", () => Results.Ok(new { message = "Protected data" }))
.RequireAuthorization("read");
app.MapPost("/data", (DataModel model) => Results.Created())
.RequireAuthorization("write");
app.Run();
```
## Common Anti-Patterns
- ❌ Omitting `ValidTypes = ["at+jwt"]` — allows JWT confusion attacks where identity tokens are accepted as access tokens
- ✅ Always validate the `at+jwt` type header
- ❌ Using `AddOAuth2Introspection` without configuring `ApiSecrets` on the `ApiResource`
- ✅ Always set a shared secret between the API and the introspection endpoint
- ❌ Hardcoding scope checks against a space-delimited string without normalization
- ✅ Implement a custom `IClaimsTransformation` to split space-delimited scope claims into individual claims
- ❌ Configuring DPoP validation without registering `IDistributedCache`
- ✅ Always register a distributed cache implementation for DPoP replay detection
- ❌ Using local API authentication but forgetting to add `IdentityServerApi` to client scopes
- ✅ Clients accessing local APIs must request the `IdentityServerApi` scope
## Common Pitfalls
1. **Audience mismatch**: The `Audience` in `JwtBearerOptions` must match the `ApiResource` name in IdentityServer. A mismatch causes `401` responses with no clear error message in the API logs.
2. **Introspection returns inactive**: If introspection returns `active: false`, check that the `ApiResource` secret matches and the scopes are correctly associated with the resource.
3. **Scope claim format inconsistency**: If IdentityServer emits scopes as a space-delimited string but your policies expect individual claims, authorization will fail silently. Implement a custom `IClaimsTransformation` to normalize.
4. **ForwardReferenceToken with wrong scheme name**: The scheme name passed to `ForwardReferenceToken()` must exactly match the scheme name used in `AddOAuth2Introspection()`.
5. **DPoP nonce stale errors**: DPoP nonces have a limited validity window. If the API returns `use_dpop_nonce`, the client must retry with the new nonce from the `DPoP-Nonce` response header.
6. **Local API auth in separate host**: `AddLocalApiAuthentication()` only works when the API is co-hosted with IdentityServer. For separate API hosts, use JWT bearer or introspection.
7. **Missing scope normalization in production**: During development, scopes may work because of the default array format. When `EmitScopesAsSpaceDelimitedStringInJwt` is enabled (or changed), policies break without a custom `IClaimsTransformation` to split the scope claim.
identityserver-aspire17.2 KB
---
name: identityserver-aspire
description: Orchestrate Duende IdentityServer in .NET Aspire AppHost — dependency graphs, authority URL wiring, health checks, and multi-instance.
invocable: false
---
# Orchestrating IdentityServer with .NET Aspire
## When to Use This Skill
Use this skill when:
- Adding Duende IdentityServer to an Aspire-orchestrated solution
- Configuring service dependencies so clients and APIs wait for IdentityServer
- Passing IdentityServer's authority URL to dependent services via Aspire
- Wiring database resources for IdentityServer configuration and operational stores
- Ensuring IdentityServer exposes health checks for Aspire startup ordering
- Adding IdentityServer telemetry sources to Aspire service defaults
- Running multiple IdentityServer replicas in Aspire
- Integration testing an Aspire solution that includes IdentityServer
## Core Principles
1. **IdentityServer is a startup dependency** — Every client app and API depends on IdentityServer being available for discovery, token validation, and OIDC flows. Model this with `WithReference()` + `WaitFor()`.
2. **Explicit configuration over service discovery** — Pass the authority URL, client IDs, and scopes as explicit environment variables. App code reads `IConfiguration`/`IOptions<T>`, never Aspire service discovery.
3. **Health checks enable startup ordering** — Aspire's `WaitFor()` requires the target to expose a healthy health check endpoint. IdentityServer must be configured with health checks for startup ordering to work.
4. **Cross-reference, don't duplicate** — General Aspire and IdentityServer patterns are covered by existing skills. This skill covers only the unique orchestration intersection.
## Related Skills
- `identityserver-hosting-setup` — IdentityServer DI and middleware pipeline
- `identityserver-deployment` — production deployment, data protection, health check implementations
- `identityserver-data-storage` — EF Core stores for configuration and operational data
Docs: https://docs.duendesoftware.com/identityserver/deployment/
---
## Pattern 1: AppHost Orchestration Basics
IdentityServer is added to an Aspire AppHost like any ASP.NET Core project. The key
addition is wiring its database dependency so the database is ready before IdentityServer
starts.
```csharp
var builder = DistributedApplication.CreateBuilder(args);
var sqlServer = builder.AddSqlServer("sql");
var identityDb = sqlServer.AddDatabase("identitydb");
var identityServer = builder.AddProject<Projects.IdentityServer>("identity-server")
.WithReference(identityDb)
.WaitFor(sqlServer);
builder.Build().Run();
```
`WaitFor(sqlServer)` ensures the database is accepting connections before IdentityServer
starts. This matters because IdentityServer connects to EF Core stores on startup for
configuration and operational data.
> **Important:** The IdentityServer project itself is a standard ASP.NET Core application.
> See `identityserver-hosting-setup` for DI registration and middleware pipeline setup.
> This skill focuses only on how the AppHost orchestrates it.
---
## Pattern 2: Service Dependency Graph
This is the most critical pattern. Clients and APIs must depend on IdentityServer
because:
- **Clients** download the discovery document (`.well-known/openid-configuration`) at
startup to configure OIDC flows
- **APIs** configured with JWT Bearer authentication download JWKS (signing keys) from
IdentityServer at startup to validate tokens
- **OIDC login redirects** fail if IdentityServer isn't running when a user tries to
sign in
Without explicit dependency ordering, services start in parallel and fail with cryptic
"unable to obtain configuration" errors.
### Full dependency graph
```csharp
var builder = DistributedApplication.CreateBuilder(args);
var sqlServer = builder.AddSqlServer("sql");
var identityDb = sqlServer.AddDatabase("identitydb");
var identityServer = builder.AddProject<Projects.IdentityServer>("identity-server")
.WithReference(identityDb)
.WaitFor(sqlServer);
var api = builder.AddProject<Projects.WeatherApi>("weather-api")
.WithReference(identityServer)
.WaitFor(identityServer);
var webApp = builder.AddProject<Projects.WebApp>("web-app")
.WithReference(identityServer)
.WaitFor(identityServer)
.WithReference(api);
builder.Build().Run();
```
### What each call does
| Call | Effect |
|------|--------|
| `.WithReference(identityServer)` | Makes the IdentityServer endpoint URL available to the dependent service via service discovery |
| `.WaitFor(identityServer)` | Holds the dependent service from starting until IdentityServer's health check returns healthy |
Both are needed. `WithReference` alone provides the URL but doesn't prevent premature
startup. `WaitFor` alone doesn't expose the endpoint URL.
### Dependency flow
```
sqlServer ─► identity-server ─► weather-api
─► web-app ──► weather-api
```
> **Important:** Without `WaitFor(identityServer)`, the API and web app may start before
> IdentityServer is ready, causing `HttpRequestException` when fetching the discovery
> document or JWKS. This leads to `InvalidOperationException: IDX20803: Unable to obtain
> configuration from 'https://.../.well-known/openid-configuration'` errors at startup.
---
## Pattern 3: Authority URL and OIDC Configuration
`WithReference(identityServer)` makes the endpoint available via Aspire service discovery,
but client applications need explicit configuration for the OIDC authority URL, client ID,
and scopes. Use `WithEnvironment` to pass these as standard configuration values.
### Web application (OIDC client)
```csharp
var webApp = builder.AddProject<Projects.WebApp>("web-app")
.WithReference(identityServer)
.WaitFor(identityServer)
.WithEnvironment("Authentication__Authority", identityServer.GetEndpoint("https"))
.WithEnvironment("Authentication__ClientId", "web-app")
.WithEnvironment("Authentication__Scopes__0", "openid")
.WithEnvironment("Authentication__Scopes__1", "profile")
.WithEnvironment("Authentication__Scopes__2", "weather.read");
```
### API (JWT Bearer)
```csharp
var api = builder.AddProject<Projects.WeatherApi>("weather-api")
.WithReference(identityServer)
.WaitFor(identityServer)
.WithEnvironment("Authentication__Authority", identityServer.GetEndpoint("https"));
```
### Issuer URI consideration
By default, IdentityServer infers the issuer URI from incoming requests, which works
correctly within Aspire's network. Only override if the internal URL differs from what
clients see:
```csharp
var identityServer = builder.AddProject<Projects.IdentityServer>("identity-server")
.WithReference(identityDb)
.WaitFor(sqlServer)
.WithEnvironment("IdentityServer__IssuerUri", identityServer.GetEndpoint("https"));
```
> **Important:** Do NOT set `IssuerUri` unless the internal Aspire URL differs from what
> clients see. Mismatched issuer URIs cause token validation failures — the `iss` claim in
> tokens won't match the expected authority.
See `aspire-configuration` for the general pattern of reading these values via `IOptions<T>`
in the app project.
> **When generating app code:** The environment variables above map to standard
> `IConfiguration` keys (`Authentication:Authority`, `Authentication:ClientId`,
> `Authentication:Scopes:0`, etc.). When scaffolding the web app, configure
> `AddOpenIdConnect` to read `Authority` and `ClientId` from
> `builder.Configuration["Authentication:Authority"]` and
> `builder.Configuration["Authentication:ClientId"]`. Bind scopes from the
> `Authentication:Scopes` configuration section. For the API, configure
> `AddJwtBearer` with `options.Authority` from
> `builder.Configuration["Authentication:Authority"]`.
> See `aspnetcore-authentication` for full OIDC and JWT Bearer middleware setup.
> See `aspire-configuration` for the general `IOptions<T>` binding pattern.
---
## Pattern 4: Database and Store Wiring
IdentityServer typically needs its own database for configuration and operational stores.
Other services in the solution use separate databases for application data.
### Separate databases per service
```csharp
var sqlServer = builder.AddSqlServer("sql");
var identityDb = sqlServer.AddDatabase("identitydb");
var appDb = sqlServer.AddDatabase("appdb");
var identityServer = builder.AddProject<Projects.IdentityServer>("identity-server")
.WithReference(identityDb)
.WaitFor(sqlServer);
var api = builder.AddProject<Projects.WeatherApi>("weather-api")
.WithReference(appDb)
.WaitFor(sqlServer)
.WithReference(identityServer)
.WaitFor(identityServer);
```
`WithReference(identityDb)` sets `ConnectionStrings__identitydb` automatically. The
IdentityServer project's EF stores must use this connection string name:
```csharp
// In IdentityServer's Program.cs
builder.Services.AddIdentityServer()
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(builder.Configuration.GetConnectionString("identitydb"));
})
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(builder.Configuration.GetConnectionString("identitydb"));
});
```
### Migration strategies
- **Option A: Startup migration** — Call `Database.MigrateAsync()` in `Program.cs`. Simple
and suitable for development.
- **Option B: Dedicated migration service** — Add a separate migration runner project that
runs before IdentityServer:
```csharp
var migrations = builder.AddProject<Projects.MigrationRunner>("migrations")
.WithReference(identityDb)
.WaitFor(sqlServer);
var identityServer = builder.AddProject<Projects.IdentityServer>("identity-server")
.WithReference(identityDb)
.WaitFor(migrations); // Wait for migrations to complete
```
See `identityserver-data-storage` for EF Core store configuration details and migration
patterns.
---
## Pattern 5: Health Checks for Startup Readiness
Aspire's `WaitFor()` polls the target's `/health` endpoint. If IdentityServer doesn't
expose a health check, `WaitFor()` has no readiness signal and dependent services may
start too early or the AppHost may time out waiting.
### Minimal health check setup
```csharp
// In IdentityServer's Program.cs
builder.Services.AddHealthChecks();
// After building the app
app.MapHealthChecks("/health");
```
### Enhanced health checks with readiness validation
For production-grade startup ordering, add checks that validate IdentityServer can
actually serve discovery documents and signing keys:
```csharp
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
.AddCheck<DiscoveryDocumentHealthCheck>("discovery", tags: ["ready"])
.AddCheck<DiscoveryKeysHealthCheck>("jwks", tags: ["ready"]);
app.MapHealthChecks("/health");
app.MapHealthChecks("/alive", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
app.MapHealthChecks("/ready", new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("ready")
});
```
The `DiscoveryDocumentHealthCheck` and `DiscoveryKeysHealthCheck` verify that IdentityServer
can serve its discovery document and signing keys. These checks catch configuration errors
(missing signing credentials, database connection failures) before dependent services try
to connect.
> **Important:** The `DiscoveryDocumentHealthCheck` and `DiscoveryKeysHealthCheck`
> implementations are covered in the `identityserver-deployment` skill. Use them to ensure
> IdentityServer is fully operational before dependent services start.
If using `AddServiceDefaults()` from the Aspire service defaults project, the `/health`
and `/alive` endpoints are already mapped. You still need to register the
IdentityServer-specific health checks in the DI container.
---
## Pattern 6: Service Defaults Integration
IdentityServer emits OpenTelemetry traces and metrics under specific source names. To see
them in the Aspire dashboard, add these sources in the shared service defaults project.
### Tracing sources
Add IdentityServer activity sources to `ConfigureOpenTelemetry` in the service defaults
`Extensions.cs`:
```csharp
tracing
.AddSource(builder.Environment.ApplicationName)
// Duende IdentityServer trace sources
.AddSource("Duende.IdentityServer")
.AddSource("Duende.IdentityServer.Cache")
.AddSource("Duende.IdentityServer.Services")
.AddSource("Duende.IdentityServer.Stores")
.AddSource("Duende.IdentityServer.Validation")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation();
```
### Metrics
Add the IdentityServer meter:
```csharp
metrics
.AddMeter("Duende.IdentityServer")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation();
```
> **Important:** Use string literals (not `IdentityServerConstants.Tracing.*` or
> `Telemetry.ServiceName`) in service defaults to avoid adding a Duende.IdentityServer
> package reference to the shared project. Only the IdentityServer project itself should
> reference the Duende package.
See `aspire-service-defaults` for the full service defaults setup pattern and
`identityserver-deployment` for detailed telemetry guidance.
---
## Pattern 7: Multi-Instance Considerations
Aspire supports running multiple instances of a project with `WithReplicas`:
```csharp
var identityServer = builder.AddProject<Projects.IdentityServer>("identity-server")
.WithReference(identityDb)
.WaitFor(sqlServer)
.WithReplicas(3);
```
Running multiple IdentityServer instances requires shared state across all replicas:
- **Shared signing key store** — All instances must access the same signing keys via a
shared `ISigningKeyStore` (EF operational store or custom implementation)
- **Shared data protection keys** — All instances must share ASP.NET Data Protection keys
(Redis, database, or blob storage). Without this, authentication cookies encrypted by
one instance can't be decrypted by another.
- **Shared operational store** — Persisted grants, device codes, and server-side sessions
must be in a shared database
- **Distributed cache** — Required if using the OIDC state data formatter, JWT replay
cache, or Pushed Authorization Requests (PAR)
See `identityserver-deployment` for data protection and operational store configuration.
See `identityserver-data-storage` for EF Core store setup.
> **Important:** Do NOT use `.WithReplicas(n)` without first configuring shared state.
> Multiple instances with file-based signing keys or in-memory stores will produce token
> validation failures, lost sessions, and authentication cookie errors.
---
## Pattern 8: Integration Testing with IdentityServer
When integration testing an Aspire solution that includes IdentityServer, the key
challenge is that the authority URL uses a dynamic port assigned at runtime. Test clients
must discover the URL from the test fixture.
```csharp
public sealed class IdentityAspireFixture : IAsyncLifetime
{
private DistributedApplication? _app;
public async Task InitializeAsync()
{
var builder = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.MyApp_AppHost>();
_app = await builder.BuildAsync();
await _app.StartAsync();
// Wait for IdentityServer to be healthy before running tests
await _app.ResourceNotifications
.WaitForResourceHealthyAsync("identity-server");
}
public Uri GetAuthorityUrl() =>
_app!.GetEndpoint("identity-server", "https");
public HttpClient CreateApiClient() =>
_app!.CreateHttpClient("weather-api");
public async Task DisposeAsync()
{
if (_app is not null)
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}
}
```
The important details:
- **`WaitForResourceHealthyAsync("identity-server")`** ensures IdentityServer is fully
ready before any test runs. The resource name matches the name in the AppHost.
- **`GetEndpoint("identity-server", "https")`** returns the dynamic `https://localhost:{port}`
URL. Use this as the authority when configuring test `HttpClient` instances.
- **`CreateHttpClient("weather-api")`** creates a client pre-configured with the API's
dynamic base address.
---
## Do / Don't Checklist
**Do**
- Use `WithReference()` + `WaitFor()` for every service that depends on IdentityServer
- Pass authority URLs and OIDC settings as explicit environment variables
- Register health checks in IdentityServer for Aspire startup ordering
- Add IdentityServer telemetry sources to service defaults as string literals
- Use separate databases for IdentityServer and application data
**Don't**
- Start APIs or web apps without `WaitFor(identityServer)` — causes discovery failures
- Reference Duende packages from the shared service defaults project
- Use `WithReplicas()` without configuring shared state (signing keys, data protection, operational store)
- Set `IssuerUri` unless the internal and external URLs actually differ
- Duplicate Aspire or IdentityServer patterns covered by other skills — cross-reference them
---
## Resources
- .NET Aspire orchestration: https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/app-host-overview
- Aspire service dependencies: https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/app-host-overview#waiting-for-resources
- Duende IdentityServer documentation: https://docs.duendesoftware.com/
identityserver-configuration21.2 KB
---
name: identityserver-configuration
description: Configure Duende IdentityServer including client definitions, API resources, identity resources, scopes, signing credentials, and server-side sessions. Covers client types (M2M, interactive, SPA), grant types, API Scopes vs API Resources vs Identity Resources, secret management, and client authentication methods. Includes both in-memory and database-backed configuration.
invocable: false
---
# Duende IdentityServer Configuration
## When to Use This Skill
Use this skill when:
- Setting up a new Duende IdentityServer host
- Defining or modifying client registrations
- Configuring API resources, API scopes, or identity resources
- Setting up signing key management (automatic or static)
- Enabling server-side sessions
- Tuning `IdentityServerOptions` for production deployments
- Migrating from IdentityServer4 to Duende IdentityServer
## Core Principles
1. **Authorization Code + PKCE by Default** — Use `GrantTypes.Code` for all interactive clients. Never use implicit flow for new applications.
2. **Least Privilege Scopes** — Grant clients only the scopes they need. Avoid wildcard or overly broad scope assignments.
3. **Automatic Key Management** — Prefer the built-in automatic key rotation over static key configuration in production.
4. **API Resources for Audience Isolation** — Use `ApiResource` to control the `aud` claim and isolate API boundaries. Use `ApiScope` for fine-grained permission modeling within those boundaries.
5. **Server-Side Sessions for Enterprise** — Enable server-side sessions when you need centralized session management, back-channel logout, or session queries.
## Related Skills
- `identityserver-stores` — EF Core persistence for configuration and operational data
- `oauth-oidc-protocols` — Protocol fundamentals that underpin these configuration choices
- `identity-security-hardening` — Production hardening of IdentityServer deployments
- `token-management` — Client-side token lifecycle with Duende.AccessTokenManagement
- `aspnetcore-authentication` — Configuring OIDC authentication in client applications
Docs: https://docs.duendesoftware.com/identityserver/configuration
---
## Sub-Documents
Load these sub-documents when the user's question specifically targets one of these areas:
| Document | Description | When to Load |
|----------|-------------|--------------|
| [docs/client-types.md](docs/client-types.md) | Grant type selection matrix, client property reference tables, client authentication methods (shared secret, private_key_jwt, mTLS), secret rollover, and CORS | private_key_jwt, mTLS, secret rotation, refresh token settings, client authentication, CORS origins |
| [docs/resources-scopes.md](docs/resources-scopes.md) | Resource type decision matrix, identity resources, API scopes (including parameterized scopes), and API resources with audience isolation | aud claim, audience isolation, parameterized scopes, IScopeParser, IResourceValidator, EmitStaticAudienceClaim, API Resources, Identity Resources |
---
## Pattern 1: Hosting and Basic Setup
Register Duende IdentityServer in `Program.cs` with `AddIdentityServer`. All configuration flows from the `IdentityServerOptions` lambda and the builder's fluent API.
```csharp
var builder = WebApplication.CreateBuilder(args);
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
// Let the issuer be inferred from the request URL (recommended)
// options.IssuerUri = "https://identity.example.com"; // Only set when behind a reverse proxy
// Enable events for diagnostics
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients);
var app = builder.Build();
app.UseIdentityServer(); // Includes UseAuthentication()
app.UseAuthorization();
app.Run();
```
> **Important:** Call `UseIdentityServer()` instead of `UseAuthentication()` — it registers both the IdentityServer middleware and the authentication middleware.
---
## Pattern 2: Client Definitions
Clients represent applications that request tokens. The three most common configurations are:
### Machine-to-Machine (Client Credentials)
For service-to-service communication with no interactive user:
```csharp
new Client
{
ClientId = "service.worker",
ClientName = "Background Worker Service",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedScopes = { "api1", "api2.read_only" }
}
```
### Interactive Web Application (Authorization Code + PKCE)
For server-rendered web apps that authenticate users and call APIs:
```csharp
new Client
{
ClientId = "web.app",
ClientName = "Web Application",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true, // Default is true in Duende IS
ClientSecrets = { new Secret("secret".Sha256()) },
// Redirect URIs — must exactly match what the client sends
RedirectUris = { "https://app.example.com/signin-oidc" },
PostLogoutRedirectUris = { "https://app.example.com/signout-callback-oidc" },
FrontChannelLogoutUri = "https://app.example.com/signout-oidc",
// Enable offline access for refresh tokens
AllowOfflineAccess = true,
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
"api1"
}
}
```
### SPA with BFF Pattern
For JavaScript SPAs using the Backend-for-Frontend pattern (see `duende-bff` skill):
```csharp
new Client
{
ClientId = "spa.bff",
ClientName = "SPA with BFF",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = true, // BFF host holds the secret
ClientSecrets = { new Secret("secret".Sha256()) },
RedirectUris = { "https://app.example.com/signin-oidc" },
PostLogoutRedirectUris = { "https://app.example.com/signout-callback-oidc" },
BackChannelLogoutUri = "https://app.example.com/bff/backchannel",
AllowOfflineAccess = true,
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
"api1"
}
}
```
### Key Client Properties
| Property | Purpose | Default |
|----------|---------|---------|
| `RequirePkce` | Enforce PKCE for authorization code flow | `true` |
| `AllowOfflineAccess` | Enable refresh token issuance | `false` |
| `AccessTokenLifetime` | Access token duration in seconds | `3600` (1 hour) |
| `IdentityTokenLifetime` | Identity token duration in seconds | `300` (5 min) |
| `RefreshTokenUsage` | `ReUse` or `OneTimeOnly` | `ReUse` (recommend `OneTimeOnly` for security) |
| `RefreshTokenExpiration` | `Absolute` or `Sliding` | `Absolute` |
| `AbsoluteRefreshTokenLifetime` | Max refresh token lifetime in seconds | `2592000` (30 days) |
| `AllowedCorsOrigins` | CORS origins for token endpoint calls | empty |
| `RequireConsent` | Show consent screen | `false` |
| `CoordinateLifetimeWithUserSession` | Tie token lifetimes to user session | `false` |
### Defining Clients in appsettings.json
For scenarios where client configuration should be externalized:
```json
{
"IdentityServer": {
"Clients": [
{
"Enabled": true,
"ClientId": "local-dev",
"ClientName": "Local Development",
"ClientSecrets": [
{
"Value": "<Insert Sha256 hash of the secret encoded as Base64 string>"
}
],
"AllowedGrantTypes": ["client_credentials"],
"AllowedScopes": ["api1"]
}
]
}
}
```
```csharp
// Load clients from configuration
idsvrBuilder.AddInMemoryClients(
configuration.GetSection("IdentityServer:Clients"));
```
---
## Pattern 3: Identity Resources
Identity resources define groups of claims about users, requested via the `scope` parameter. They map to claims in the **identity token** and the **userinfo endpoint**.
### Standard Identity Resources
```csharp
public static IEnumerable<IdentityResource> IdentityResources =>
new IdentityResource[]
{
new IdentityResources.OpenId(), // Required — maps to "sub" claim
new IdentityResources.Profile(), // name, family_name, given_name, etc.
new IdentityResources.Email(), // email, email_verified
new IdentityResources.Phone(), // phone_number, phone_number_verified
new IdentityResources.Address(), // address (JSON object)
};
```
### Custom Identity Resources
Define custom identity resources for application-specific user claims:
```csharp
// ✅ Custom identity resource for tenant membership
new IdentityResource(
name: "tenant",
displayName: "Your organization info",
userClaims: new[] { "tenant_id", "tenant_name", "tenant_role" })
{
Required = true // Do not show on consent screen as optional
}
```
> **Key concept:** The `openid` scope is mandatory for any OpenID Connect request. It tells IdentityServer to return the `sub` (subject ID) claim.
---
## Pattern 4: API Scopes and API Resources
API scopes and API resources work together to model your API surface area. Understanding the distinction is critical.
### API Scopes — Permission Model
An `ApiScope` represents a permission or capability a client can request:
```csharp
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
// Simple scope — just a name
new ApiScope("api1", "Main API"),
// Granular scopes for fine-grained access
new ApiScope("catalog.read", "Read product catalog"),
new ApiScope("catalog.write", "Modify product catalog"),
new ApiScope("orders.manage", "Manage orders"),
// Scope that includes specific user claims in the access token
new ApiScope("invoicing", "Invoicing API")
{
UserClaims = { "department", "cost_center" }
}
};
```
### API Resources — Logical API Boundaries
An `ApiResource` represents a logical API (typically a deployed service). It groups scopes and controls the `aud` (audience) claim in access tokens:
```csharp
public static IEnumerable<ApiResource> ApiResources =>
new ApiResource[]
{
new ApiResource("catalog-api", "Product Catalog API")
{
Scopes = { "catalog.read", "catalog.write" },
// These claims are included when any scope in this resource is requested
UserClaims = { "role" }
},
new ApiResource("orders-api", "Order Management API")
{
Scopes = { "orders.manage" },
// API-specific secret for reference token introspection
ApiSecrets = { new Secret("orders-secret".Sha256()) }
}
};
```
### When to Use ApiResource vs ApiScope
| Scenario | Use ApiScope alone? | Add ApiResource? |
|----------|-------------------|------------------|
| Single API, simple permissions | ✅ Sufficient | Optional |
| Multiple APIs sharing a scope | ❌ | ✅ Required for audience isolation |
| Reference token introspection | ❌ | ✅ Required for API secrets |
| Per-API signing algorithms | ❌ | ✅ Use `AllowedTokenSigningAlgorithms` |
| Resource isolation (RFC 8707) | ❌ | ✅ Required |
### Resource Isolation
When multiple APIs share scope names, resource isolation prevents a token issued for one API from being used at another:
```csharp
// Two separate APIs that both have a "read" scope
new ApiResource("inventory-api") { Scopes = { "read", "write" } },
new ApiResource("reporting-api") { Scopes = { "read" } }
```
With resource isolation, the client specifies the target resource in the token request using the `resource` parameter (RFC 8707), and IdentityServer issues a token with a single audience.
---
## Pattern 5: Automatic Key Management
Duende IdentityServer's automatic key management handles signing key creation, rotation, and retirement. This is the recommended approach for production.
```csharp
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
// Automatic key management is enabled by default
// Customize rotation policy:
options.KeyManagement.RotationInterval = TimeSpan.FromDays(90); // New key every 90 days
options.KeyManagement.PropagationTime = TimeSpan.FromDays(14); // Announce 14 days early
options.KeyManagement.RetentionDuration = TimeSpan.FromDays(14); // Keep old keys 14 days
options.KeyManagement.DeleteRetiredKeys = true; // Clean up old keys
// Keys are encrypted at rest via ASP.NET Data Protection (default: true)
options.KeyManagement.DataProtectKeys = true;
});
```
### Key Lifecycle
Keys move through these phases:
1. **Announced** — Added to discovery but not used for signing (`PropagationTime` duration)
2. **Active** — Used for signing tokens (until `RotationInterval` is reached)
3. **Retired** — No longer signs tokens, but remains in discovery for validation (`RetentionDuration`)
4. **Deleted** — Removed from discovery (if `DeleteRetiredKeys` is true)
### Multiple Signing Algorithms
Support multiple algorithms for different clients or APIs:
```csharp
options.KeyManagement.SigningAlgorithms = new[]
{
// RS256 for maximum compatibility (first = default)
new SigningAlgorithmOptions(SecurityAlgorithms.RsaSha256)
{
UseX509Certificate = true // Wrap in X.509 certificate
},
// PS256 for enhanced security
new SigningAlgorithmOptions(SecurityAlgorithms.RsaSsaPssSha256),
// ES256 for compact tokens
new SigningAlgorithmOptions(SecurityAlgorithms.EcdsaSha256)
};
```
> The first algorithm in the list becomes the default. Clients and API resources can override via `AllowedTokenSigningAlgorithms`.
### Load-Balanced Deployments
For file-system key storage in load-balanced environments, all instances need access to the same key path:
```csharp
options.KeyManagement.KeyPath = "/home/shared/keys";
```
Alternatively, use the EF Core operational store for database-backed key storage (see `identityserver-stores`).
---
## Pattern 6: Static Key Configuration
When automatic key management is not available or you need explicit control:
```csharp
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.Enabled = false;
});
// Load key from secure storage
var signingKey = LoadKeyFromVault(); // Your key loading logic
idsvrBuilder.AddSigningCredential(signingKey, SecurityAlgorithms.RsaSha256);
```
### Manual Key Rotation (Three-Phase Process)
Rotating static keys requires careful sequencing to avoid breaking token validation:
```csharp
// Phase 1: Announce new key (keep signing with old key)
idsvrBuilder.AddSigningCredential(oldKey, SecurityAlgorithms.RsaSha256);
idsvrBuilder.AddValidationKey(newKey, SecurityAlgorithms.RsaSha256);
// Wait for all clients/APIs to refresh their JWKS cache (default: 24h)
// Phase 2: Start signing with new key (keep old key for validation)
idsvrBuilder.AddSigningCredential(newKey, SecurityAlgorithms.RsaSha256);
idsvrBuilder.AddValidationKey(oldKey, SecurityAlgorithms.RsaSha256);
// Wait for all tokens signed with old key to expire
// Phase 3: Remove old key
idsvrBuilder.AddSigningCredential(newKey, SecurityAlgorithms.RsaSha256);
```
---
## Pattern 7: Server-Side Sessions
Server-side sessions store authentication session data in a server-side store instead of the cookie alone. This enables centralized session management, queries, and back-channel logout.
```csharp
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
// Remove expired sessions automatically
options.ServerSideSessions.RemoveExpiredSessionsFrequency = TimeSpan.FromMinutes(10);
options.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout = true;
// Coordinate client token lifetimes with user sessions
options.Authentication.CoordinateClientLifetimesWithUserSession = true;
})
// Server-side sessions are enabled by calling AddServerSideSessions()
.AddServerSideSessions();
```
> **Important:** Server-side sessions are enabled by calling `.AddServerSideSessions()` on the IdentityServer builder — there is no `options.ServerSideSessions.Enabled` property. Add this to the builder chain, not to options.
### Session Expiration Options
| Option | Default | Purpose |
|--------|---------|---------|
| `ServerSideSessions.RemoveExpiredSessionsFrequency` | 10 min | Cleanup interval |
| `ServerSideSessions.RemoveExpiredSessions` | `true` | Enable automatic cleanup |
| `ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout` | `true` | Notify clients on session expiry |
> **Tip:** Combine server-side sessions with `CoordinateClientLifetimesWithUserSession = true` to ensure refresh tokens are revoked when a user's session ends.
---
## Pattern 8: Important IdentityServerOptions
### Events — Enable for Production Monitoring
```csharp
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseSuccessEvents = true;
```
### Authentication Cookie Settings
```csharp
options.Authentication.CookieLifetime = TimeSpan.FromHours(10);
options.Authentication.CookieSlidingExpiration = false;
```
### Caching (with Store Caching Enabled)
```csharp
options.Caching.ClientStoreExpiration = TimeSpan.FromMinutes(15);
options.Caching.ResourceStoreExpiration = TimeSpan.FromMinutes(15);
```
### Pushed Authorization Requests (PAR)
```csharp
options.PushedAuthorization.Required = true; // Require all clients to use PAR
```
### DPoP (Demonstrating Proof-of-Possession)
```csharp
options.DPoP.ValidationMode = DPoPTokenExpirationValidationMode.Nonce;
options.DPoP.ServerClockSkew = TimeSpan.FromMinutes(5);
```
---
## Common Pitfalls
### 1. Missing openid Scope
```csharp
// ❌ WRONG — OpenID Connect requires the openid scope
new Client
{
AllowedScopes = { "profile", "api1" }
}
// ✅ CORRECT — Always include openid for interactive clients
new Client
{
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
"api1"
}
}
```
### 2. Mismatched Redirect URIs
```csharp
// ❌ WRONG — Trailing slash mismatch causes "invalid_redirect_uri" error
RedirectUris = { "https://app.example.com/signin-oidc/" }
// Client sends: https://app.example.com/signin-oidc (no trailing slash)
// ✅ CORRECT — Exact match required
RedirectUris = { "https://app.example.com/signin-oidc" }
```
### 3. Using ApiScope When ApiResource Is Needed
```csharp
// ❌ WRONG — No audience claim, tokens work at any API
public static IEnumerable<ApiScope> ApiScopes =>
new[] { new ApiScope("read"), new ApiScope("write") };
// ✅ CORRECT — API resource sets audience for token isolation
public static IEnumerable<ApiResource> ApiResources =>
new[]
{
new ApiResource("my-api")
{
Scopes = { "read", "write" }
}
};
```
### 4. Plaintext Client Secrets in Source Control
```csharp
// ❌ WRONG — Secret in source code
ClientSecrets = { new Secret("my-production-secret".Sha256()) }
// ✅ CORRECT — Load from configuration or vault
ClientSecrets = { new Secret(configuration["Clients:Web:Secret"].Sha256()) }
// ✅ ALSO CORRECT — Use asymmetric credentials (no shared secret)
// Client authenticates with a signed JWT assertion
```
### 5. Forgetting AllowOfflineAccess for Refresh Tokens
```csharp
// ❌ Client requests "offline_access" scope but server doesn't allow it
var client = new Client
{
AllowedGrantTypes = GrantTypes.Code,
AllowOfflineAccess = false, // Default
AllowedScopes = { "openid", "api1" }
};
// Client silently won't receive a refresh token
// ✅ Enable offline access explicitly
var client = new Client
{
AllowedGrantTypes = GrantTypes.Code,
AllowOfflineAccess = true,
AllowedScopes = { "openid", "api1" }
};
```
### 6. Not Setting IssuerUri Behind a Reverse Proxy
```csharp
// ❌ IdentityServer behind Nginx but IssuerUri defaults to internal hostname
// Tokens contain iss: "http://internal-host:5000" — clients reject them
// ✅ Set IssuerUri to the external URL
options.IssuerUri = "https://identity.example.com";
```
---
## Production Configuration Checklist
| Setting | Dev | Production |
|---------|-----|------------|
| `KeyManagement.Enabled` | `true` | `true` |
| `KeyManagement.DataProtectKeys` | `true` | `true` + configure Data Protection |
| `Events.Raise*Events` | Optional | All `true` |
| `ServerSideSessions` (`AddServerSideSessions()`) | Optional | Recommended |
| Secrets | In-memory / config | Key vault / certificates |
| Store | In-memory | EF Core or custom |
| HTTPS | Optional | **Required** |
---
## Resources
- [Clients — Duende Docs](https://docs.duendesoftware.com/identityserver/fundamentals/clients/)
- [Resources — Duende Docs](https://docs.duendesoftware.com/identityserver/fundamentals/resources/)
- [Key Management — Duende Docs](https://docs.duendesoftware.com/identityserver/fundamentals/key-management/)
- [IdentityServerOptions Reference — Duende Docs](https://docs.duendesoftware.com/identityserver/reference/options/)
- [Server-Side Sessions — Duende Docs](https://docs.duendesoftware.com/identityserver/ui/server-side-sessions/)
- [Client Model Reference — Duende Docs](https://docs.duendesoftware.com/identityserver/reference/models/client/)
Referenced files: 2
identityserver-dcr11.2 KB
---
name: identityserver-dcr
description: "Configuring Dynamic Client Registration (DCR) in Duende IdentityServer: endpoint setup, authorization policies, custom validation with DynamicClientRegistrationValidator, software statement validation, IClientConfigurationStore, and separate DCR hosting."
invocable: false
---
# Dynamic Client Registration (DCR)
## When to Use This Skill
- Setting up Dynamic Client Registration (DCR) at `/connect/dcr`
- Securing the DCR endpoint with authorization policies
- Customizing DCR validation with `DynamicClientRegistrationValidator`
- Implementing software statement validation
- Persisting dynamically registered clients with `IClientConfigurationStore`
- Hosting DCR in a separate application from IdentityServer
## Core Principles
- DCR requires the `Duende.IdentityServer.Configuration` NuGet package
- Requires **Business Edition** or higher license
- Always secure the `/connect/dcr` endpoint with an authorization policy — never expose it unauthenticated
- Enforce PKCE and restrict allowed grant types in the DCR validator
- Use persistent stores (database) for dynamically registered clients in production
Docs: https://docs.duendesoftware.com/identityserver/configuration/dcr
## Overview
Dynamic Client Registration allows clients to register themselves at the `/connect/dcr` endpoint per RFC 7591. This feature requires the **Business Edition** or higher and has been available since version 6.3.
DCR uses a separate NuGet package and can be hosted in the same application as IdentityServer or in a separate host.
### Setup
```bash
dotnet add package Duende.IdentityServer.Configuration
```
```csharp
// Program.cs
builder.Services.AddIdentityServer()
.AddInMemoryClients(Config.Clients)
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes);
builder.Services.AddIdentityServerConfiguration();
var app = builder.Build();
app.UseIdentityServer();
app.UseAuthorization();
app.MapDynamicClientRegistration();
app.Run();
```
`MapDynamicClientRegistration()` is an endpoint-routing extension from the **`Duende.IdentityServer.Configuration`** package (separate from `Duende.IdentityServer`). Call it where you configure the pipeline/endpoint routing — in the quickstart/template hosts this is the `ConfigurePipeline()` method (`HostingExtensions.cs`), alongside `UseIdentityServer()`. `AddIdentityServerConfiguration()` registers the DCR services; `MapDynamicClientRegistration()` maps the `/connect/dcr` endpoint. Both are required.
### Securing the DCR Endpoint
Apply standard ASP.NET Core authorization policies to the DCR endpoint:
```csharp
// Using JWT bearer for the DCR endpoint
builder.Services.AddAuthentication()
.AddJwtBearer("dcr", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "IdentityServer.Configuration";
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("dcr", policy =>
{
policy.AddAuthenticationSchemes("dcr");
policy.RequireAuthenticatedUser();
policy.RequireClaim("scope", "IdentityServer.Configuration");
});
});
app.MapDynamicClientRegistration()
.RequireAuthorization("dcr");
```
### DCR Request and Response
**Registration request:**
```
POST /connect/dcr HTTP/1.1
Content-Type: application/json
Authorization: Bearer <access_token>
{
"client_name": "My Dynamic App",
"redirect_uris": ["https://app.example.com/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "client_secret_basic"
}
```
**Registration response:**
```json
{
"client_id": "generated-client-id",
"client_secret": "generated-secret",
"client_name": "My Dynamic App",
"redirect_uris": ["https://app.example.com/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"registration_client_uri": "https://identity.example.com/connect/dcr?client_id=generated-client-id",
"registration_access_token": "..."
}
```
### Customizing DCR Validation
Extend `DynamicClientRegistrationValidator` to add custom validation logic:
```csharp
public class CustomDcrValidator : DynamicClientRegistrationValidator
{
protected override Task ValidateGrantTypesAsync(
DynamicClientRegistrationContext context)
{
// Only allow authorization_code
var grantTypes = context.Request.GrantTypes;
if (grantTypes.Any(gt => gt != "authorization_code"))
{
context.SetError("Grant type not allowed");
return Task.CompletedTask;
}
return base.ValidateGrantTypesAsync(context);
}
protected override Task ValidateRedirectUrisAsync(
DynamicClientRegistrationContext context)
{
// Enforce HTTPS redirect URIs
var uris = context.Request.RedirectUris;
if (uris.Any(u => !u.StartsWith("https://", StringComparison.OrdinalIgnoreCase)))
{
context.SetError("Redirect URIs must use HTTPS");
return Task.CompletedTask;
}
return base.ValidateRedirectUrisAsync(context);
}
protected override Task SetClientDefaultsAsync(
DynamicClientRegistrationContext context)
{
// Set defaults for dynamically registered clients
var client = context.Client;
client.RequirePkce = true;
client.AllowOfflineAccess = false;
client.AccessTokenLifetime = 300; // 5 minutes
return base.SetClientDefaultsAsync(context);
}
}
```
Register:
```csharp
builder.Services.AddIdentityServerConfiguration()
.AddDynamicClientRegistrationValidator<CustomDcrValidator>();
```
### DynamicClientRegistrationContext
The context object passed to validation methods contains:
| Property | Purpose |
| --------- | ----------------------------------------------------- |
| `Client` | The IdentityServer `Client` being built |
| `Request` | The raw DCR request |
| `Caller` | The `ClaimsPrincipal` of the authenticated DCR caller |
| `Items` | Dictionary for passing data between validation steps |
### Software Statements
Software statements are signed JWTs that contain pre-approved client metadata. Validate them by overriding `ValidateSoftwareStatementAsync`:
```csharp
public class SoftwareStatementDcrValidator : DynamicClientRegistrationValidator
{
protected override async Task ValidateSoftwareStatementAsync(
DynamicClientRegistrationContext context)
{
var softwareStatement = context.Request.SoftwareStatement;
if (string.IsNullOrEmpty(softwareStatement))
{
context.SetError("Software statement required");
return;
}
var handler = new JsonWebTokenHandler();
var validationResult = await handler.ValidateTokenAsync(
softwareStatement,
new TokenValidationParameters
{
ValidIssuer = "https://trusted-authority.example.com",
IssuerSigningKeys = await GetTrustedKeysAsync(),
ValidateLifetime = true
});
if (!validationResult.IsValid)
{
context.SetError("Invalid software statement");
return;
}
// Apply claims from software statement to the client
var claims = validationResult.ClaimsIdentity;
context.Client.ClientName = claims.FindFirst("software_name")?.Value;
await base.ValidateSoftwareStatementAsync(context);
}
}
```
### Other DCR Extensibility Points
| Interface | Purpose |
| --------------------------------------------- | ---------------------------------------- |
| `IDynamicClientRegistrationRequestProcessor` | Process the DCR request (extend default) |
| `IDynamicClientRegistrationResponseGenerator` | Customize the DCR response |
### Client Configuration Store
DCR needs a persistent store for dynamically registered clients. Use the Entity Framework implementation:
```bash
dotnet add package Duende.IdentityServer.Configuration.EntityFramework
```
```csharp
builder.Services.AddIdentityServerConfiguration()
.AddClientConfigurationStore();
```
Or implement `IClientConfigurationStore` for a custom backing store:
```csharp
public class CustomClientConfigurationStore : IClientConfigurationStore
{
public async Task AddAsync(Client client)
{
// Persist the dynamically registered client
}
public async Task<Client?> FindByClientIdAsync(string clientId)
{
// Retrieve a dynamically registered client
}
public async Task UpdateAsync(Client client)
{
// Update client configuration
}
public async Task DeleteAsync(string clientId)
{
// Remove a dynamically registered client
}
}
```
### Separate DCR Host
DCR can be hosted in a separate application from IdentityServer:
```csharp
// Separate DCR host — Program.cs
builder.Services.AddIdentityServerConfiguration(options =>
{
options.IdentityServerBaseUrl = "https://identity.example.com";
});
builder.Services.AddAuthentication()
.AddJwtBearer("dcr", options =>
{
options.Authority = "https://identity.example.com";
options.Audience = "IdentityServer.Configuration";
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapDynamicClientRegistration().RequireAuthorization("dcr");
app.Run();
```
## Common Anti-Patterns
- **Exposing the DCR endpoint without authentication** — Always secure `/connect/dcr` with an authorization policy.
- **Allowing dynamically registered clients to use any grant type** — Restrict allowed grant types and enforce PKCE in the DCR validator.
- **Using in-memory stores for DCR clients in production** — Use persistent stores (database) for production deployments.
## Common Pitfalls
1. **Business Edition requirement**: `AddIdentityServerConfiguration()` requires a Business Edition or higher license. Community Edition does not support DCR.
2. **Client secrets**: Dynamically registered clients receive generated secrets. Ensure your `IClientConfigurationStore` stores these securely (hashed, not plaintext).
3. **Software statement trust**: Software statements must be validated against a trusted signing key. Do not accept software statements signed by unknown issuers.
4. **Separate host connectivity**: When hosting DCR separately, it must be able to communicate with IdentityServer's data stores. Ensure the `IClientConfigurationStore` is backed by the same database that IdentityServer reads from (or uses a shared data layer).
## Related Skills
- `identityserver-configuration` — IdentityServer host configuration, client types, grant types, secret management, and resource configuration
- `identityserver-saml` — SAML 2.0 Identity Provider (the other advanced IdentityServer feature)
- `identityserver-stores` — Persistent store patterns (useful for custom `IClientConfigurationStore`)
- `aspnetcore-authorization` — Authorization policies for securing the DCR endpoint
- `identity-security-hardening` — Security hardening including HTTPS enforcement
identityserver-deployment28.5 KB
---
name: identityserver-deployment
description: "Guide for deploying Duende IdentityServer to production, covering reverse proxy configuration, data protection, health checks, distributed caching, multi-instance deployment, OpenTelemetry integration, logging, and common deployment pitfalls."
invocable: false
---
# IdentityServer Deployment, Proxies, and Production Readiness
## When to Use This Skill
- Deploying IdentityServer behind a reverse proxy or load balancer
- Configuring ASP.NET Core Data Protection for production persistence
- Implementing health checks for monitoring IdentityServer instances
- Setting up distributed caching for multi-instance deployments
- Configuring OpenTelemetry for metrics, traces, and logs
- Troubleshooting common deployment issues (HTTPS downgrade, cookie problems, key rotation failures)
- Understanding the difference between Data Protection keys and IdentityServer signing keys
- Setting up logging and events for production monitoring
Docs: https://docs.duendesoftware.com/identityserver/deployment
## Deployment Architecture
IdentityServer is ASP.NET Core middleware. It can be hosted with the same diversity of technology as any ASP.NET Core application:
- **Hosting**: On-premises, cloud (Azure, AWS, GCP), containers, Kubernetes
- **Web servers**: Kestrel, IIS, Nginx, Apache
- **Artifacts**: Files, containers (no Dockerfile needed with `dotnet publish /t:PublishContainer`)
- **Scaling**: Horizontal with load balancers; requires shared state for multi-instance
## Reverse Proxy and Load Balancer Configuration
### The Problem
When IdentityServer runs behind a proxy that terminates TLS or changes the originating IP, the middleware sees incorrect request information. This causes:
- HTTPS requests downgraded to HTTP
- HTTP issuer published in `.well-known/openid-configuration` instead of HTTPS
- Incorrect host names in discovery document or redirects
- Cookies missing the `Secure` attribute (breaks `SameSite` behavior)
### Solution: ForwardedHeaders Middleware
Most proxies set `X-Forwarded-For` and `X-Forwarded-Proto` headers. Configure ASP.NET Core to read them.
#### Option 1: Environment Variable (Simplest)
Set `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true`. This automatically adds the middleware and accepts forwarded headers from any single proxy. Best for cloud-hosted environments and Kubernetes.
#### Option 2: Explicit Configuration (More Control)
```csharp
// Program.cs
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedHost |
ForwardedHeaders.XForwardedProto;
// Add the IP address of your known proxy
options.KnownProxies.Add(IPAddress.Parse("203.0.113.42"));
// Or use a network range
// var network = new IPNetwork(IPAddress.Parse("198.51.100.0"), 24);
// options.KnownNetworks.Add(network);
// Number of proxies in front of the app
options.ForwardLimit = 1;
});
```
**Important**: The ForwardedHeaders middleware must run **early** in the pipeline, before IdentityServer middleware and ASP.NET authentication middleware.
### Default KnownNetworks
By default, `KnownNetworks` and `KnownProxies` support localhost (`127.0.0.1/8` and `::1`). This is useful for local development or when the proxy and .NET host are on the same machine. In production, configure the actual proxy addresses.
## ASP.NET Core Data Protection
> **Cross-cutting concern:** Data protection is critical for all Duende products — both IdentityServer and BFF. See [ASP.NET Core Data Protection](https://docs.duendesoftware.com/general/data-protection/) for comprehensive guidance covering all Duende SDKs.
### Why It Matters
Data Protection is critical for IdentityServer. It encrypts and signs sensitive data including:
- Signing keys at rest (when automatic key management is used)
- Persisted grants at rest
- Server-side session data at rest
- State parameters for external OIDC providers
- UI message payloads (logout context, error context)
- Authentication session cookies
- Anti-forgery tokens
### Production Configuration
```csharp
// Program.cs
builder.Services.AddDataProtection()
// Choose a persistence method
.PersistKeysToFoo() // PersistKeysToFileSystem, PersistKeysToDbContext,
// PersistKeysToAzureBlobStorage, PersistKeysToAWSSystemsManager,
// PersistKeysToStackExchangeRedis
// Choose a key protection method
.ProtectKeysWithBar() // ProtectKeysWithCertificate, ProtectKeysWithAzureKeyVault
// Set explicit application name
.SetApplicationName("My.IdentityServer");
```
### Critical Rules
1. **Always persist keys to durable storage** using a `.PersistKeysTo...()` method
2. **Ensure the storage itself is durable** — e.g., if using Redis, configure Redis persistence (RDB/AOF)
3. **Always set an explicit application name** with `.SetApplicationName()` to prevent key isolation issues
4. **Share keys across all load-balanced instances**
5. **Consider a key escrow sink** — for backup/restore of corrupted data protection keys, configure an `IXmlEncryptor`-based escrow
### Data Protection Keys vs Signing Keys
| Aspect | Data Protection Keys | IdentityServer Signing Keys |
| ------------ | -------------------------------------------------- | ------------------------------------------------------------------------- |
| Purpose | Encrypt/sign sensitive data at rest and in cookies | Sign JWT tokens (id_tokens, access tokens) |
| Cryptography | Symmetric (private key) | Asymmetric (public/private key pair) |
| Visibility | Internal to the application | Public keys published via discovery/JWKS |
| Managed by | ASP.NET Core framework | IdentityServer (automatic key management) |
| Storage | Configured via `.PersistKeysTo...()` | File system (default), EF operational store, or custom `ISigningKeyStore` |
Both are critical secrets. Losing either causes failures.
### Common Data Protection Problems
| Problem | Symptom | Solution |
| ------------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------- |
| No shared keys in load-balanced environment | `CryptographicException`: key not found in key ring | Configure shared key persistence |
| Keys generated in dev included in build | Keys from wrong environment can't be read in production | Exclude `~/keys` directory from source control and builds |
| Application name mismatch | Keys from one deployment can't be read by another | Set explicit `SetApplicationName()` consistently |
| IIS lacking permissions | Ephemeral keys generated every restart | Follow Microsoft's IIS Data Protection configuration |
| .NET 6 path normalization change | Keys break between .NET versions | Always set explicit application name (reverted in .NET 7+) |
### Symptoms of Data Protection Failure
- `CryptographicException` in logs
- Error messages like "Error unprotecting key with kid {Signing Key ID}"
- "The key {Data Protection Key ID} was not found in the key ring"
- Automatic signing key management fails silently
## IdentityServer Data Stores for Multi-Instance
### Configuration Data
For multi-instance deployments, configuration data must be shared:
| Scenario | Recommendation |
| ----------------------------- | --------------------------------------------------------------------- |
| Rarely changing configuration | In-memory stores loaded from config files (with redeploy for changes) |
| Dynamic configuration (SaaS) | Database via EF Core stores or custom stores |
### Operational Data
Operational data must always be shared in multi-instance deployments:
- Authorization codes, tokens, consent — via persisted grant store
- Signing keys — via `ISigningKeyStore` (EF operational store or custom)
- Server-side sessions — via `IServerSideSessionStore`
Use Entity Framework Core or a persistent cache like Redis.
## Distributed Caching
Some optional features require ASP.NET Core's `IDistributedCache`:
| Feature | Why It Needs Distributed Cache |
| ----------------------------- | ------------------------------------------------------------ |
| OIDC state data formatter | Stores external provider state server-side instead of in URL |
| JWT replay cache | Prevents JWT client credentials replay |
| Device flow throttling | Rate-limits polling across instances |
| Authorization parameter store | Stores PAR request data |
Configure a distributed cache for multi-instance deployments:
```csharp
// Program.cs — Example using Redis
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
```
## Health Checks
### Discovery Endpoint Health Check
Tests that IdentityServer can process requests and communicate with the configuration store:
```csharp
public class DiscoveryHealthCheck : IHealthCheck
{
private readonly IEnumerable<Hosting.Endpoint> _endpoints;
private readonly IHttpContextAccessor _httpContextAccessor;
public DiscoveryHealthCheck(IEnumerable<Hosting.Endpoint> endpoints,
IHttpContextAccessor httpContextAccessor)
{
_endpoints = endpoints;
_httpContextAccessor = httpContextAccessor;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var endpoint = _endpoints.FirstOrDefault(
x => x.Name == IdentityServerConstants.EndpointNames.Discovery);
if (endpoint != null)
{
var handler = _httpContextAccessor.HttpContext.RequestServices
.GetRequiredService(endpoint.Handler) as IEndpointHandler;
if (handler != null)
{
var result = await handler.ProcessAsync(
_httpContextAccessor.HttpContext);
if (result is DiscoveryDocumentResult)
{
return HealthCheckResult.Healthy();
}
}
}
}
catch { }
return new HealthCheckResult(context.Registration.FailureStatus);
}
}
```
### JWKS Health Check
Tests that IdentityServer can access its signing keys:
```csharp
public class DiscoveryKeysHealthCheck : IHealthCheck
{
private readonly IEnumerable<Hosting.Endpoint> _endpoints;
private readonly IHttpContextAccessor _httpContextAccessor;
public DiscoveryKeysHealthCheck(IEnumerable<Hosting.Endpoint> endpoints,
IHttpContextAccessor httpContextAccessor)
{
_endpoints = endpoints;
_httpContextAccessor = httpContextAccessor;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
var endpoint = _endpoints.FirstOrDefault(
x => x.Name == IdentityServerConstants.EndpointNames.Jwks);
if (endpoint != null)
{
var handler = _httpContextAccessor.HttpContext.RequestServices
.GetRequiredService(endpoint.Handler) as IEndpointHandler;
if (handler != null)
{
var result = await handler.ProcessAsync(
_httpContextAccessor.HttpContext);
if (result is JsonWebKeysResult)
{
return HealthCheckResult.Healthy();
}
}
}
}
catch { }
return new HealthCheckResult(context.Registration.FailureStatus);
}
}
```
**Note**: Finding endpoints by name requires IdentityServer v6.3+.
## OpenTelemetry Integration
IdentityServer emits traces, metrics, and logs via the .NET OpenTelemetry SDK (added in v6.1, expanded in v7.0).
### Setup
```bash
dotnet add package OpenTelemetry
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
```
```csharp
// Program.cs
using OpenTelemetry.Resources;
// Add OpenTelemetry logging to correlate logs with traces
builder.Logging.AddOpenTelemetry();
var openTelemetry = builder.Services.AddOpenTelemetry();
openTelemetry.ConfigureResource(r => r
.AddService(builder.Environment.ApplicationName));
openTelemetry.WithMetrics(m => m
.AddMeter("Duende.IdentityServer") // Telemetry.ServiceName == "Duende.IdentityServer"
.AddPrometheusExporter());
openTelemetry.WithTracing(t => t
.AddSource(IdentityServerConstants.Tracing.Basic)
.AddSource(IdentityServerConstants.Tracing.Cache)
.AddSource(IdentityServerConstants.Tracing.Services)
.AddSource(IdentityServerConstants.Tracing.Stores)
.AddSource(IdentityServerConstants.Tracing.Validation)
.AddAspNetCoreInstrumentation()
.AddConsoleExporter());
// Add Prometheus scraping endpoint
app.UseOpenTelemetryPrometheusScrapingEndpoint();
```
### Tracing Sources
| Source | What It Traces |
| -------------------------------------------- | --------------------------------------------------------------- |
| `IdentityServerConstants.Tracing.Basic` | High-level request processing (validators, response generators) |
| `IdentityServerConstants.Tracing.Cache` | Cache operations |
| `IdentityServerConstants.Tracing.Services` | Service-layer operations |
| `IdentityServerConstants.Tracing.Stores` | Store operations (database calls) |
| `IdentityServerConstants.Tracing.Validation` | Detailed validation operations |
In production, you may want only `Basic` tracing. Use all sources during development and troubleshooting.
### Key Metrics (v7.0+)
The meter name is `Duende.IdentityServer` (accessible via `Telemetry.ServiceName`).
| Metric | Counter Name | Description |
| --------------- | --------------------------------------- | ------------------------------------------------ |
| Operations | `tokenservice.operation` | Aggregated success/failure/internal_error counts |
| Active Requests | `active_requests` | Current requests being processed by endpoints |
| Token Issuance | `tokenservice.token_issued` | Successful/failed token issuance attempts |
| Client Auth | `tokenservice.client.secret_validation` | Client authentication success/failure |
| Introspection | `tokenservice.introspection` | Token introspection counts |
| Revocation | `tokenservice.revocation` | Token revocation counts |
### UI Metrics (From Quickstart)
| Metric | Counter Name | Tags |
| ----------- | ------------------------- | --------------------------------------- |
| User Login | `tokenservice.user_login` | client, idp, error |
| User Logout | `user_logout` | idp |
| Consent | `tokenservice.consent` | client, scope, consent (granted/denied) |
## Logging
IdentityServer uses ASP.NET Core's standard `ILogger`. Logs are written under the `Duende.IdentityServer` category.
### Log Levels
| Level | Usage |
| ------------- | --------------------------------------------------- |
| `Trace` | Sensitive data (tokens); never enable in production |
| `Debug` | Internal flow and decisions; short-term debugging |
| `Information` | General application flow; long-term value |
| `Warning` | Abnormal or unexpected events |
| `Error` | Failed validation, unhandled exceptions |
| `Critical` | Missing store implementations, invalid key material |
### Configuration
```json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Duende.IdentityServer": "Information"
}
}
}
```
In production, default to `Warning` to avoid excessive log volume.
### Filtering Exceptions
```csharp
builder.Services.AddIdentityServer(options =>
{
options.Logging.UnhandledExceptionLoggingFilter = (ctx, ex) =>
{
// Return false to suppress, true to log
if (ctx.RequestAborted.IsCancellationRequested && ex is OperationCanceledException)
return false; // Already the default
return true;
};
});
```
### OpenTelemetry Log Correlation
Logs written to `ILogger` in .NET 8+ can be exported to OpenTelemetry traces. Add `builder.Logging.AddOpenTelemetry()` to correlate logs with trace IDs.
## Events System
Events provide higher-level structured data about operations, suitable for APM integration.
### Enabling Events
```csharp
builder.Services.AddIdentityServer(options =>
{
options.Events.RaiseSuccessEvents = true;
options.Events.RaiseFailureEvents = true;
options.Events.RaiseErrorEvents = true;
options.Events.RaiseInformationEvents = true;
});
```
### Raising Events
```csharp
public async Task<IActionResult> Login(LoginInputModel model)
{
if (_users.ValidateCredentials(model.Username, model.Password))
{
var user = _users.FindByUsername(model.Username);
await _events.RaiseAsync(
new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username));
}
else
{
await _events.RaiseAsync(
new UserLoginFailureEvent(model.Username, "invalid credentials"));
}
}
```
### Custom Event Sink
```csharp
public class SeqEventSink : IEventSink
{
private readonly Logger _log;
public SeqEventSink()
{
_log = new LoggerConfiguration()
.WriteTo.Seq("http://localhost:5341")
.CreateLogger();
}
public Task PersistAsync(Event evt)
{
if (evt.EventType == EventTypes.Success ||
evt.EventType == EventTypes.Information)
{
_log.Information("{Name} ({Id}), Details: {@details}",
evt.Name, evt.Id, evt);
}
else
{
_log.Error("{Name} ({Id}), Details: {@details}",
evt.Name, evt.Id, evt);
}
return Task.CompletedTask;
}
}
```
Events work well with structured logging stores like ELK, Seq, or Splunk.
## Rate Limiting
Duende IdentityServer has **no built-in rate limiting**. Assess it for public-facing or multi-tenant deployments. Three combinable approaches:
### (a) Network Layer (first line of defense)
Reverse proxy / gateway (nginx, Azure Application Gateway, AWS API Gateway, Cloudflare). Partitions only by IP/path — coarse, but stops most volumetric abuse before it reaches the app.
### (b) ASP.NET Core Rate Limiting Middleware
Register **before** `app.UseIdentityServer()`:
```csharp
app.UseRateLimiter();
app.UseIdentityServer();
```
**Critical caveat:** IdentityServer matches its protocol endpoints (`/connect/authorize`, `/connect/token`, …) with its **own middleware, NOT ASP.NET Core endpoint routing**. You therefore **cannot attach a named per-endpoint policy** to protocol endpoints — only the **GLOBAL limiter** applies to them.
- 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).
- For the token endpoint, prefer returning a **JSON error + `Retry-After` header** rather than an HTML 429.
```csharp
builder.Services.AddRateLimiter(options =>
{
// Global limiter — the ONLY limiter that applies to protocol endpoints
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(context =>
{
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
// Partition on path to approximate per-endpoint limits
return RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: $"{ip}:{context.Request.Path}",
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4
});
});
});
```
### (c) Identity-Aware Custom Validator
Implement `ICustomTokenRequestValidator` — it runs **after** token request validation, so `ClientId`/user are known:
```csharp
public class RateLimitingTokenRequestValidator : ICustomTokenRequestValidator
{
// v8 added the CancellationToken parameter to this interface
public Task ValidateAsync(CustomTokenRequestValidationContext context, CancellationToken ct)
{
var clientId = context.Result.ValidatedRequest.ClientId;
if (IsOverLimit(clientId))
{
context.Result.IsError = true;
context.Result.Error = "rate_limited";
context.Result.ErrorDescription = "Too many requests";
}
return Task.CompletedTask;
}
}
// idsvrBuilder.AddCustomTokenRequestValidator<RateLimitingTokenRequestValidator>();
```
**Note:** It runs **after** client authentication, secret validation, and DB lookups — so pair it with a coarser layer (a or b) to shed load earlier.
## Production Readiness Checklist
| Item | Status | Notes |
| ----------------------------------------------------- | -------------------------------------- | --------------------------------------- |
| Data Protection keys persisted to durable storage | Required | `.PersistKeysTo...()` |
| Data Protection keys shared across instances | Required for multi-instance | Same storage for all instances |
| Explicit application name set | Required | `.SetApplicationName("My.IdentityServer")` |
| ForwardedHeaders configured (if behind proxy) | Required | Match your proxy's headers |
| Operational store configured with durable persistence | Required | EF Core or custom store |
| Token cleanup enabled | Recommended | `EnableTokenCleanup = true` |
| Configuration store cache enabled | Recommended | `AddConfigurationStoreCache()` |
| Distributed cache configured (if multi-instance) | Recommended | Redis, SQL, etc. |
| Health checks implemented | Recommended | Discovery + JWKS endpoints |
| OpenTelemetry configured | Recommended | Metrics + traces for monitoring |
| Events enabled | Recommended | For auditing and APM |
| Signing key store uses durable storage | Required for multi-instance | EF operational store or custom |
| Logging level set to Warning+ for production | Recommended | Avoid log bloat |
| `~/keys` directory excluded from source control | Required if using file-based key store | Prevent dev keys in production |
| HTTPS + ForwardedHeaders configured before IdentityServer | Required if behind proxy | Discovery must publish HTTPS issuer |
| Signing keys shared by all instances + rotation plan | Required for multi-instance | Automatic Key Management where available |
| DB schema changes applied before new app version starts | Required | Plus operational-store cleanup enabled |
| Same operational data / signing keys / DP keys / caches per instance | Required for multi-instance | Every instance shares all shared state |
| CORS allows only required client origins | Required | Watch middleware order |
| Token + session lifetimes match threat model | Recommended | Tune per deployment |
| Rate limiting assessed | Recommended | Public / multi-tenant deployments |
## Common Anti-Patterns
- ❌ Deploying without configuring ForwardedHeaders behind a reverse proxy
- ✅ Always configure ForwardedHeaders when behind a proxy; test by checking the discovery document's issuer URL
- ❌ Using default (ephemeral) Data Protection keys in production
- ✅ Always persist keys to durable, shared storage with `.PersistKeysTo...()`
- ❌ Not setting `SetApplicationName()` causing key isolation between deployments
- ✅ Always set an explicit, consistent application name
- ❌ Using file-system signing key store in containerized/multi-instance deployments
- ✅ Use EF operational store or a shared `ISigningKeyStore` implementation
- ❌ Enabling `Trace` or `Debug` logging in production — exposes tokens and sensitive data
- ✅ Use `Warning` level in production; use `Information` temporarily for troubleshooting
- ❌ Not enabling token cleanup — database grows indefinitely
- ✅ Enable `EnableTokenCleanup = true` and configure appropriate intervals
## Common Pitfalls
1. **Discovery document shows HTTP issuer**: The most common deployment issue. Always configure ForwardedHeaders or the `ASPNETCORE_FORWARDEDHEADERS_ENABLED` environment variable when behind a TLS-terminating proxy.
2. **CryptographicException on startup**: Usually means Data Protection keys from one environment are being used in another. Check that keys are persisted correctly and the application name is consistent.
3. **Signing keys not shared across instances**: The default file-system key store is per-instance. Use `AddOperationalStore()` which includes `ISigningKeyStore`, or configure a custom shared store.
4. **Redis losing Data Protection keys on restart**: If using `PersistKeysToStackExchangeRedis`, configure Redis with persistence (RDB snapshots or AOF) to survive restarts.
5. **IIS Data Protection permissions**: IIS may lack permissions to persist Data Protection keys. Follow Microsoft's IIS-specific Data Protection documentation.
6. **Multiple proxies in chain**: If you have more than one proxy, set `ForwardLimit` to match the number of proxies, and add all proxy addresses to `KnownProxies` or `KnownNetworks`.
7. **Cookie SameSite failures behind proxy**: If the proxy strips HTTPS, cookies won't get the `Secure` attribute, causing `SameSite=None` cookies to be rejected by browsers. Fix the proxy configuration first.
8. **OpenTelemetry trace source selection**: In production, subscribing to all trace sources (`Stores`, `Validation`, etc.) can generate excessive trace data. Start with `Basic` and add more sources as needed for troubleshooting.
9. **v8 license key format / runtime enforcement**: The v8 license key is a signed JWT with a `kid` header. A v7-format key still runs v8 core, but a v8 key fails on v7/earlier or the BFF runtime with `IDX10503: ... Token does not have a kid.` v8 also **throws at startup** when a configured license lacks the entitlement for Server-Side Sessions, Automatic Key Management, or SAML — run lower environments with the production key so gaps surface before production.
---
## Related Skills
- `identityserver-hosting-setup` — DI registration and middleware pipeline
- `identityserver-data-storage` — EF Core stores, migrations, token cleanup
- `identityserver-aspire` — orchestrating IdentityServer in Aspire AppHost
identityserver-hosting-setup14.7 KB
---
name: identityserver-hosting-setup
description: Setting up and hosting Duende IdentityServer in ASP.NET Core applications, including DI registration, middleware pipeline, hosting patterns, essential options, license configuration, and ASP.NET Identity integration.
invocable: false
---
# Setting Up and Hosting IdentityServer
## When to Use This Skill
- Setting up a new Duende IdentityServer project from scratch
- Configuring the ASP.NET Core DI system and middleware pipeline for IdentityServer
- Deciding between separate vs shared hosting patterns
- Integrating IdentityServer with ASP.NET Identity for user management
- Configuring `IdentityServerOptions` (issuer, key management, endpoints)
- Setting up proxy/load balancer forwarded headers
- Configuring data protection for production deployments
- Understanding the IdentityServer middleware pipeline ordering
Docs: https://docs.duendesoftware.com/identityserver/fundamentals
## Core Concepts
Duende IdentityServer is middleware that adds OpenID Connect and OAuth 2.0 endpoints to an ASP.NET Core host. It requires two setup steps: registering services in DI and adding middleware to the request pipeline.
### Architecture Decision: Separate vs Shared Host
IdentityServer should be in its own dedicated application to minimize the attack surface. While it is technically possible to co-host IdentityServer with clients or APIs, this is not recommended.
| Hosting Pattern | Pros | Cons |
| ------------------------------- | -------------------------------------------------------------------- | ------------------------------------------- |
| **Separate host (recommended)** | Minimal attack surface, independent scaling, clear security boundary | Additional deployment artifact |
| **Shared with web app** | Fewer projects | Larger attack surface, coupled deployments |
| **Shared with API** | Fewer projects | Security risk, conflicting middleware needs |
## Step 1: Install Templates and Create a Project
```bash
dotnet new install Duende.Templates
dotnet new duende-is-empty -n IdentityServer
```
The `duende-is-empty` template creates a minimal project with the IdentityServer NuGet package installed and basic configuration.
## Step 2: Register IdentityServer Services (DI)
Call `AddIdentityServer` on the service collection to register all necessary services. This method also calls `AddAuthentication` internally.
```csharp
// Program.cs
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
// Configure IdentityServerOptions here
});
```
### Adding Configuration Stores
The builder object returned by `AddIdentityServer` provides extension methods to add configuration stores for clients, resources, and scopes:
```csharp
// Program.cs
var idsvrBuilder = builder.Services.AddIdentityServer()
.AddInMemoryClients(Config.Clients)
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes);
```
**Store options:**
- **In-memory stores** - good for development, demos, and static configuration
- **EntityFramework stores** - production-ready, supports dynamic configuration
- **Custom stores** - implement the store interfaces for any backing store
### Minimal Working Example
```csharp
// Program.cs
builder.Services.AddIdentityServer()
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients);
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();
app.MapDefaultControllerRoute();
app.Run();
```
## Step 3: Configure the Request Pipeline
Add `UseIdentityServer` middleware to the pipeline. Pipeline ordering is critical.
```csharp
// Program.cs
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();
app.MapDefaultControllerRoute();
```
### Pipeline Ordering Rules
| Order | Middleware | Notes |
| ----- | ----------------------------- | -------------------------------------------------- |
| 1 | `UseStaticFiles()` | Before IdentityServer |
| 2 | `UseRouting()` | Before IdentityServer |
| 3 | `UseIdentityServer()` | Includes `UseAuthentication()` internally |
| 4 | `UseAuthorization()` | Required after IdentityServer, must not be omitted |
| 5 | `MapDefaultControllerRoute()` | UI framework endpoints |
### Common Pipeline Anti-Patterns
```csharp
// ❌ WRONG: UseAuthentication is redundant (UseIdentityServer includes it)
app.UseAuthentication();
app.UseIdentityServer();
// ✅ CORRECT: UseIdentityServer already calls UseAuthentication
app.UseIdentityServer();
app.UseAuthorization();
```
```csharp
// ❌ WRONG: Missing UseAuthorization - required for the Duende UI template
app.UseIdentityServer();
app.MapDefaultControllerRoute();
// ✅ CORRECT: Always include UseAuthorization after UseIdentityServer
app.UseIdentityServer();
app.UseAuthorization();
app.MapDefaultControllerRoute();
```
```csharp
// ❌ WRONG: IdentityServer before routing
app.UseIdentityServer();
app.UseRouting();
// ✅ CORRECT: Routing before IdentityServer
app.UseRouting();
app.UseIdentityServer();
```
## Step 4: Configure Essential IdentityServerOptions
```csharp
// Program.cs
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
// IssuerUri: Not recommended to set; inferred from request URL by default.
// Set only when IdentityServer is accessed on a different address than the
// expected issuer (e.g., internal Kubernetes address).
// options.IssuerUri = "https://identity.example.com";
// Emit scopes as space-delimited string per RFC 9068
options.EmitScopesAsSpaceDelimitedStringInJwt = false; // default, array format
// Emit static audience claim in format {issuer}/resources
options.EmitStaticAudienceClaim = false; // default
// Emit iss response parameter on authorize responses (RFC 9207)
options.EmitIssuerIdentificationResponseParameter = true; // default
});
```
### Key Configuration Properties
| Property | Default | Purpose |
| ------------------------------------------- | ----------------- | ------------------------------------------------- |
| `IssuerUri` | inferred from URL | Token issuer name in discovery and tokens |
| `LowerCaseIssuerUri` | `true` | Lowercase inferred issuer URIs |
| `AccessTokenJwtType` | `"at+jwt"` | `typ` header in JWT access tokens (RFC 9068) |
| `EmitScopesAsSpaceDelimitedStringInJwt` | `false` | Scope claim format in JWTs |
| `EmitStaticAudienceClaim` | `false` | Static `aud` claim in `{issuer}/resources` format |
| `EmitIssuerIdentificationResponseParameter` | `true` | `iss` param on authorize responses (RFC 9207) |
## Step 5: Configure the License Key
Duende IdentityServer requires a valid license for production use. Without a license key, IdentityServer runs in trial/community mode and will log a warning on startup.
Set the license key via `options.LicenseKey` or via configuration:
```csharp
// Option 1: Inline in AddIdentityServer (not recommended for production — keep out of source control)
builder.Services.AddIdentityServer(options =>
{
options.LicenseKey = "YOUR_LICENSE_KEY";
});
// Option 2: From configuration (recommended)
builder.Services.AddIdentityServer(options =>
{
options.LicenseKey = builder.Configuration["IdentityServer:LicenseKey"];
});
```
Store the key in a secret manager, environment variable, or key vault — never in source-controlled `appsettings.json`.
## Step 6: ASP.NET Identity Integration
To use ASP.NET Identity as the user store for IdentityServer, install the integration package and configure both systems:
```bash
dotnet add package Duende.IdentityServer.AspNetIdentity
```
```csharp
// Program.cs
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddIdentityServer()
.AddAspNetIdentity<ApplicationUser>();
```
### What AddAspNetIdentity Configures
`AddAspNetIdentity<TUser>` registers the following IdentityServer implementations:
- **`IProfileService`** - uses `IUserClaimsPrincipalFactory` to add claims to tokens
- **`IResourceOwnerPasswordValidator`** - supports the password grant type
- **`IUserClaimsPrincipalFactory`** - a wrapper implementation that calls through to the previously registered factory and adds extra IdentityServer-specific claims
### Custom IUserClaimsPrincipalFactory
If you register a custom `IUserClaimsPrincipalFactory` before calling `AddAspNetIdentity`, the IdentityServer registration will resolve your factory and call through to it, layering additional claims on top:
```csharp
// Program.cs
// Register custom factory BEFORE AddAspNetIdentity
builder.Services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>, CustomClaimsPrincipalFactory>();
builder.Services.AddIdentityServer()
.AddAspNetIdentity<ApplicationUser>();
```
### Inactive User Handling
ASP.NET Identity has no built-in concept of inactive users. The default `IsActiveAsync` implementation returns `true`. To support enable/disable functionality:
```csharp
public class CustomProfileService : ProfileService<ApplicationUser>
{
public CustomProfileService(
UserManager<ApplicationUser> userManager,
IUserClaimsPrincipalFactory<ApplicationUser> claimsFactory)
: base(userManager, claimsFactory)
{ }
protected override Task<bool> IsUserActiveAsync(ApplicationUser user)
{
return Task.FromResult(user.IsEnabled); // your custom property
}
}
```
### Template Alternative
Use the `duende-is-aspid` template for a pre-configured ASP.NET Identity integration:
```bash
dotnet new duende-is-aspid -n IdentityServer
```
## Production Deployment: Proxy and Load Balancer Configuration
When behind a reverse proxy or load balancer, the proxy obscures request scheme and IP address. This causes common symptoms:
- HTTPS downgraded to HTTP in discovery document
- Incorrect host names in discovery or redirects
- Cookies missing the `secure` attribute
### Solution: Forwarded Headers Middleware
**Option 1: Environment variable (simple)**
Set `ASPNETCORE_FORWARDEDHEADERS_ENABLED=true` for cloud/Kubernetes environments.
**Option 2: Explicit configuration (production)**
```csharp
// Program.cs
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedHost |
ForwardedHeaders.XForwardedProto;
options.KnownProxies.Add(IPAddress.Parse("203.0.113.42"));
options.ForwardLimit = 1;
});
```
Add `UseForwardedHeaders()` early in the pipeline, before `UseIdentityServer()`.
## Production Deployment: Data Protection
Data protection is critical for IdentityServer. It protects signing keys at rest, persisted grants, server-side sessions, and authentication cookies. See [ASP.NET Core Data Protection](https://docs.duendesoftware.com/general/data-protection/) for comprehensive guidance covering all Duende SDKs.
```csharp
// Program.cs
builder.Services.AddDataProtection()
.PersistKeysToFoo() // Choose persistence (FileSystem, DbContext, Azure, Redis, etc.)
.ProtectKeysWithBar() // Choose key protection (Certificate, Azure Key Vault, etc.)
.SetApplicationName("My.IdentityServer"); // Prevent key isolation issues
```
### Data Protection Checklist
| Requirement | Why |
| ----------------------------------------- | ------------------------------------------------------------ |
| Persist keys to durable storage | Keys are lost on restart without persistence |
| Share keys across load-balanced instances | Each instance must read data protected by other instances |
| Set explicit application name | Prevents key isolation across deployments |
| Ensure storage durability | Redis without persistence or ephemeral filesystems lose keys |
### Data Protection Keys vs Signing Keys
These are completely separate:
| | Data Protection Keys | IdentityServer Signing Keys |
| ---------------- | --------------------------------------------- | ------------------------------------ |
| **Purpose** | Encrypt/sign sensitive data (cookies, grants) | Sign tokens (JWT, id_token) |
| **Cryptography** | Symmetric (private key) | Asymmetric (public/private key pair) |
| **Framework** | ASP.NET Core Data Protection | IdentityServer Key Management |
| **Public** | No | Public keys published in discovery |
## Common Pitfalls
1. **Missing `UseAuthorization()`** - The Duende UI template requires authorization middleware. Omitting it causes authorization failures in the UI pages.
2. **Redundant `UseAuthentication()`** - `UseIdentityServer()` already includes `UseAuthentication()`. Adding both is unnecessary but not harmful.
3. **Data protection not configured for production** - The default file-based key storage does not survive container restarts or work across load-balanced instances. Always configure persistent, shared key storage.
4. **Issuer mismatch** - If `IssuerUri` is set manually, clients must know this exact value. Prefer letting IdentityServer infer the issuer from request URLs.
5. **Keys directory in source control** - The `~/keys` directory created by automatic key management contains cryptographic secrets and must be excluded from source control via `.gitignore`.
6. **Shared hosting with APIs/clients** - Co-hosting IdentityServer with other applications increases the attack surface. Use a dedicated host.
7. **Not calling `AddAspNetIdentity` after `AddIdentity`** - When using ASP.NET Identity, you must call both. `AddIdentity` configures ASP.NET Identity; `AddAspNetIdentity` bridges it to IdentityServer.
---
## Related Skills
- `identityserver-configuration` — client definitions, resources, scopes
- `identityserver-deployment` — production deployment, data protection, health checks
- `identityserver-aspire` — orchestrating IdentityServer in Aspire AppHost
identityserver-key-management18.7 KB
---
name: identityserver-key-management
description: Managing cryptographic signing keys in Duende IdentityServer, including automatic key management, KeyManagementOptions, data protection at rest, static key configuration, migration from static to automatic, and multi-instance deployment considerations.
invocable: false
---
# Key Management and Signing
## When to Use This Skill
- Configuring automatic key management for signing token keys
- Setting up static/manual signing keys from certificates or key vaults
- Configuring key rotation intervals and key lifecycle
- Migrating from static keys to automatic key management
- Deploying IdentityServer in load-balanced or multi-instance environments
- Protecting keys at rest using data protection
- Configuring per-algorithm or per-resource signing
- Troubleshooting key-related errors (CryptographicException, unprotecting key failures)
Docs: https://docs.duendesoftware.com/identityserver/fundamentals/key-management/
## Core Concepts
IdentityServer issues cryptographically signed tokens: identity tokens, JWT access tokens, and logout tokens. These signatures require key material that can be managed automatically or manually (statically).
### Supported Signing Algorithms
IdentityServer supports the `RS`, `PS`, and `ES` families:
| Family | Algorithms | Key Type |
| ------ | ------------------------- | -------- |
| RS | `RS256`, `RS384`, `RS512` | RSA |
| PS | `PS256`, `PS384`, `PS512` | RSA |
| ES | `ES256`, `ES384`, `ES512` | ECDSA |
### Core Rotation Rule
Regardless of approach, safe rotation obeys one rule: **publish a new public key in discovery (JWKS) BEFORE using it to sign tokens, and keep a RETIRED public key published until every token signed with it has expired.** Automatic Key Management enforces this overlap for you (Announced → Signing → Retired phases). With static/manual keys you must perform the overlap yourself via phased rotation (see [Manual Key Rotation](#manual-key-rotation-phased-approach)).
## Automatic Key Management (Recommended)
Automatic Key Management handles key creation, rotation, announcement, and retirement. It is enabled by default and is part of the Business and Enterprise editions.
### Key Lifecycle
Keys move through four phases:
```
Announced --> Signing --> Retired --> Deleted
| | | |
|<--Propagation-->| | |
| |<--Rotation-->| |
| | |<--Retention-->|
```
| Phase | Duration (default) | Purpose |
| ------------- | -------------------------------------------- | --------------------------------------------- |
| **Announced** | 14 days (`PropagationTime`) | Published in discovery, not yet signing |
| **Signing** | 76 days (RotationInterval - PropagationTime) | Active signing credential |
| **Retired** | 14 days (`RetentionDuration`) | In discovery for token validation only |
| **Deleted** | After retention | Removed from discovery and optionally deleted |
**Default schedule:** Keys rotate every 90 days, announced 14 days early, retained 14 days after rotation.
### Configuration
```csharp
// Program.cs
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
// Key rotates every 30 days
options.KeyManagement.RotationInterval = TimeSpan.FromDays(30);
// Announce new key 2 days in advance in discovery
options.KeyManagement.PropagationTime = TimeSpan.FromDays(2);
// Keep old key for 7 days in discovery for validation
options.KeyManagement.RetentionDuration = TimeSpan.FromDays(7);
// Don't delete keys after their retention period is over
options.KeyManagement.DeleteRetiredKeys = false;
});
```
### KeyManagement Options Reference
| Property | Default | Description |
| ------------------------------------ | --------- | --------------------------------------------------------- |
| `Enabled` | `true` | Enable automatic key management |
| `SigningAlgorithms` | `[RS256]` | Algorithms for which keys are managed |
| `RsaKeySize` | `2048` | RSA key size in bits |
| `RotationInterval` | 90 days | Age at which keys stop signing |
| `PropagationTime` | 14 days | Time for new keys to propagate to all servers and clients |
| `RetentionDuration` | 14 days | Duration retired keys remain in discovery |
| `DeleteRetiredKeys` | `true` | Delete keys after retention period |
| `KeyPath` | `{ContentRootPath}/keys` | File system path for default key store |
| `DataProtectKeys` | `true` | Encrypt keys at rest using data protection |
| `KeyCacheDuration` | 24 hours | Cache duration for keys from store |
| `InitializationDuration` | 5 minutes | Synchronization window on first key creation |
| `InitializationSynchronizationDelay` | 5 seconds | Delay between retries during initialization |
### Multiple Signing Algorithms
Configure multiple algorithms to serve clients with different requirements. The first algorithm in the list is the default for signing tokens.
```csharp
options.KeyManagement.SigningAlgorithms = new[]
{
// RS256 for older clients (with X.509 wrapping)
new SigningAlgorithmOptions(SecurityAlgorithms.RsaSha256) { UseX509Certificate = true },
// PS256
new SigningAlgorithmOptions(SecurityAlgorithms.RsaSsaPssSha256),
// ES256
new SigningAlgorithmOptions(SecurityAlgorithms.EcdsaSha256)
};
```
Override the default on a per-client or per-resource basis:
```csharp
// Client level
var client = new Client
{
AllowedIdentityTokenSigningAlgorithms = { SecurityAlgorithms.RsaSsaPssSha256 }
};
// API Resource level
var api = new ApiResource("invoice")
{
AllowedAccessTokenSigningAlgorithms = { SecurityAlgorithms.RsaSsaPssSha256 }
};
```
## Key Storage
### Default: File System
The default `FileSystemKeyStore` writes keys to the `KeyPath` directory (defaults to `{ContentRootPath}/keys`). This directory must be:
- Excluded from source control
- Accessible (read/write) to all load-balanced instances if using file-based storage
```csharp
// Program.cs
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.KeyPath = "/home/shared/keys";
});
```
### EntityFramework Store
Use the EF operational store for database-backed key storage:
```csharp
// Program.cs
builder.Services.AddIdentityServer()
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString);
});
```
### Custom Store
Implement `ISigningKeyStore` for custom storage (e.g., Azure Key Vault, AWS KMS):
```csharp
// Program.cs
builder.Services.AddIdentityServer()
.AddSigningKeyStore<YourCustomStore>();
```
The store interface methods:
- `LoadKeysAsync` - load all keys (cached for `KeyCacheDuration`)
- `StoreKeyAsync` - persist a new key
- `DeleteKeyAsync` - remove a retired key
## Encryption of Keys at Rest
By default, keys are protected at rest using ASP.NET Core Data Protection (`DataProtectKeys = true`). Keep this enabled unless your custom `ISigningKeyStore` already ensures encryption (e.g., Azure Key Vault).
```csharp
// ❌ WRONG: Disabling without alternative encryption
options.KeyManagement.DataProtectKeys = false;
// ✅ CORRECT: Only disable when using a vault that encrypts at rest
options.KeyManagement.DataProtectKeys = false; // OK if using Azure Key Vault via custom ISigningKeyStore
```
### Data Protection Configuration for Production
Data protection must be properly configured for key encryption to work across instances. See [ASP.NET Core Data Protection](https://docs.duendesoftware.com/general/data-protection/) for foundational concepts and troubleshooting.
```csharp
// Program.cs
builder.Services.AddDataProtection()
.PersistKeysToDbContext<MyDbContext>() // or PersistKeysToAzureBlobStorage, etc.
.ProtectKeysWithCertificate(certificate) // or ProtectKeysWithAzureKeyVault
.SetApplicationName("My.IdentityServer");
```
### Common Data Protection Problems
| Symptom | Cause | Fix |
| -------------------------------------------------------------------- | ------------------------------------------------- | ---------------------------------------- |
| `CryptographicException: The key {ID} was not found in the key ring` | Data protection keys not shared across instances | Configure shared key persistence |
| `Error unprotecting key with kid {ID}` | Keys protected by a different data protection key | Ensure consistent data protection config |
| Keys work locally but fail in deployment | Default file-based storage uses ephemeral storage | Use durable, shared storage |
| Keys break after redeployment | Application name changed or not set | Set explicit `SetApplicationName()` |
## Static Key Management
For scenarios where you want explicit control over signing keys or your license does not include automatic key management.
### Disabling Automatic Key Management
```csharp
// Program.cs
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.Enabled = false;
});
```
### Adding Static Signing Keys
```csharp
// Program.cs
var idsvrBuilder = builder.Services.AddIdentityServer();
var key = LoadKeyFromVault(); // your code to load the key
idsvrBuilder.AddSigningCredential(key, SecurityAlgorithms.RsaSha256);
```
Multiple signing keys can be registered. The first one added is the default.
### Adding Validation Keys
Register public keys that should be accepted for token validation (used during key rotation):
```csharp
idsvrBuilder.AddValidationKey(oldKey, SecurityAlgorithms.RsaSha256);
```
### Creating Self-Signed Certificates
```csharp
var name = "MySelfSignedCertificate";
using var rsa = RSA.Create(keySizeInBits: 2048);
var request = new CertificateRequest(
subjectName: $"CN={name}",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1
);
var certificate = request.CreateSelfSigned(
DateTimeOffset.Now,
DateTimeOffset.Now.AddYears(1)
);
var pfxBytes = certificate.Export(X509ContentType.Pfx, password: "password");
File.WriteAllBytes($"{name}.pfx", pfxBytes);
```
### Loading Keys from Disk or Certificate Store
```csharp
// From PFX file
var bytes = File.ReadAllBytes("mycertificate.pfx");
var certificate = X509CertificateLoader.LoadPkcs12(bytes, "password");
// From certificate store
var store = new X509Store(StoreName.My, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadWrite);
var certificate = store.Certificates.First(c => c.Thumbprint == "<thumbprint>");
```
## Manual Key Rotation (Phased Approach)
When using static keys, rotation must be performed carefully to avoid validation failures.
### Why Phased Rotation is Necessary
1. **Client/API caching** - Clients and APIs cache keys (default: 24 hours). Using a new key immediately means cached clients cannot validate tokens signed with it.
2. **Existing tokens** - Tokens signed with the old key are still valid. Removing the old key immediately invalidates those tokens.
### Phase 1: Announce the New Key
Sign with the old key, publish the new key as a validation key:
```csharp
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.Enabled = false;
});
var oldKey = LoadOldKeyFromVault();
var newKey = LoadNewKeyFromVault();
idsvrBuilder.AddSigningCredential(oldKey, SecurityAlgorithms.RsaSha256);
idsvrBuilder.AddValidationKey(newKey, SecurityAlgorithms.RsaSha256);
```
**Wait:** Until all clients/APIs have refreshed their caches (default 24 hours).
### Phase 2: Start Signing with the New Key
Swap signing and validation keys:
```csharp
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.Enabled = false;
});
var oldKey = LoadOldKeyFromVault();
var newKey = LoadNewKeyFromVault();
idsvrBuilder.AddSigningCredential(newKey, SecurityAlgorithms.RsaSha256);
idsvrBuilder.AddValidationKey(oldKey, SecurityAlgorithms.RsaSha256);
```
**Wait:** Until all tokens signed with the old key have expired (default access token lifetime: 1 hour).
### Phase 3: Remove the Old Key
```csharp
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.Enabled = false;
});
var newKey = LoadNewKeyFromVault();
idsvrBuilder.AddSigningCredential(newKey, SecurityAlgorithms.RsaSha256);
```
## Migrating from Static to Automatic Key Management
This is also a three-phase process where automatic keys gradually replace static keys.
### Phase 1: Enable Automatic Key Management, Keep Signing with Static Key
The static signing credential takes precedence over automatic keys. Automatic key management begins creating and announcing keys in discovery.
```csharp
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.Enabled = true;
});
var oldKey = LoadOldKeyFromVault();
idsvrBuilder.AddSigningCredential(oldKey, SecurityAlgorithms.RsaSha256);
```
**Wait:** Until all APIs and clients have updated their caches with the new automatic keys.
### Phase 2: Switch to Automatic Signing, Keep Static for Validation
Remove the static signing credential; keep it as a validation key:
```csharp
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.Enabled = true;
});
var oldKey = LoadOldKeyFromVault();
idsvrBuilder.AddValidationKey(oldKey, SecurityAlgorithms.RsaSha256);
```
**Wait:** Until all tokens signed with the old static key have expired.
### Phase 3: Remove Static Key Entirely
```csharp
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.KeyManagement.Enabled = true;
});
```
## Multi-Instance / Load-Balanced Deployment
### Requirements
| Concern | Solution |
| ----------------------------------- | --------------------------------------------------- |
| Key storage shared across instances | Use EF operational store or shared file system |
| Data protection keys shared | Configure shared data protection key persistence |
| Key cache synchronization | `PropagationTime` handles cache refresh windows |
| Initialization race condition | `InitializationDuration` (5 min) allows server sync |
### File System Store in Load-Balanced Environments
All instances need read/write access to the `KeyPath`:
```csharp
options.KeyManagement.KeyPath = "/shared-volume/identity-keys";
```
### Recommended: Database-Backed Store
```csharp
builder.Services.AddIdentityServer()
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b => b.UseSqlServer(connectionString);
});
```
## OIDC + SAML Shared Signing Keys
When the SAML component is enabled, IdentityServer uses the **same signing credentials** for OIDC tokens and SAML messages — one key store, one rotation schedule. Rotated public keys are published in parallel via the OIDC JWKS endpoint and the SAML IdP metadata during rollover.
### X.509 Requirement (SAML metadata needs certificates)
SAML metadata requires X.509 certificates, not raw keys:
- **Automatic Key Management** — creates RSA keys by default, and the SAML component **auto-wraps** managed RSA keys in self-signed X.509 certificates. You do **not** need to set `UseX509Certificate` just to enable SAML.
- **Static Key Management** — you **must** configure an X.509 signing certificate **with a private key**. A manually registered raw RSA key — including one from `AddDeveloperSigningCredential()` — **cannot** be auto-wrapped for SAML.
### Constraints
- The default SAML signing service supports **RSA only**. `UseX509Certificate` is **not** supported for EC (`ES`) keys.
- For a different certificate, independent rotation, or an external key system, implement a custom `ISamlSigningService`.
### Rotation Knobs (shared with OIDC)
| Knob | Effect for SAML |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `PropagationTime` | How long a new managed key is published before it starts signing — set long enough for all SPs to refresh IdP metadata. |
| `RetentionDuration` | Keeps the previous certificate in metadata while SPs may still validate old messages (and old OIDC tokens remain valid). |
Service providers with **statically configured** IdP certificates must update those certs on every rotation.
## Common Pitfalls
1. **`keys` directory in source control** - Contains cryptographic secrets. Add the `keys` directory (under the app content root) to `.gitignore`. If accidentally committed, the keys may be data-protected with development-only data protection keys and fail in production.
2. **Data protection not configured for production** - Default data protection uses machine-specific keys. In containers or multi-instance deployments, keys protected by one instance cannot be read by another. Always configure shared, persistent data protection.
3. **Immediate key rotation** - Switching signing keys without a transition period causes validation failures. Use the phased approach or rely on automatic key management.
4. **Disabling `DataProtectKeys` without alternative** - Turning off key encryption without ensuring your store encrypts at rest exposes signing keys to anyone with storage access.
5. **X.509 certificate expiration confusion** - IdentityServer does not validate X.509 certificate expiration dates. Expired certificates still work for signing. The expiration date is a policy decision, not a technical enforcement.
6. **Not setting `PropagationTime` long enough** - If clients/APIs cache keys longer than your propagation time, new keys may not be in their caches when signing starts. Ensure `PropagationTime` exceeds your longest cache duration.
7. **Mixing up Data Protection keys and signing keys** - These are completely separate. Data Protection uses symmetric encryption for sensitive data at rest. Signing keys use asymmetric cryptography for token signatures. Both must be properly configured.
identityserver-saml22.4 KB
---
name: identityserver-saml
description: "Configuring Duende IdentityServer as a SAML 2.0 Identity Provider (IdP): service provider registration, SSO and SLO flows, claim mappings, extensibility interfaces, and production deployment patterns."
invocable: false
---
# SAML 2.0 Identity Provider
## When to Use This Skill
- Setting up IdentityServer as a SAML 2.0 Identity Provider (IdP)
- Registering SAML Service Providers with the `SamlServiceProvider` model
- Configuring SP-initiated SSO and Single Logout (SLO) flows
- Customizing claim-to-attribute mappings via `ClaimMappings` or extensibility interfaces
- Implementing production SP stores (EF Core, custom `ISamlServiceProviderStore`)
- Extending SAML behavior (custom NameID generation, signing, metadata, multi-tenant issuer)
- Linking an external SAML IdP as a federated authentication source (SP mode)
## Core Principles
- SAML 2.0 IdP support is **built into Duende.IdentityServer** (v8.0+) — no separate NuGet package
- Requires **Standard (add-on), Advanced, or Custom Edition** license
- SP-initiated SSO is the default; IdP-initiated SSO is opt-in per service provider
- `SignAssertion` is the default signing behavior; `SignResponse` is recommended for most deployments
- Use EF Core stores for service providers in production; in-memory is for development only
- Front-channel SLO uses iframes (not redirect chains); partial logout is expected behavior
- The claim pipeline flows: AllowedScopes → RequestedClaimTypes → ClaimMappings
Docs: https://docs.duendesoftware.com/identityserver/saml
## Setup
```csharp
builder.Services.AddIdentityServer()
.AddInMemoryClients(Config.Clients)
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddSaml()
.AddInMemorySamlServiceProviders(Config.SamlServiceProviders);
```
Update the login page to call `DenyAuthenticationAsync` for SAML cancellation support (when user cancels login during a SAML flow).
## Endpoints
| Endpoint | Path | Purpose |
|----------|------|---------|
| Metadata | `/Saml2` | IdP metadata (certificates, endpoints, NameID formats) |
| Sign-in | `/Saml2/SSO` | Receives AuthnRequest (GET/POST) |
| Sign-in Callback | `/Saml2/SSO/Callback` | Builds SAML Response after authentication |
| Logout | `/Saml2/SLO` | Handles LogoutRequest/LogoutResponse |
| Logout Callback | `/Saml2/SLO/Callback` | Completes SLO round-trip |
Paths are customizable via `SamlOptions.Endpoints`.
### Profile Active Check
`IProfileService.IsActiveAsync` is called on every SSO request, including when the user already has an active session.
If `IsActive` returns `false`: passive requests (`IsPassive=true`) receive a SAML `NoPassive` error response; all other requests are redirected to the login page.
This is the recommended mechanism for blocking disabled or locked accounts without waiting for session expiry.
### Observability
All SAML endpoints emit audit events and OpenTelemetry telemetry counters.
SSO and SLO endpoints participate in distributed tracing via the `Duende.IdentityServer` activity source.
See docs for SAML audit events and `TelemetryMetricsCounters.SamlSso`.
## SamlServiceProvider Model
```csharp
new SamlServiceProvider
{
// Required
EntityId = "https://sp.example.com",
DisplayName = "Example SP",
// ACS endpoints (HTTP-POST only, indexed)
AssertionConsumerServiceUrls =
[
new IndexedEndpoint
{
Location = "https://sp.example.com/acs",
Binding = SamlBinding.HttpPost,
Index = 0,
IsDefault = true
}
],
// Single Logout (HTTP-Redirect only)
SingleLogoutServiceUrls =
[
new SamlEndpointType
{
Location = "https://sp.example.com/saml/slo",
Binding = SamlBinding.HttpRedirect
}
],
// Security
SigningBehavior = SamlSigningBehavior.SignAssertion,
RequireSignedAuthnRequests = true,
Certificates =
[
new ServiceProviderCertificate
{
Certificate = spCert,
Use = KeyUse.Signing
}
],
// Claims (identity resources the SP can access)
AllowedScopes = ["openid", "profile", "email"],
RequestedClaimTypes = ["email", "name"], // optional narrowing
ClaimMappings = new Dictionary<string, string>
{
["email"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
["name"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
},
// NameID
DefaultNameIdFormat = SamlNameIdFormat.EmailAddress,
// IdP-Initiated SSO (opt-in)
AllowIdpInitiated = false,
// Lifecycle / display
Enabled = true, // false → reject all requests from this SP
Description = "Optional notes", // human-readable, not sent in SAML responses
// Per-SP overrides (null = fall back to SamlOptions global default)
AssertionLifetime = TimeSpan.FromMinutes(5), // overrides SamlOptions.DefaultAssertionLifetime
EmailNameIdClaimType = "email", // overrides SamlOptions.EmailNameIdClaimType
RequireSignedLogoutResponses = true, // overrides SamlOptions.RequireSignedLogoutResponses
AllowedSignatureAlgorithms = ["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"], // null → IdP default
AuthnContextMappings = new Dictionary<string, string> // overrides SamlOptions.DefaultAuthnContextMappings
{
["pwd"] = "urn:oasis:names:tc:SAML:2.0:ac:classes:Password",
["mfa"] = "urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract"
}
}
```
### Claim Pipeline
```
AllowedScopes (identity resources) → filters available claim types
↓
RequestedClaimTypes (optional narrowing) → selects specific claims
↓
ClaimMappings (OIDC claim name → SAML attribute URI) → output as <saml:Attribute>
```
Use `SamlOptions.DefaultClaimMappings` for global defaults; per-SP `ClaimMappings` override them.
## Configuration (SamlOptions)
```csharp
builder.Services.AddIdentityServer()
.AddSaml(saml =>
{
saml.EntityId = "https://idp.example.com/Saml2"; // default: {host}/Saml2
saml.EntityIdPath = "/Saml2"; // path appended to host URL to form default EntityId
saml.WantAuthnRequestsSigned = true; // default: true
saml.RequireSignedLogoutResponses = true; // default: true
saml.DefaultSigningBehavior = SamlSigningBehavior.SignAssertion;
saml.DefaultClockSkew = TimeSpan.FromMinutes(5);
saml.DefaultRequestMaxAge = TimeSpan.FromMinutes(5);
saml.DefaultAssertionLifetime = TimeSpan.FromMinutes(5);
saml.SupportedNameIdFormats = [SamlNameIdFormat.EmailAddress, SamlNameIdFormat.Unspecified];
saml.MaxRelayStateLength = 80; // SAML spec requirement
saml.MaxMessageSize = 1_048_576; // max chars of inbound SAML messages (default: 1 MB)
// Session/state lifetimes
saml.SigninStateLifetime = TimeSpan.FromMinutes(15); // how long sign-in request state is retained
saml.LogoutSessionLifetime = TimeSpan.FromMinutes(5); // how long SLO session tracking state is retained
// NameID claim type for email-format NameIDs (default: "email")
saml.EmailNameIdClaimType = "email";
// Global claim mappings
saml.DefaultClaimMappings = new Dictionary<string, string>
{
["name"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
["email"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
["role"] = "http://schemas.xmlsoap.org/ws/2005/05/identity/role"
};
// AuthnContext mappings (acr/amr → SAML AuthnContext URIs)
saml.DefaultAuthnContextMappings = new Dictionary<string, string>
{
["pwd"] = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
};
// Optional error inspector callbacks for debugging interoperability issues
// These can inspect or suppress parse errors on inbound SAML messages
saml.AuthnRequestErrorInspector = (context, exception) => { /* inspect/suppress */ };
saml.LogoutRequestErrorInspector = (context, exception) => { /* inspect/suppress */ };
saml.LogoutResponseErrorInspector = (context, exception) => { /* inspect/suppress */ };
});
```
### Metadata Options
```csharp
builder.Services.AddIdentityServer()
.AddSaml(saml =>
{
saml.Metadata.CacheDuration = TimeSpan.FromHours(12);
saml.Metadata.ExpiryDuration = TimeSpan.FromDays(5);
});
```
### Endpoint Options
| Property | Default | Description |
|----------|---------|-------------|
| `SingleSignOnServicePath` | `"/Saml2/SSO"` | Path for the SSO endpoint |
| `SingleSignOnServiceBindings` | `[HttpRedirect, HttpPost]` | Bindings advertised in metadata (not which the endpoint accepts) |
| `SingleSignOnCallbackPath` | `"/Saml2/SSO/Callback"` | Internal callback path after user authenticates |
| `SingleLogoutServicePath` | `"/Saml2/SLO"` | Path for the SLO endpoint |
| `SingleLogoutServiceBindings` | `[HttpRedirect, HttpPost]` | Bindings advertised in metadata for SLO |
| `SingleLogoutCallbackPath` | `"/Saml2/SLO/Callback"` | Internal callback path for SLO completion |
| `StateIdParameterName` | `"samlStateId"` | Query string param name for the SAML sign-in state ID |
```csharp
builder.Services.AddIdentityServer()
.AddSaml(saml =>
{
saml.Endpoints.SingleSignOnServicePath = "/Saml2/SSO";
saml.Endpoints.SingleLogoutServicePath = "/Saml2/SLO";
});
```
## SAML Signing Keys (X.509)
SAML signing **requires an X.509 certificate**. OIDC and SAML share the same signing credentials; rotation timing is governed by Automatic Key Management `PropagationTime` and `RetentionDuration`.
- **Automatic Key Management (RSA)**: auto-generated RSA keys are auto-wrapped into a self-signed X.509 container. You do **not** need `UseX509Certificate` just to enable SAML.
- **Manual / raw RSA keys — including `AddDeveloperSigningCredential()`**: **cannot** be auto-wrapped. Register an X.509 certificate **with a private key** instead.
- The default SAML signing service is **RSA-only**. `UseX509Certificate` is **not** supported for EC keys — implement a custom `ISamlSigningService` for EC (or HSM/Key Vault) scenarios.
## Service Provider Stores
### In-Memory (Development)
```csharp
.AddInMemorySamlServiceProviders(new[]
{
new SamlServiceProvider { EntityId = "...", /* ... */ }
});
```
### EF Core (Production — Recommended)
```csharp
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString);
})
```
Run EF migrations: `dotnet ef migrations add Update_DuendeIdentityServer_v8_0`
### Custom Store
```csharp
.AddSamlServiceProviderStore<MySamlSpStore>()
public class MySamlSpStore : ISamlServiceProviderStore
{
public Task<SamlServiceProvider?> FindByEntityIdAsync(
string entityId, CancellationToken ct)
{ /* lookup from your backend */ }
public IAsyncEnumerable<SamlServiceProvider> GetAllSamlServiceProvidersAsync(
CancellationToken ct)
{ /* stream all SPs */ }
}
```
> **Note — Operational store auto-registration**: `AddOperationalStore()` automatically registers EF Core implementations of **both** `ISamlSigninStateStore` **and** `ISamlLogoutSessionStore`. When using the EF operational store, these do not need to be registered separately.
### Caching & Validation
```csharp
// Add HybridCache layer to any custom store
.AddSamlServiceProviderStoreCache<MySamlSpStore>()
```
Cache duration is controlled by `IdentityServerOptions.Caching.SamlServiceProviderStoreExpiration` (default: 15 minutes):
```csharp
builder.Services
.AddIdentityServer(options =>
{
options.Caching.SamlServiceProviderStoreExpiration = TimeSpan.FromMinutes(30);
})
.AddSaml()
.AddSamlServiceProviderStoreCache<MySamlServiceProviderStore>();
```
All stores are automatically wrapped with `ValidatingSamlServiceProviderStore<T>` that checks: EntityId required, ≥1 ACS URL (HTTP-POST only), ≥1 AllowedScopes, positive lifetimes. Invalid SPs are treated as non-existent.
## Single Logout (SLO)
SLO uses **front-channel logout via iframes** (not redirect chains):
1. SP sends LogoutRequest to `/Saml2/SLO`
2. IdentityServer ends local session
3. Renders iframes sending LogoutRequests to all other active SPs
4. Collects LogoutResponses from SPs
5. Sends final LogoutResponse to originating SP
**Key points:**
- Partial logout is normal (some SPs may not respond)
- User must stay on logout page for iframes to complete
- Use `ISamlLogoutSessionStore` for distributed deployments (tracks which SPs have active sessions)
- Short session lifetimes serve as SLO fallback
## IdP-Initiated SSO
> ⚠️ **CSRF Warning**: IdP-initiated SSO is inherently vulnerable to CSRF. There is no SAML-compliant way to implement it without CSRF exposure. Only enable it after careful security review.
**Recommended alternative**: Mimic OIDC third-party initiated login — create a dedicated SP endpoint that accepts a target application hint and redirects the user to the IdP with a standard SP-initiated AuthnRequest. This avoids the CSRF risk entirely.
**Enabling per SP**: If IdP-initiated SSO is genuinely required, set `AllowIdpInitiated = true` on the `SamlServiceProvider`.
**No built-in endpoint**: There is no built-in IdP-initiated SSO endpoint. Implement your own Razor Page or controller and inject `IIdpInitiatedSsoService`:
```csharp
// Key method on IIdpInitiatedSsoService:
Task<IdpInitiatedSsoResult> CreateResponseAsync(
HttpContext httpContext, string spEntityId, string? relayState, CancellationToken ct);
```
Call `CreateResponseAsync` from your custom endpoint to generate and return the SAML Response to the SP. The SP must have `AllowIdpInitiated = true`; otherwise the call will fail.
## Extensibility
| Interface | Purpose |
|-----------|---------|
| `ISamlNameIdGenerator` | Custom NameID value derivation (e.g., from employee_id claim) |
| `ISamlSigningService` | HSM/Key Vault signing certificate integration |
| `ISaml2MetadataResponseGenerator` | Custom metadata extensions (org info, federation) |
| `ISaml2IssuerNameService` | Multi-tenant: dynamic entity ID per tenant |
| `ISaml2SsoInteractionResponseGenerator` | Custom step-up auth logic during SSO |
| `ISaml2SsoResponseGenerator` | Custom SAML Response generation |
| `ISamlLogoutNotificationService` | Selective SLO targeting; returns `SamlLogoutNotificationResult` (`Messages`: collection of `SamlLogoutRequestContext`, `SkippedCount`: int) |
| `ISaml2SloResponseGenerator` | Custom SLO `LogoutResponse` generation (success vs partial logout) |
| `ISamlLogoutSessionStore` | Distributed SLO state (Redis, EF Core); key method: `TryRecordResponseAsync(string requestId, string issuer, bool success, CancellationToken ct)`; `SamlLogoutSession` has `SkippedSpCount` (int), `ExpiresAtUtc` (DateTime), `ExpectedResponses` dictionary |
| `ISaml2FrontChannelLogoutRequestBuilder` | Custom logout request structure; `BuildLogoutRequestAsync` returns `SamlLogoutRequestContext` (wraps outbound message + `RequestId` + `SpEntityId` for response correlation) |
| `ISamlResourceResolver` | Dynamic scope filtering per SP |
| `IIdpInitiatedSsoService` | Portal "My Apps" dashboard for IdP-initiated flows |
| `IAuthnRequestValidator` | Custom SP access rules, IP/time-based controls |
| `ILogoutRequestValidator` | Custom SLO authorization rules |
| `ISamlSigninStateStore` | Distributed sign-in state (for multi-node deployments); methods include `UpdateSigninRequestStateAsync` |
| `ISamlServiceProviderConfigurationValidator` | Custom SP config validation rules |
> **DI ordering is NOT required**: Custom SAML services do **not** need to be registered before `AddSaml()`. Defaults are registered with `TryAdd*` (e.g. `TryAddScoped`), so a custom scoped registration takes precedence regardless of order.
> **State serializer & `Extensions`**: The default `ISamlSigninStateSerializer` **ignores** the `Extensions` property. To persist custom SAML extension data across the sign-in round-trip, implement a custom serializer.
### Example: Custom NameID Generator
```csharp
public class EmployeeNameIdGenerator : ISamlNameIdGenerator
{
public Task<NameIdGenerationResult> GenerateAsync(
NameIdGenerationContext context, CancellationToken ct)
{
var employeeId = context.Subject.FindFirst("employee_id")?.Value;
if (employeeId is null)
return Task.FromResult(NameIdGenerationResult.Failure(
StatusCodes.Responder, StatusCodes.UnknownPrincipal,
"Employee ID claim not found."));
return Task.FromResult(NameIdGenerationResult.Success(
new NameId(employeeId, context.ResolvedFormat)));
}
}
```
### SAML Authentication Context in Login UI
Inject `IIdentityServerInteractionService` and call `GetAuthenticationContextAsync(returnUrl)`; pattern-match the result to `SamlAuthenticationContext` for customizing login flows per SP.
`SamlAuthenticationContext` properties:
- `ServiceProvider` — the SP that initiated the request
- `IdP` (string?) — IdP entity ID from `Scoping`, null if multiple IdPs listed
- `LoginHint` (string?) — login hint from NameID in AuthnRequest
- `Tenant` (string?) — tenant identifier from RequestedAuthnContext
- `PromptModes` — derived from `ForceAuthn` and `IsPassive` flags
- `RelayState` (string?) — relay state from the AuthnRequest
- `IsIdpInitiated` (bool) — whether this is an IdP-initiated SSO flow
- `RequestedAuthnContext` — authentication context requirements from the SP
- `StateId` (Guid) — identifier for sign-in state entry; needed when calling `DenyAuthenticationAsync`
## Using IdentityServer as a SAML Service Provider (SP Mode)
IdentityServer can consume SAML assertions from external IdPs via federation. Add a SAML authentication handler and configure it as an external provider in IdentityServer's login UI — same pattern as any external authentication scheme.
### Native SAML SP handler (`AddSamlServiceProvider`)
Register the built-in Duende SAML SP handler as an external scheme feeding the IdentityServer external cookie:
```csharp
builder.Services.AddAuthentication()
.AddSamlServiceProvider("corporate-idp", options =>
{
options.SpEntityId = "https://sp.example.com";
options.IdpEntityId = "https://idp.example.com";
options.SingleSignOnServiceUrl = "https://idp.example.com/sso";
options.SigningCertificatesBase64 = ["<base64>"]; // LIST → supports IdP cert rollover
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
// IdP-initiated (unsolicited) SSO — opt-in
options.AllowUnsolicitedAuthnResponse = true; // default false
options.IdpInitiatedCallbackUrl = "/ExternalLogin/Callback"; // REQUIRED when above is true
});
```
| Property | Default | Notes |
|----------|---------|-------|
| `AllowUnsolicitedAuthnResponse` | `false` | Accept IdP-initiated (unsolicited) `AuthnResponse`. When `true`, you **must** set `IdpInitiatedCallbackUrl`. |
| `IdpInitiatedCallbackUrl` | `null` | **Required** when unsolicited responses are allowed. Relative path (e.g. `/ExternalLogin/Callback`) or absolute http/https URL; redirect target after processing an unsolicited response. |
| `MaxRelayStateLength` | `1024` | Max bytes of RelayState persisted in auth properties; oversized values are **silently dropped** to avoid cookie bloat. |
**Callback handling (IdP-initiated):** authenticate against `IdentityServerConstants.ExternalCookieAuthenticationScheme`. The IdP-supplied RelayState surfaces at `AuthenticationProperties.Items["relayState"]` (only when ≤ `MaxRelayStateLength`); `"scheme"` and `"returnUrl"` items are also populated.
> ⚠️ **Security**: treat RelayState as **untrusted input**. Always validate it before using it as a redirect target.
**Dynamic providers**: the dynamic `SamlProvider` model gains the same `AllowUnsolicitedAuthnResponse` and `IdpInitiatedCallbackUrl` properties. Note the dynamic model uses `SigningCertificateBase64` (**singular** string), whereas the static handler uses `SigningCertificatesBase64` (**list**, supports rollover).
### Third-party handlers
Alternatively, use a third-party handler (e.g., `Sustainsys.Saml2` or `ITfoxtec.Identity.Saml2`) configured as an external scheme.
For step-by-step setup instructions, see the official docs: https://docs.duendesoftware.com/identityserver/ui/login/saml-provider/
> **Managing many SAML IdPs?** For scenarios with a large or changing set of external SAML identity providers, consider using **dynamic providers** instead of static registration. Dynamic providers allow you to manage IdP configurations at runtime without redeployment. See: https://docs.duendesoftware.com/identityserver/ui/login/dynamicproviders/#saml-providers
## Common Anti-Patterns
❌ Enabling `AllowIdpInitiated` on all SPs — only enable where explicitly required (less secure)
❌ Using `DoNotSign` outside of local testing
❌ Using in-memory SP stores in production
❌ Omitting `AllowedScopes` — SP gets no claims in the assertion
❌ Configuring ACS URLs with HTTP-Redirect binding (only HTTP-POST is supported)
## Common Pitfalls
1. **Edition requirement**: `AddSaml()` requires Standard (add-on), Advanced, or Custom Edition license.
2. **ACS binding**: Only HTTP-POST is supported for AssertionConsumerServiceUrls. HTTP-Redirect will fail validation.
3. **Clock skew**: Default 5 minutes. Increase if SPs report "response not yet valid" errors.
4. **Partial SLO**: Front-channel logout via iframes means some SPs may not respond. This is expected — don't treat it as an error.
5. **DenyAuthenticationAsync**: Login page must call this for SAML cancellation. Without it, users get stuck if they cancel.
6. **Operational stores**: For multi-node deployments, configure `ISamlSigninStateStore` and `ISamlLogoutSessionStore` (e.g., EF Core, Redis). Without them, SSO/SLO state is lost across nodes.
7. **Certificate rotation**: Metadata is cached (default 12h). SPs may not pick up new signing certs until cache expires.
8. **ClaimMappings vs AllowedScopes**: If `AllowedScopes` doesn't include a resource containing a claim type, that claim won't reach `ClaimMappings`.
## Related Skills
- `identityserver-configuration` — IdentityServer host configuration and options
- `identityserver-stores` — Persistent store patterns (EF Core, custom stores)
- `identity-security-hardening` — Key rotation, HTTPS enforcement
- `identityserver-ui-flows` — Login/logout UI flows that SAML integrates with
- `identityserver-upgrade-v7-to-v8` — Migration guide including SAML EF migrations
identityserver-sessions-providers25.7 KB
---
name: identityserver-sessions-providers
description: "Guide for configuring server-side sessions, session management and querying, inactivity timeout, dynamic identity providers, and CIBA (Client Initiated Backchannel Authentication) in Duende IdentityServer."
invocable: false
---
# IdentityServer Sessions, Dynamic Providers, and CIBA
## When to Use This Skill
- Enabling and configuring server-side sessions for authentication state management
- Implementing session querying, revocation, and administrative tooling via `ISessionManagementService`
- Configuring inactivity timeout across IdentityServer and client applications
- Setting up the Entity Framework Core session store or implementing a custom `IServerSideSessionStore`
- Adding dynamic identity providers loaded from a database at runtime
- Implementing custom non-OIDC dynamic provider types (Google, SAML, etc.)
- Building a CIBA (Client Initiated Backchannel Authentication) flow
- Understanding edition requirements (Business vs Enterprise) for these features
Docs: https://docs.duendesoftware.com/identityserver/ui/server-side-sessions/
## Server-Side Sessions
### What Problem Do They Solve?
By default, ASP.NET Core stores all authentication session state in a self-contained cookie. This creates several challenges:
| Problem | Impact |
| ---------------------------- | --------------------------------------------------------------------------------- |
| Cookie size growth | As clients are tracked, the cookie grows; large cookies can exceed browser limits |
| No session visibility | Cannot query how many active sessions exist |
| No administrative revocation | Cannot terminate a session from outside the user's browser |
| No server-side coordination | Cannot detect inactivity or synchronize session expiration across clients |
Server-side sessions store authentication state on the server, keeping only a session reference in the cookie.
### Edition Requirements
Server-side sessions are part of the **Duende IdentityServer Business and Enterprise Edition**.
### Enabling Server-Side Sessions
```csharp
// Program.cs
builder.Services.AddIdentityServer()
.AddServerSideSessions();
```
**Important**: This call must come after any custom `IRefreshTokenService` implementation registration. Order matters in the ASP.NET Core service provider.
By default, sessions are stored in-memory. For production, use Entity Framework Core or a custom store.
### Using Entity Framework Core Store
```csharp
// Program.cs
builder.Services.AddIdentityServer()
.AddServerSideSessions()
.AddOperationalStore(options =>
{
options.ConfigureDbContext = builder =>
builder.UseSqlServer(connectionString,
sql => sql.MigrationsAssembly(migrationsAssembly));
});
```
The EF Core implementation is included in the operational store and supports the `IServerSideSessionStore` interface automatically.
### Custom Session Store
Implement `IServerSideSessionStore` and register it:
```csharp
// Program.cs — two-step registration
builder.Services.AddIdentityServer()
.AddServerSideSessions()
.AddServerSideSessionStore<YourCustomStore>();
// Or one-step registration
builder.Services.AddIdentityServer()
.AddServerSideSessions<YourCustomStore>();
```
### Data Stored Server-Side
The session stores the serialized ASP.NET Core `AuthenticationTicket` (all claims + `AuthenticationProperties.Items`). The data is protected using ASP.NET Core's Data Protection API.
Queryable indices extracted from the session:
| Index | Source |
| ------------ | ------------------------------------------------- |
| Subject ID | `sub` claim value |
| Session ID | `sid` claim value |
| Display Name | Configurable claim type (e.g., `name` or `email`) |
Configure the display name claim. **Note**: `UserDisplayNameClaimType` is **unset (null) by default** due to PII concerns. You must explicitly set it if you want display names stored in the session index:
```csharp
// Program.cs
builder.Services.AddIdentityServer(options => {
options.ServerSideSessions.UserDisplayNameClaimType = "name";
}).AddServerSideSessions();
```
## Session Management with ISessionManagementService
### Querying Sessions
```csharp
var userSessions = await _sessionManagementService.QuerySessionsAsync(new SessionQuery
{
CountRequested = 10,
SubjectId = "12345",
DisplayName = "Bob",
});
```
### Paging Through Results
```csharp
// First page
var userSessions = await _sessionManagementService.QuerySessionsAsync(new SessionQuery
{
CountRequested = 10,
});
// Next page
userSessions = await _sessionManagementService.QuerySessionsAsync(new SessionQuery
{
ResultsToken = userSessions.ResultsToken,
CountRequested = 10,
});
// Previous page
userSessions = await _sessionManagementService.QuerySessionsAsync(new SessionQuery
{
ResultsToken = userSessions.ResultsToken,
RequestPriorResults = true,
CountRequested = 10,
});
```
### Performance Note on Querying
When listing sessions, prefer `GetSessionsAsync` over `QuerySessionsAsync`. The `QuerySessionsAsync` method performs a full-text search and may be slower. Use `QuerySessionsAsync` only when advanced filtering is needed.
### Terminating Sessions
Terminate sessions and optionally revoke tokens, consents, and send back-channel logout notifications:
```csharp
// Revoke everything for a user
await _sessionManagementService.RemoveSessionsAsync(new RemoveSessionsContext
{
SubjectId = "12345"
});
```
Selective revocation (filtering by `SessionId` or `ClientIds` is also supported):
```csharp
// Only revoke refresh tokens, keep session and consents
await _sessionManagementService.RemoveSessionsAsync(new RemoveSessionsContext
{
SubjectId = "12345",
SessionId = "abc123", // optional: target a specific session
ClientIds = { "my_app" }, // optional: target specific clients
RevokeTokens = true,
RemoveServerSideSession = false,
RevokeConsents = false,
SendBackchannelLogoutNotification = false,
});
```
### What Gets Cleaned Up
| Flag | Effect |
| --------------------------------------------------- | ---------------------------------------------------------------- |
| `RemoveServerSideSession` (default: true) | Deletes the session record from the store |
| `RevokeTokens` (default: true) | Revokes refresh tokens and reference access tokens |
| `RevokeConsents` (default: true) | Removes persisted consent grants |
| `SendBackchannelLogoutNotification` (default: true) | Sends back-channel logout to clients with `BackChannelLogoutUri` |
Internally, this uses `IServerSideTicketStore`, `IPersistedGrantStore`, and `IBackChannelLogoutService`.
## Server-Side Session Custom Metadata
Store per-sign-in metadata (device name, auth method, region) in the `AuthenticationTicket`'s `AuthenticationProperties.Items`. It stays inside IdentityServer and is **NOT** issued as claims.
### Writing Metadata
```csharp
var properties = new AuthenticationProperties();
properties.Items["device_name"] = "Bob's iPhone";
properties.Items["region"] = "eu-west";
await HttpContext.SignInAsync(identityServerUser, properties);
```
With ASP.NET Identity, pass `properties` to `SignInWithClaimsAsync`:
```csharp
await _signInManager.SignInWithClaimsAsync(user, properties, additionalClaims: []);
```
If you use `PasswordSignInAsync` (which does not accept properties), override `SignInWithClaimsAsync` in a custom `SignInManager` to inject the metadata.
### Reading Metadata
```csharp
var sessions = await _sessionManagementService.QuerySessionsAsync(
new SessionQuery { SubjectId = "12345" });
foreach (var session in sessions.Results)
{
if (session.AuthenticationTicket.Properties.Items
.TryGetValue("device_name", out var deviceName))
{
// use deviceName
}
}
```
**Limitation**: custom metadata is **not indexed** by the built-in store — you cannot filter `SessionQuery` by it. Query by subject id, session id, or display name first, then inspect the tickets.
## Inactivity Timeout
### The Challenge
OpenID Connect does not natively provide distributed session management based on user inactivity. Multiple artifacts (cookies, refresh tokens, access tokens) have independent lifetimes controlled by different entities. Coordinating their expiration is non-trivial.
### Design: Centralized Session Tracking
Server-side sessions at IdentityServer provide the central record for monitoring user activity:
1. **Activity signals**: As the user's client uses refresh tokens, introspection, or userinfo, these protocol calls extend the server-side session automatically via an internal `ISessionCoordinationService` (this is an implementation detail, not a public API for consumers).
2. **Inactivity detection**: When no activity occurs within the session timeout, the session expires and cleanup is triggered (back-channel logout, token revocation).
### Configuration at IdentityServer
Three features must be enabled:
```csharp
// Program.cs
builder.Services.AddIdentityServer(options =>
{
// 1. Enable server-side sessions
// (done separately via .AddServerSideSessions())
// 2. Coordinate client token lifetimes with the user session
options.Authentication.CoordinateClientLifetimesWithUserSession = true;
// 3. Trigger back-channel logout when sessions expire
// This is already true by default, shown here for explicitness
options.ServerSideSessions.ExpiredSessionsTriggerBackchannelLogout = true;
}).AddServerSideSessions();
```
**Note**: `ExpiredSessionsTriggerBackchannelLogout` defaults to `true`, so step 3 is technically optional. The only setting you must explicitly enable is `CoordinateClientLifetimesWithUserSession` (step 2).
Alternatively, enable coordination per-client:
```csharp
var client = new Client
{
ClientId = "my_app",
CoordinateLifetimeWithUserSession = true
};
```
### Client-Side Configuration
| Client Type | How Activity Is Signaled | How Inactivity Is Detected |
| ----------------------------------------- | ----------------------------------------- | -------------------------------------------------------------- |
| Client with refresh tokens | Refresh token requests extend the session | Handle refresh token failure, or implement back-channel logout |
| Client with reference tokens (no refresh) | Introspection extends the session | Handle `401` from API, or implement back-channel logout |
| Client without access tokens | Cannot signal activity | Must implement back-channel logout |
**Critical**: Configure access token lifetime to be shorter than the server-side session lifetime at IdentityServer, so that refresh token usage naturally keeps the session alive.
## Session Expiration and Cleanup
When a session cookie expires without explicit logout, the server-side session record remains in the store. An automatic cleanup job periodically scans for and removes these expired records.
### Expiration Configuration Options
All options are on `options.ServerSideSessions`:
| Option | Default | Description |
| ------ | ------- | ----------- |
| `RemoveExpiredSessions` | `true` | Enables periodic cleanup of expired sessions |
| `RemoveExpiredSessionsFrequency` | 10 minutes | How often the cleanup job runs |
| `RemoveExpiredSessionsBatchSize` | 100 | Number of expired records removed per batch |
| `ExpiredSessionsTriggerBackchannelLogout` | `true` | Send back-channel logout notifications when expired sessions are cleaned up |
| `FuzzExpiredSessionRemovalStart` | `true` | Randomize the first cleanup run to avoid multi-instance conflicts |
### Customizing the Cleanup Interval
```csharp
// Program.cs
builder.Services.AddIdentityServer(options => {
options.ServerSideSessions.RemoveExpiredSessionsFrequency = TimeSpan.FromSeconds(60);
}).AddServerSideSessions();
```
### Disabling Automatic Cleanup
```csharp
builder.Services.AddIdentityServer(options => {
options.ServerSideSessions.RemoveExpiredSessions = false;
}).AddServerSideSessions();
```
### Configuring Session Lifetime
The server-side session lifetime is inherited from the cookie authentication handler:
- **Default (no ASP.NET Identity)**: Controlled by `options.Authentication.CookieLifetime` (defaults to 10 hours)
- **With ASP.NET Core Identity**: Controlled by `ConfigureApplicationCookie(options => options.ExpireTimeSpan = ...)` (defaults to 14 days)
### Session Renewal and Absolute Lifetime Cap
With server-side sessions the **cookie expiration can extend beyond the configured lifetime**: IdentityServer calls `SignInAsync` whenever the session's client list changes (e.g. the user signs into an additional client), re-issuing the cookie and resetting its timer. Without server-side sessions, the cookie expiration is set once at login.
To enforce an absolute cap, combine:
- `Client.UserSsoLifetime` — forces interactive re-authentication after N seconds, regardless of cookie renewals.
- `Client.AbsoluteRefreshTokenLifetime` with `RefreshTokenExpiration = TokenExpiration.Absolute` — caps refresh-token-driven session extension.
```csharp
var client = new Client
{
ClientId = "web.app",
UserSsoLifetime = 8 * 3600, // re-auth after 8h
AbsoluteRefreshTokenLifetime = 8 * 3600,
RefreshTokenExpiration = TokenExpiration.Absolute,
};
```
## Dynamic Identity Providers
### Edition Requirements
Dynamic identity providers are part of the **Duende IdentityServer Enterprise Edition**.
### Problem Statement
Statically registering many authentication handlers via `AddOpenIdConnect()` has performance penalties in ASP.NET Core's DI system. It also requires application restart for configuration changes.
### Solution
Dynamic providers are loaded from a store at runtime, avoiding DI overhead and enabling live configuration changes.
### Store Options
| Store | Implementation |
| --------------------- | ---------------------------------- |
| In-memory | `AddInMemoryIdentityProviders()` |
| Entity Framework Core | Via `ConfigurationDbContext` |
| Custom | Implement `IIdentityProviderStore` |
### Adding a Dynamic OIDC Provider (In-Memory)
```csharp
// Program.cs
builder.Services.AddIdentityServer()
.AddInMemoryIdentityProviders(new[]
{
new OidcProvider
{
Scheme = "oidc",
DisplayName = "Sample provider",
Enabled = true,
// ... more properties
}
});
```
### Adding a Dynamic OIDC Provider (Entity Framework)
```csharp
// SeedData.cs
private static async Task SeedDynamicProviders(ConfigurationDbContext context)
{
if (!context.IdentityProviders.Any())
{
context.IdentityProviders.Add(new OidcProvider
{
Scheme = "demoidsrv",
DisplayName = "IdentityServer (dynamic)",
Authority = "https://demo.duendesoftware.com",
ClientId = "login",
}.ToEntity());
await context.SaveChangesAsync();
}
}
```
### Caching Dynamic Providers
By default, dynamic provider configuration is loaded from the store on every request. Enable caching:
- **EF stores**: Use `AddConfigurationStoreCache()`
- **Custom stores**: Use `AddIdentityProviderStoreCache<T>()`
### Listing Dynamic Providers on the Login Page
Merge static and dynamic providers:
```csharp
// Login.cshtml.cs
var schemes = await _schemeProvider.GetAllSchemesAsync();
var providers = schemes
.Where(x => x.DisplayName != null)
.Select(x => new ExternalProvider
{
DisplayName = x.DisplayName ?? x.Name,
AuthenticationScheme = x.Name
}).ToList();
var dynamicSchemes = (await _identityProviderStore.GetAllSchemeNamesAsync())
.Where(x => x.Enabled)
.Select(x => new ExternalProvider
{
AuthenticationScheme = x.Scheme,
DisplayName = x.DisplayName
});
providers.AddRange(dynamicSchemes);
```
### Callback Path Convention
Dynamic providers follow the convention `~/federation/{scheme}/{suffix}`:
| Path | Purpose |
| --------------------------------------- | -------------------------------------------------- |
| `/federation/{scheme}/signin` | OIDC redirect URI (`CallbackPath`) |
| `/federation/{scheme}/signout-callback` | Post-logout redirect URI (`SignedOutCallbackPath`) |
| `/federation/{scheme}/signout` | Front-channel logout URI (`RemoteSignOutPath`) |
Customize the prefix:
```csharp
builder.Services.AddIdentityServer(options =>
{
options.DynamicProviders.PathPrefix = "/fed";
});
```
### Custom (Non-OIDC) Dynamic Providers
To add providers like Google or SAML:
**Step 1**: Create a custom `IdentityProvider` type:
```csharp
public class GoogleIdentityProvider : IdentityProvider
{
public const string ProviderType = "google";
public GoogleIdentityProvider() : base(ProviderType) { }
public string? ClientId
{
get => this["ClientId"];
set => this["ClientId"] = value;
}
public string? ClientSecret
{
get => this["ClientSecret"];
set => this["ClientSecret"] = value;
}
}
```
**Step 2**: Register the handler mapping:
```csharp
// Program.cs
builder.Services.AddIdentityServer(options =>
{
options.DynamicProviders
.AddProviderType<GoogleHandler, GoogleOptions, GoogleIdentityProvider>(
GoogleIdentityProvider.ProviderType);
});
```
**Step 3**: Configure options mapping:
```csharp
class GoogleDynamicConfigureOptions
: ConfigureAuthenticationOptions<GoogleOptions, GoogleIdentityProvider>
{
public GoogleDynamicConfigureOptions(IHttpContextAccessor httpContextAccessor,
ILogger<GoogleDynamicConfigureOptions> logger) : base(httpContextAccessor, logger) { }
protected override void Configure(
ConfigureAuthenticationContext<GoogleOptions, GoogleIdentityProvider> context)
{
var googleProvider = context.IdentityProvider;
var googleOptions = context.AuthenticationOptions;
googleOptions.ClientId = googleProvider.ClientId;
googleOptions.ClientSecret = googleProvider.ClientSecret;
googleOptions.SignInScheme = context.DynamicProviderOptions.SignInScheme;
googleOptions.CallbackPath = context.PathPrefix + "/signin";
}
}
```
Register it:
```csharp
builder.Services.ConfigureOptions<GoogleDynamicConfigureOptions>();
```
### Customizing OpenIdConnectOptions for Dynamic Providers
Implement `IConfigureNamedOptions<OpenIdConnectOptions>` for per-scheme customization:
```csharp
public class CustomConfig : IConfigureNamedOptions<OpenIdConnectOptions>
{
public void Configure(string name, OpenIdConnectOptions options)
{
if (name == "MyScheme")
{
// customize options
}
}
public void Configure(OpenIdConnectOptions options) { }
}
```
Register: `builder.Services.ConfigureOptions<CustomConfig>();`
For customizations that need access to the `OidcProvider` data (e.g., the `Properties` bag), derive from `ConfigureAuthenticationOptions<OpenIdConnectOptions, OidcProvider>` instead.
## CIBA (Client Initiated Backchannel Authentication)
### Edition Requirements
CIBA is part of the **Duende IdentityServer Enterprise Edition**.
### What Is CIBA?
CIBA allows a user to authenticate on a different device than the one running the client application. Example: a user at a bank kiosk authenticates via their mobile phone.
### CIBA Flow
1. **Client** sends a backchannel authentication request to IdentityServer's `/connect/ciba` endpoint
2. **IdentityServer** validates the request and identifies the user via `IBackchannelAuthenticationUserValidator` (you must implement this)
3. **IdentityServer** creates a pending login request in the `IBackchannelAuthenticationRequestStore`
4. **IdentityServer** notifies the user via `IBackchannelAuthenticationUserNotificationService` (you must implement this — e.g., push notification, email, SMS)
5. **User** reviews and approves/denies the request; your UI calls `IBackchannelAuthenticationInteractionService.CompleteLoginRequestAsync`
6. **Client** polls the token endpoint and receives tokens (or an error if denied/timed out)
### Required Implementations
| Interface | Your Responsibility |
| --------------------------------------------------- | ------------------------------------------------------------------------------- |
| `IBackchannelAuthenticationUserValidator` | Validate the request and return the user's `sub` claim |
| `IBackchannelAuthenticationUserNotificationService` | Notify the user (push, email, SMS, etc.) with the `BackchannelUserLoginRequest` |
### Client Configuration
```csharp
var client = new Client
{
ClientId = "kiosk.app",
AllowedGrantTypes = GrantTypes.Ciba, // CIBA grant
// ClientSecrets, AllowedScopes, etc.
};
```
The client calls the backchannel authentication endpoint, receives an `auth_req_id`, then polls the token endpoint (`poll` mode). It must handle `authorization_pending`, `slow_down`, expiration, and user denial.
### User Notification Service
```csharp
public class UserNotificationService : IBackchannelAuthenticationUserNotificationService
{
public Task SendLoginRequestAsync(BackchannelUserLoginRequest request, CancellationToken ct)
{
// request.Subject.GetSubjectId() — the user to notify
// request.InternalId — sensitive handle to the pending request
// request.BindingMessage — show to user; compared on both devices
// Deliver a push/SMS/email linking to your approval UI.
return Task.CompletedTask;
}
}
```
Register it:
```csharp
builder.Services.AddIdentityServer()
.AddBackchannelAuthenticationUserNotificationService<UserNotificationService>();
```
The built-in no-op implementation just logs a URL for testing — **replace it in production**. Treat `InternalId` as sensitive; never surface it in the notification. The user compares the `BindingMessage` shown on both the consumption device and their authentication device.
### Approval UI
Use `IBackchannelAuthenticationInteractionService`:
```csharp
// List this user's pending CIBA requests
var pending = await _cibaInteraction.GetPendingLoginRequestsForCurrentUserAsync(ct);
// Reload one by internal id and verify ownership
var request = await _cibaInteraction.GetLoginRequestByInternalIdAsync(internalId, ct);
if (request.Subject.GetSubjectId() != currentUserSubjectId) return Forbid();
// Approve with consented scopes (a subset is allowed)
await _cibaInteraction.CompleteLoginRequestAsync(
new CompleteBackchannelLoginRequest(internalId)
{
ScopesValuesConsented = request.ValidatedResources.RawScopeValues,
}, ct);
await _events.RaiseAsync(new ConsentGrantedEvent(/* ... */));
// Deny: leave ScopesValuesConsented empty/null
await _cibaInteraction.CompleteLoginRequestAsync(
new CompleteBackchannelLoginRequest(internalId) { ScopesValuesConsented = null }, ct);
await _events.RaiseAsync(new ConsentDeniedEvent(/* ... */));
```
The server **rejects any scope not present in the original CIBA request**. Raise `ConsentGrantedEvent` / `ConsentDeniedEvent` for audit.
IdentityServer supports the `poll` mode for clients to obtain results.
## Common Anti-Patterns
- ❌ Using in-memory session store in production — sessions are lost on restart
- ✅ Use Entity Framework Core or a custom durable store for production
- ❌ Registering hundreds of static authentication handlers via `AddOpenIdConnect()`
- ✅ Use dynamic identity providers for scalable provider management
- ❌ Assuming inactivity timeout works automatically without enabling `CoordinateClientLifetimesWithUserSession`
- ✅ Explicitly enable coordination at the global or per-client level
- ❌ Using `QuerySessionsAsync` for simple session listing
- ✅ Prefer `GetSessionsAsync` — it is faster; use `QuerySessionsAsync` only for advanced filtering
- ❌ Forgetting to implement `IBackchannelAuthenticationUserValidator` and `IBackchannelAuthenticationUserNotificationService` for CIBA
- ✅ Both interfaces must be implemented and registered in DI — IdentityServer does not provide defaults
## Common Pitfalls
1. **Registration order matters**: `AddServerSideSessions()` must be called after any custom `IRefreshTokenService` registration.
2. **Data Protection dependency**: Server-side session data is protected using ASP.NET Core Data Protection. Ensure Data Protection keys are persisted and shared across load-balanced instances.
3. **Session expiration vs cookie expiration**: The server-side session has its own lifetime. When a session expires server-side, the user's cookie becomes invalid even if the cookie itself hasn't expired.
4. **Dynamic provider store is read-only**: `IIdentityProviderStore` only has query methods. To add/update/delete providers, use `ConfigurationDbContext` directly (for EF) or your own mechanism (for custom stores).
5. **CIBA requires Enterprise Edition**: Attempting to use CIBA features without the Enterprise Edition license will fail at runtime.
6. **Access token lifetime must be shorter than session timeout**: For inactivity timeout to work, refresh token usage must happen regularly enough to signal activity. If the access token lives longer than the session timeout, the client won't refresh in time.
identityserver-stores37.5 KB
---
name: identityserver-stores
description: Implement and customize Duende IdentityServer stores including configuration store, operational store, and Entity Framework Core integration. Covers migrations, custom store implementations, caching strategies, server-side sessions, signing key storage, token cleanup, and multi-tenant patterns.
invocable: false
---
# Duende IdentityServer Stores
## When to Use This Skill
- You are wiring up `AddConfigurationStore()` or `AddOperationalStore()` with EF Core and need correct registration, migration assembly setup, and schema configuration.
- You are implementing a custom `IClientStore`, `IResourceStore`, `IPersistedGrantStore`, or `ISigningKeyStore` against a non-EF data source (Redis, Mongo, external API, etc.).
- You need to enable and tune configuration store caching (`AddConfigurationStoreCache()`, expiration windows, distributed cache setup) to reduce database load.
- You are managing EF Core migrations across IdentityServer versions and need to correctly handle schema drift for `ConfigurationDbContext` and `PersistedGrantDbContext`.
- You are enabling server-side sessions (`IServerSideSessionStore`) and need to understand session lifecycle, cleanup, and storage integration.
- You are troubleshooting stale client or resource data, expired token accumulation, or signing key rotation failures tied to store configuration.
- You are designing a multi-tenant IdentityServer deployment and need to choose between database-per-tenant and shared-database store strategies.
## Core Principles
**Store interfaces decouple IdentityServer from persistence.** All data access goes through store interfaces registered in the ASP.NET Core DI container. IdentityServer does not care what database backs them — EF Core, Redis, MongoDB, or a static in-memory collection are all equally valid.
**Two independent store categories exist: configuration and operational.** They can be used independently or together. Configuration data is relatively static (clients, resources, CORS); operational data is dynamic and high-write (grants, sessions, signing keys). They should be sized, cached, and maintained with those distinct access patterns in mind.
**Operational data is protected at rest.** The `Data` payload of persisted grants and serialized signing keys is encrypted using the ASP.NET Core Data Protection API. Key rotation and Data Protection configuration must be coordinated — a lost Data Protection key makes stored grants and signing keys unreadable.
**Consumed grants are soft-deleted, not immediately removed.** One-time-use grants (e.g., authorization codes, one-time refresh tokens) are marked with a `ConsumedTime` rather than deleted. This enables threat detection in custom `IRefreshTokenService` implementations. Do not confuse consumed with expired — the token cleanup service only removes records past their `Expiration`, not consumed ones (unless `RemoveConsumedTokens` is enabled).
**EF Core schema changes are your responsibility.** Duende does not ship automatic migration scripts or schema upgrade tooling. You own migration creation, application, and data migration between IdentityServer versions.
Docs: https://docs.duendesoftware.com/identityserver/data
---
## NuGet Package
```bash
dotnet add package Duende.IdentityServer.EntityFramework
```
This package provides EF Core implementations for all configuration and operational store interfaces.
---
## Store Architecture
IdentityServer's data is split into two categories, each with its own set of store interfaces:
```
┌─────────────────────────────────────────────────────────────┐
│ IdentityServer Runtime │
├──────────────────────────┬──────────────────────────────────┤
│ Configuration Data │ Operational Data │
│ │ │
│ • Clients │ • Authorization codes │
│ • API Resources │ • Reference tokens │
│ • API Scopes │ • Refresh tokens │
│ • Identity Resources │ • User consent │
│ • Identity Providers │ • Device codes │
│ • CORS policies │ • Pushed auth. requests │
│ │ • Signing keys │
│ │ • Server-side sessions │
├──────────────────────────┼──────────────────────────────────┤
│ ConfigurationDbContext │ PersistedGrantDbContext │
│ (IClientStore, │ (IPersistedGrantStore, │
│ IResourceStore, │ IDeviceFlowStore, │
│ IIdentityProviderStore,│ IPushedAuthorizationRequestStore,│
│ ICorsPolicyService) │ IServerSideSessionStore, │
│ │ ISigningKeyStore) │
└──────────────────────────┴──────────────────────────────────┘
```
### Configuration Data
Stores static, rarely-changing data that describes how IdentityServer behaves:
| Interface | Contents |
|---|---|
| `IClientStore` | OAuth/OIDC clients (grant types, redirect URIs, secrets, claims, scopes) |
| `IResourceStore` | `IdentityResource`, `ApiResource`, and `ApiScope` definitions |
| `ICorsPolicyService` | CORS allowed-origin rules (derived from client configuration) |
| `IIdentityProviderStore` | Dynamic external identity provider registrations |
### Operational Data
Stores dynamic, high-write runtime state that IdentityServer creates and manages during request processing:
| Interface | Contents |
|---|---|
| `IPersistedGrantStore` | Authorization codes, refresh tokens, reference tokens, user consent records |
| `IDeviceFlowStore` | Device authorization flow codes and user codes |
| `ISigningKeyStore` | Dynamically managed signing keys (used by automatic key management) |
| `IServerSideSessionStore` | Server-side authentication session data for interactive users |
| `IPushedAuthorizationRequestStore` | Pushed authorization request (PAR) data |
---
## EF Core Integration
The `Duende.IdentityServer.EntityFramework` NuGet package provides EF Core-backed implementations of all store interfaces. It ships two `DbContext` types:
- **`ConfigurationDbContext`** — backs `IClientStore`, `IResourceStore`, `ICorsPolicyService`, `IIdentityProviderStore`
- **`PersistedGrantDbContext`** — backs `IPersistedGrantStore`, `IDeviceFlowStore`, `ISigningKeyStore`, `IServerSideSessionStore`
### Registering Both Stores
```csharp
// ✅ Correct: register both stores with explicit migration assembly
var migrationsAssembly = typeof(Program).Assembly.GetName().Name;
var connectionString = builder.Configuration.GetConnectionString("IdentityServer");
builder.Services.AddIdentityServer()
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString, sql =>
sql.MigrationsAssembly(migrationsAssembly));
})
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString, sql =>
sql.MigrationsAssembly(migrationsAssembly));
options.EnableTokenCleanup = true;
options.TokenCleanupInterval = 3600; // seconds; default 1 hour
});
```
```csharp
// ❌ Wrong: omitting MigrationsAssembly when migrations live in the host project
builder.Services.AddIdentityServer()
.AddConfigurationStore(options =>
{
options.ConfigureDbContext = b => b.UseSqlServer(connectionString);
// EF will look for migrations in Duende.IdentityServer.EntityFramework.dll
// and fail to find them
});
```
### Separate Schemas
Isolate configuration and operational tables using `DefaultSchema` to avoid naming collisions and simplify backup strategies:
```csharp
// ✅ Recommended for production: dedicated schemas per store
builder.Services.AddIdentityServer()
.AddConfigurationStore(options =>
{
options.DefaultSchema = "idscfg";
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString, sql =>
{
sql.MigrationsAssembly(migrationsAssembly);
sql.MigrationsHistoryTable("__ConfigMigrationsHistory", "idscfg");
});
})
.AddOperationalStore(options =>
{
options.DefaultSchema = "idsop";
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString, sql =>
{
sql.MigrationsAssembly(migrationsAssembly);
sql.MigrationsHistoryTable("__OperationalMigrationsHistory", "idsop");
});
});
```
---
## Migrations
EF Core migrations must be created in the host assembly. IdentityServer does not generate or apply migrations automatically.
### Creating Migrations
```shell
# Configuration store migration
dotnet ef migrations add InitialIdentityServerConfigurationDb \
--context ConfigurationDbContext \
--output-dir Data/Migrations/IdentityServer/ConfigurationDb
# Operational store migration
dotnet ef migrations add InitialIdentityServerOperationalDb \
--context PersistedGrantDbContext \
--output-dir Data/Migrations/IdentityServer/OperationalDb
```
### Applying Migrations at Startup
```csharp
// ✅ Apply EF migrations on startup (suitable for dev/staging; use a deploy pipeline in production)
public static void InitializeDatabase(IApplicationBuilder app)
{
using var serviceScope = app.ApplicationServices
.GetRequiredService<IServiceScopeFactory>()
.CreateScope();
serviceScope.ServiceProvider
.GetRequiredService<PersistedGrantDbContext>()
.Database
.Migrate();
var configContext = serviceScope.ServiceProvider
.GetRequiredService<ConfigurationDbContext>();
configContext.Database.Migrate();
// Seed initial configuration data if empty
if (!configContext.Clients.Any())
{
foreach (var client in Config.Clients)
configContext.Clients.Add(client.ToEntity());
configContext.SaveChanges();
}
}
```
### Handling Schema Updates Across Versions
When upgrading IdentityServer, always check the [upgrade guide](https://docs.duendesoftware.com/identityserver/upgrades/) for schema changes before applying the new package version:
1. Review the changelog for any new columns or tables in `ConfigurationDbContext` or `PersistedGrantDbContext`.
2. Scaffold a new EF migration: `dotnet ef migrations add UpgradeToV7x --context ConfigurationDbContext`.
3. Review the generated migration SQL — especially for columns with `NOT NULL` constraints that require backfill.
4. Apply to a staging environment and validate before production.
```csharp
// ❌ Never auto-apply migrations in production startup without a health gate
// This causes downtime on multi-instance deployments where one instance
// applies the migration while others still run against the old schema
app.ApplicationServices.GetRequiredService<ConfigurationDbContext>()
.Database.Migrate(); // Dangerous in multi-node deployments
```
---
## Caching Configuration Data
Configuration data (clients, resources, CORS) is read on every token request. Without caching, every request hits the database.
### EF Store Caching (Recommended)
```csharp
// ✅ Enable cache for the EF configuration store (v8: uses HybridCache)
builder.Services.AddIdentityServer()
.AddConfigurationStore(options => { /* ... */ })
.AddConfigurationStoreCache(); // wraps EF stores with HybridCache
```
`AddConfigurationStoreCache()` wraps each configuration store with a caching decorator backed by Microsoft `HybridCache`. Cache expiration is controlled through `IdentityServerOptions.Caching`:
```csharp
builder.Services.AddIdentityServer(options =>
{
options.Caching.ClientStoreExpiration = TimeSpan.FromMinutes(5);
options.Caching.ResourceStoreExpiration = TimeSpan.FromMinutes(5);
options.Caching.CorsExpiration = TimeSpan.FromMinutes(5);
options.Caching.IdentityProviderCacheDuration = TimeSpan.FromMinutes(60);
})
.AddConfigurationStore(options => { /* ... */ })
.AddConfigurationStoreCache();
```
### Custom Store Caching
When using a custom `IClientStore`, wrap it with the caching decorator explicitly:
```csharp
// ✅ Cache applied to a custom store implementation
builder.Services.AddIdentityServer()
.AddClientStore<MongoClientStore>()
.AddResourceStore<MongoResourceStore>()
.AddClientStoreCache<MongoClientStore>()
.AddResourceStoreCache<MongoResourceStore>();
```
### Distributed Cache for Multi-Node Deployments
In-memory cache is node-local — a client update only invalidates the cache on the node where the change was made. For multi-node deployments, configure `HybridCache` with a distributed backend:
```csharp
// ✅ Configure HybridCache with Redis backend for multi-node scenarios
builder.Services.AddHybridCache();
builder.Services.AddStackExchangeRedisCache(options =>
options.Configuration = builder.Configuration["Redis:ConnectionString"]);
builder.Services.AddIdentityServer()
.AddConfigurationStore(options => { /* ... */ })
.AddConfigurationStoreCache();
```
> **Note:** In v8, `ICache<T>` is replaced by Microsoft `HybridCache`. If you have custom `ICache<T>` implementations, migrate to `HybridCache` with keyed services (`ServiceProviderKeys.ConfigurationStoreCache`). See the `identityserver-upgrade-v7-to-v8` skill for migration patterns.
>
> After a client or resource update, explicitly evict the cache entry or wait for expiration. There is no built-in cache invalidation webhook.
---
## Custom Stores
Implement custom stores when EF Core is unsuitable — for example, when client definitions live in an external system, or when operational data must be stored in Redis or a document database.
### In-Memory Stores (Development Only)
For development and testing, in-memory stores avoid database setup entirely:
```csharp
// ✅ In-memory stores — development and testing only
builder.Services.AddIdentityServer()
.AddInMemoryClients(Config.Clients)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryApiResources(Config.ApiResources)
.AddInMemoryIdentityResources(Config.IdentityResources);
```
In-memory stores are created once at startup and cannot be updated at runtime without restarting the application. They do not survive restarts and should never be used for operational data in production.
> **Version Note — CancellationToken parameters (v8+):**
> The store interface signatures below include `CancellationToken` parameters, which were **added in Duende IdentityServer v8**. In **v7 and earlier**, these interfaces do **not** accept `CancellationToken` — omit the parameter when targeting v7. Additionally, `IClientStore.GetAllClientsAsync` is a **new method in v8**; it does not exist in v7.
### `IClientStore`
```csharp
// ✅ Custom client store reading from an external API
public sealed class ExternalApiClientStore : IClientStore
{
private readonly IExternalClientApi _api;
public ExternalApiClientStore(IExternalClientApi api)
=> _api = api;
public async Task<Client?> FindClientByIdAsync(string clientId, CancellationToken ct = default)
{
var dto = await _api.GetClientAsync(clientId);
return dto is null ? null : dto.ToIdentityServerClient();
}
public async IAsyncEnumerable<Client> GetAllClientsAsync(CancellationToken ct = default)
{
await foreach (var dto in _api.GetAllClientsAsync(ct))
yield return dto.ToIdentityServerClient();
}
}
```
```csharp
// ✅ Registration — use helper method, not AddTransient directly
builder.Services.AddIdentityServer()
.AddClientStore<ExternalApiClientStore>();
```
### `IResourceStore`
```csharp
// ✅ Custom resource store — must implement all five query methods
public sealed class DatabaseResourceStore : IResourceStore
{
private readonly ResourceRepository _repo;
public DatabaseResourceStore(ResourceRepository repo) => _repo = repo;
public Task<IEnumerable<IdentityResource>> FindIdentityResourcesByScopeNameAsync(
IEnumerable<string> scopeNames, CancellationToken ct = default)
=> _repo.GetIdentityResourcesAsync(scopeNames);
public Task<IEnumerable<ApiScope>> FindApiScopesByNameAsync(
IEnumerable<string> scopeNames, CancellationToken ct = default)
=> _repo.GetApiScopesAsync(scopeNames);
public Task<IEnumerable<ApiResource>> FindApiResourcesByScopeNameAsync(
IEnumerable<string> scopeNames, CancellationToken ct = default)
=> _repo.GetApiResourcesByScopeAsync(scopeNames);
public Task<IEnumerable<ApiResource>> FindApiResourcesByNameAsync(
IEnumerable<string> apiResourceNames, CancellationToken ct = default)
=> _repo.GetApiResourcesByNameAsync(apiResourceNames);
public Task<Resources> GetAllResourcesAsync(CancellationToken ct = default)
=> _repo.GetAllAsync();
}
```
### `IPersistedGrantStore`
```csharp
// ✅ Custom persisted grant store — all methods must be implemented
public sealed class RedisPersistedGrantStore : IPersistedGrantStore
{
private readonly IDatabase _redis;
public RedisPersistedGrantStore(IConnectionMultiplexer mux)
=> _redis = mux.GetDatabase();
public async Task StoreAsync(PersistedGrant grant, CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(grant);
var expiry = grant.Expiration.HasValue
? grant.Expiration.Value - DateTimeOffset.UtcNow
: TimeSpan.FromDays(30);
await _redis.StringSetAsync(grant.Key, json, expiry);
}
public async Task<PersistedGrant?> GetAsync(string key, CancellationToken ct = default)
{
var value = await _redis.StringGetAsync(key);
return value.IsNull ? null : JsonSerializer.Deserialize<PersistedGrant>(value!);
}
public async Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter, CancellationToken ct = default)
{
// Redis requires a secondary index (e.g., SET per subjectId) for filtered queries
// Implementation depends on your indexing strategy
throw new NotImplementedException("Implement with a subject-keyed index");
}
public Task RemoveAsync(string key, CancellationToken ct = default)
=> _redis.KeyDeleteAsync(key);
public Task RemoveAllAsync(PersistedGrantFilter filter, CancellationToken ct = default)
{
// Requires secondary index lookup
throw new NotImplementedException("Implement with a subject-keyed index");
}
}
```
```csharp
// ✅ Registration for custom operational stores — register directly, not through builder helpers
builder.Services.AddIdentityServer();
builder.Services.AddTransient<IPersistedGrantStore, RedisPersistedGrantStore>();
builder.Services.AddTransient<IDeviceFlowStore, YourCustomDeviceFlowStore>();
```
---
## Server-Side Sessions Store
Server-side sessions (added in IdentityServer 6.1) keep authentication session data server-side rather than in the cookie, enabling centralized session management, inactivity timeouts, and back-channel logout across all sessions for a user.
### Enabling with EF Core
```csharp
// ✅ Server-side sessions backed by the EF operational store
builder.Services.AddIdentityServer()
.AddServerSideSessions() // must be called to enable the feature
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString, sql =>
sql.MigrationsAssembly(migrationsAssembly));
options.EnableTokenCleanup = true;
});
```
### Custom `IServerSideSessionStore`
```csharp
// ✅ Custom server-side session store
builder.Services.AddIdentityServer()
.AddServerSideSessions<YourCustomSessionStore>();
// Equivalent to:
builder.Services.AddIdentityServer()
.AddServerSideSessions()
.AddServerSideSessionStore<YourCustomSessionStore>();
```
The `IServerSideSessionStore` interface provides methods for `CreateSessionAsync`, `GetSessionAsync`, `UpdateSessionAsync`, `DeleteSessionAsync`, and bulk query/management methods used by session expiration and back-channel logout coordination. All methods must be implemented — there are no default no-op implementations.
### Session Cleanup
Session records accumulate over time. Token cleanup (`EnableTokenCleanup`) removes expired sessions from the EF operational store. For custom stores, you must implement your own cleanup background service.
---
## Signing Key Store
Duende IdentityServer's automatic key management feature dynamically creates and rotates signing keys. Keys must be persisted across restarts and shared across nodes.
### Default: File System
The default `ISigningKeyStore` persists keys to the file system. This is suitable for single-node deployments only:
```csharp
// ✅ File system key store (default) — single node only
builder.Services.AddIdentityServer()
.AddDeveloperSigningCredential(); // development only
// For production single-node: nothing extra needed; file system is the default
```
### EF Core Key Store
`AddOperationalStore()` automatically registers `ISigningKeyStore` against `PersistedGrantDbContext`:
```csharp
// ✅ EF-backed signing key store — required for multi-node deployments
builder.Services.AddIdentityServer()
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString, sql =>
sql.MigrationsAssembly(migrationsAssembly));
});
// ISigningKeyStore is now backed by PersistedGrantDbContext
```
### Custom `ISigningKeyStore`
```csharp
// ✅ Register a custom signing key store
builder.Services.AddIdentityServer()
.AddSigningKeyStore<YourCustomSigningKeyStore>();
```
The `ISigningKeyStore` interface has three methods (CancellationToken parameters are v8+ only — see version note above):
- `LoadKeysAsync(CancellationToken ct)` — returns all `SerializedKey` records; called on startup and periodically
- `StoreKeyAsync(SerializedKey key, CancellationToken ct)` — persists a newly created key
- `DeleteKeyAsync(string id, CancellationToken ct)` — removes a retired key
### Data Protection Considerations
The `Data` property of `SerializedKey` may be encrypted via ASP.NET Core Data Protection (check `DataProtected == true`). When implementing a custom store:
- **Do not re-encrypt** data returned from `LoadKeysAsync` — IdentityServer decrypts it internally.
- **Ensure Data Protection keys are shared** across all nodes in a multi-node deployment. If node A encrypts a signing key and node B cannot decrypt it, token signing will fail.
- Store Data Protection keys in a shared location (Azure Blob, SQL, Redis) and protect them with a shared certificate or key vault key.
```csharp
// ✅ Share Data Protection keys across nodes (Azure Blob + Key Vault example)
builder.Services.AddDataProtection()
.PersistKeysToAzureBlobStorage(/* blob container */)
.ProtectKeysWithAzureKeyVault(/* key vault key id */);
```
---
## Token Cleanup
Operational data accumulates continuously. Without cleanup, the `PersistedGrants` table grows unbounded, degrading query performance.
### Enabling Automatic Cleanup
```csharp
// ✅ Enable token cleanup in the EF operational store
builder.Services.AddIdentityServer()
.AddOperationalStore(options =>
{
options.ConfigureDbContext = b =>
b.UseSqlServer(connectionString, sql =>
sql.MigrationsAssembly(migrationsAssembly));
options.EnableTokenCleanup = true;
options.TokenCleanupInterval = 3600; // seconds; default 1 hour
// Remove consumed one-time tokens (e.g., used refresh tokens with OneTime usage)
options.RemoveConsumedTokens = true;
options.ConsumedTokenCleanupDelay = 0; // seconds to wait before deleting consumed tokens
// Fuzz startup time to reduce multi-node cleanup conflicts (default: true)
options.FuzzTokenCleanupStart = true;
});
```
### OperationalStoreOptions Reference
| Option | Type | Default | Description |
| --------------------------- | --------------------------------- | ------- | ---------------------------------------------------------------------------- |
| `ConfigureDbContext` | `Action<DbContextOptionsBuilder>` | — | Configure the `PersistedGrantDbContext` |
| `DefaultSchema` | `string` | — | Default database schema for operational tables |
| `EnableTokenCleanup` | `bool` | `false` | Enable automatic cleanup of expired grants and pushed authorization requests |
| `RemoveConsumedTokens` | `bool` | `false` | Also remove consumed grants during cleanup (added >= 5.1) |
| `TokenCleanupInterval` | `int` | `3600` | Cleanup interval in seconds |
| `TokenCleanupBatchSize` | `int` | `100` | Number of expired tokens removed per cleanup cycle |
| `ConsumedTokenCleanupDelay` | `int` | `0` | Seconds to wait after consumption before cleaning up (added >= 6.3) |
| `FuzzTokenCleanupStart` | `bool` | `true` | Randomize first cleanup run to avoid multi-instance conflicts (added >= 7.0) |
### What Token Cleanup Removes
The `TokenCleanupService` removes:
- Persisted grants where `Expiration < UtcNow`
- Consumed tokens when `RemoveConsumedTokens = true` and `ConsumedTime + ConsumedTokenCleanupDelay < UtcNow`
- Expired device flow codes
- Expired pushed authorization requests
- Expired server-side sessions
It does **not** remove:
- Active (non-expired) refresh tokens that have been marked consumed — these are retained for threat detection unless `RemoveConsumedTokens = true`
### Grant Lifecycle States
| State | Meaning |
| ----------------------------------------------------- | --------------------------------- |
| Record exists, no `ConsumedTime`, within `Expiration` | Grant is valid |
| `ConsumedTime` is set | Grant has been used (soft delete) |
| Past `Expiration` | Grant is expired |
| Record deleted | Grant is revoked |
One-time-use grants (authorization codes, optionally refresh tokens) use the consumption mechanism instead of immediate deletion to enable replay detection and grace periods. The `Data` property of persisted grants is the authoritative payload — other properties like `Created` and `Expiration` are read-only indices. Modifying index properties directly in the database will not change runtime behavior.
### Multi-Node Cleanup Conflicts
When multiple nodes all run cleanup at the same interval, they race to delete the same rows. `FuzzTokenCleanupStart = true` (the default) randomises the first cleanup run within the interval window. For very high-scale deployments, consider disabling cleanup on all nodes and running it as a dedicated background job:
```csharp
// ✅ Disable cleanup on web nodes; run in a dedicated worker service
// In web node Program.cs:
options.EnableTokenCleanup = false;
// In a dedicated worker:
public sealed class TokenCleanupWorker : BackgroundService
{
private readonly TokenCleanupService _cleanup;
public TokenCleanupWorker(TokenCleanupService cleanup) => _cleanup = cleanup;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await _cleanup.CleanupGrantsAsync();
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
}
}
}
```
---
## Persisted Grant Service
For higher-level programmatic access to grants (e.g., building an admin UI or user consent management page), use `IPersistedGrantService` rather than querying `IPersistedGrantStore` directly:
```csharp
// ✅ Query and revoke grants via the high-level service
public sealed class GrantManagementService
{
private readonly IPersistedGrantService _grantService;
public GrantManagementService(IPersistedGrantService grantService)
=> _grantService = grantService;
public async Task<IEnumerable<Grant>> GetUserGrantsAsync(string subjectId)
=> await _grantService.GetAllGrantsAsync(subjectId);
public async Task RevokeClientGrantsAsync(string subjectId, string clientId)
=> await _grantService.RemoveAllGrantsAsync(subjectId, clientId);
}
```
This service abstracts and aggregates different grant types (authorization codes, refresh tokens, reference tokens, consent) into a unified API. It is the recommended way to implement user-facing grant/consent management rather than querying the low-level `IPersistedGrantStore`.
---
## Multi-Tenant Patterns
Multi-tenant IdentityServer deployments require careful consideration of store boundaries.
### Shared Database (Recommended for Most Cases)
A single `ConfigurationDbContext` and `PersistedGrantDbContext` shared across all tenants. Tenant isolation is enforced at the application layer by scoping queries to a `TenantId` column.
```csharp
// ✅ Shared database with tenant-scoped custom stores
public sealed class TenantAwareClientStore : IClientStore
{
private readonly AppDbContext _db;
private readonly ITenantContext _tenantContext;
public TenantAwareClientStore(AppDbContext db, ITenantContext tenantContext)
{
_db = db;
_tenantContext = tenantContext;
}
public async Task<Client?> FindClientByIdAsync(string clientId)
{
var entity = await _db.Clients
.Where(c => c.TenantId == _tenantContext.TenantId && c.ClientId == clientId)
.FirstOrDefaultAsync();
return entity?.ToIdentityServerClient();
}
}
```
### Database-per-Tenant
Each tenant gets its own connection string and EF `DbContext` instance. This provides the strongest data isolation, is appropriate for compliance requirements (GDPR data residency, SOC2 segmentation), and simplifies tenant offboarding.
```csharp
// ✅ Database-per-tenant using a factory pattern for the DbContext
builder.Services.AddIdentityServer()
.AddClientStore<TenantRoutingClientStore>();
public sealed class TenantRoutingClientStore : IClientStore
{
private readonly IDbContextFactory<ConfigurationDbContext> _factory;
private readonly ITenantConnectionStringProvider _connectionStrings;
private readonly ITenantContext _tenantContext;
public TenantRoutingClientStore(
IDbContextFactory<ConfigurationDbContext> factory,
ITenantConnectionStringProvider connectionStrings,
ITenantContext tenantContext)
{
_factory = factory;
_connectionStrings = connectionStrings;
_tenantContext = tenantContext;
}
public async Task<Client?> FindClientByIdAsync(string clientId)
{
var connStr = await _connectionStrings.GetAsync(_tenantContext.TenantId);
var options = new DbContextOptionsBuilder<ConfigurationDbContext>()
.UseSqlServer(connStr)
.Options;
await using var ctx = new ConfigurationDbContext(options, new ConfigurationStoreOptions());
var entity = await ctx.Clients
.Include(c => c.AllowedScopes)
.Include(c => c.RedirectUris)
.FirstOrDefaultAsync(c => c.ClientId == clientId);
return entity?.ToModel();
}
}
```
```csharp
// ❌ Avoid: sharing PersistedGrantDbContext across tenants without tenant isolation
// A token issued for tenant A can be looked up by tenant B's store — a security boundary violation
builder.Services.AddOperationalStore(options =>
{
options.ConfigureDbContext = b => b.UseSqlServer(sharedConnectionString);
// No tenant filtering applied — all tenants share the same grant store
});
```
---
## Store Implementation Decision Matrix
| Scenario | Recommendation |
| --------------------------------------------- | -------------------------------------------------- |
| Prototyping / local development | In-memory stores (`AddInMemory*`) |
| Small deployment, rare config changes | In-memory stores loaded from config files |
| Production with relational database | EF Core stores with `AddConfigurationStoreCache()` |
| High-traffic production | EF Core stores + caching + tuned cleanup intervals |
| Non-relational database (Redis, Cosmos, etc.) | Custom store implementations |
| SaaS with dynamic configuration | EF Core or custom stores with API for management |
---
## Common Pitfalls
**Missing `MigrationsAssembly`** — The most common EF setup error. When migrations live in the host project (not in `Duende.IdentityServer.EntityFramework`), you must call `sql.MigrationsAssembly(migrationsAssembly)`. Without this, `dotnet ef migrations add` and runtime startup fail.
**Calling `AddConfigurationStoreCache()` without `AddInMemoryCaching()`** — `AddConfigurationStoreCache()` wraps the EF stores automatically and includes its own `IMemoryCache` registration. `AddInMemoryCaching()` is needed when you are manually registering caching decorators on custom stores with `AddClientStoreCache<T>()`.
**In-memory caching in multi-node deployments** — The default `IMemoryCache`-backed cache is node-local. If you update a client configuration and one node caches the old value, token requests on that node will use the stale configuration until the cache expires. Use a distributed cache (`IDistributedCache`) to share cache state, or set a short expiration and accept eventual consistency.
**Not enabling server-side sessions before the operational store** — `AddServerSideSessions()` must be called before or alongside `AddOperationalStore()`. Reversing the order or omitting `AddServerSideSessions()` means session data is never persisted, and session management features silently degrade.
**Assuming `EnableTokenCleanup = true` removes consumed tokens** — By default, consumed tokens are not cleaned up. You must also set `RemoveConsumedTokens = true`. Consumed tokens from one-time-use refresh token flows will otherwise accumulate indefinitely.
**Rotating Data Protection keys without migrating encrypted grant data** — Signing keys and grant payloads encrypted with an old Data Protection key become unreadable after key rotation. Always keep retired Data Protection keys available for decryption for at least as long as the longest-lived grant (typically refresh token lifetime).
**Running EF migrations in multi-instance startup** — Calling `Database.Migrate()` in `Program.cs` on every startup causes migration races in multi-node deployments. Run migrations as a deployment pre-step (e.g., a Kubernetes init container or a CI/CD migration job), not in the application startup path.
**Using in-memory stores in production** — `AddInMemoryClients()`, `AddInMemoryApiResources()`, etc. are designed for development and testing only. In-memory stores cannot be updated at runtime without restarting the application and do not survive restarts.
---
## Resources
- [Data Stores & Persistence overview](https://docs.duendesoftware.com/identityserver/data/) — authoritative top-level docs
- [Configuration Data](https://docs.duendesoftware.com/identityserver/data/configuration/) — store interfaces, custom registration, caching, in-memory stores
- [Operational Data](https://docs.duendesoftware.com/identityserver/data/operational/) — grants, signing keys, server-side sessions, custom store registration
- [EF Core Integration](https://docs.duendesoftware.com/identityserver/data/ef/) — `AddConfigurationStore`, `AddOperationalStore`, `OperationalStoreOptions`, schema options, token cleanup options
- [EF Quickstart](https://docs.duendesoftware.com/identityserver/quickstarts/4-entity-framework/) — end-to-end walkthrough including migration creation
- [ISigningKeyStore reference](https://docs.duendesoftware.com/identityserver/reference/stores/signing-key-store/)
- [IServerSideSessionStore reference](https://docs.duendesoftware.com/identityserver/reference/stores/server-side-sessions/)
- [IPersistedGrantStore reference](https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/)
- [Key Management fundamentals](https://docs.duendesoftware.com/identityserver/fundamentals/key-management/)
- [Server-Side Sessions overview](https://docs.duendesoftware.com/identityserver/ui/server-side-sessions/)
- [Duende EF migrations sample](https://github.com/DuendeSoftware/products/tree/main/identity-server/migrations/IdentityServerDb) — reference SQL Server migration project maintained by Duende
- Related skill: `identityserver-configuration` — client and resource model configuration
- Related skill: `efcore-patterns` — EF Core best practices applicable to `ConfigurationDbContext` and `PersistedGrantDbContext`
- Related skill: `database-performance` — indexing, query optimization for high-write operational tables
identityserver-token-lifecycle20.2 KB
---
name: identityserver-token-lifecycle
description: "Guide for implementing token types, refresh token management, token exchange (RFC 8693), extension grants, IProfileService claims customization, and token lifetime best practices in Duende IdentityServer."
invocable: false
---
# IdentityServer Token Types, Refresh Tokens, and Token Exchange
## When to Use This Skill
- Choosing between JWT and reference access tokens for a client
- Configuring refresh token rotation, sliding expiration, or replay detection
- Implementing token exchange (RFC 8693) for impersonation or delegation
- Building an extension grant validator (`IExtensionGrantValidator`)
- Customizing which claims appear in identity tokens, access tokens, or userinfo responses via `IProfileService`
- Setting token lifetime policies for access tokens and refresh tokens
- Issuing internal tokens from extensibility code via `IIdentityServerTools`
- Understanding identity tokens vs access tokens and their intended audiences
Docs: https://docs.duendesoftware.com/identityserver/tokens
## Token Types Overview
Duende IdentityServer issues three primary token types:
| Token Type | Purpose | Audience | Format |
| -------------- | -------------------------------------------------- | ------------------------------------- | ---------------- |
| Identity Token | Communicates authentication event to the client | Client application only (`aud` claim) | Always JWT |
| Access Token | Authorizes access to a protected resource (API) | API / Resource Server | JWT or Reference |
| Refresh Token | Obtains new access tokens without user interaction | Token endpoint only | Opaque handle |
### Key Principles
- Identity tokens are **solely for the client application** that initiated the authentication. Never send an identity token to an API.
- Access tokens are for APIs. They contain client ID, scopes, expiration, and optionally user claims.
- Refresh tokens enable long-lived API access by allowing the client to request new access tokens silently.
## Identity Tokens
Identity tokens are JWTs that describe "what happened at the token service". They contain:
- `iss` — the issuer (your IdentityServer URL)
- `sub` — the authenticated user's unique identifier
- `aud` — the client that requested authentication
- `auth_time` — when the user authenticated
- `amr` — authentication method (e.g., `pwd`)
- `idp` — identity provider used (e.g., `local`)
- `sid` — the session ID
- `nonce` — ensures the token is consumed only once at the client
```json
{
"iss": "https://localhost:5001",
"nbf": 1609932802,
"iat": 1609932802,
"exp": 1609933102,
"aud": "web_app",
"amr": ["pwd"],
"nonce": "63745529591...I3ZTIyOTZmZTNj",
"sid": "F6E6F2EDE86EB8731EF609A4FE40ED89",
"auth_time": 1609932794,
"idp": "local",
"sub": "88421113",
"name": "Bob"
}
```
## Access Tokens: JWT vs Reference
### JWT Access Tokens
All claims are embedded in the token. The API validates the token by checking the signature using the issuer's public keys (from the JWKS endpoint). JWTs **cannot be revoked** before their expiration — the only invalidation mechanism is waiting for `exp`.
```json
{
"iss": "https://localhost:5001",
"exp": 1609936401,
"aud": "urn:resource1",
"scope": "openid resource1.scope1 offline_access",
"client_id": "web_app",
"sub": "88421113",
"jti": "2C56A356A306E64AFC7D2C6399E23A17"
}
```
### Reference Access Tokens
Reference tokens are **pointers** to token data stored in the persisted grant store. The API must call the **introspection endpoint** to validate the token. Reference tokens support **immediate revocation** by deleting the stored data.
```csharp
// Configure a client to use reference tokens
client.AccessTokenType = AccessTokenType.Reference;
```
The API consuming reference tokens must have a secret configured on the `ApiResource`:
```csharp
var api = new ApiResource("api1")
{
ApiSecrets = { new Secret("secret".Sha256()) },
Scopes = { "read", "write" }
};
```
### Decision Matrix: JWT vs Reference Tokens
| Criterion | JWT | Reference |
| -------------------- | ----------------------------------- | ------------------------------------ |
| Revocability | No (expires naturally) | Yes (immediate, delete from store) |
| API call to validate | No (self-contained) | Yes (introspection endpoint) |
| Network dependency | None at validation time | Requires IdentityServer availability |
| Token size | Larger (contains all claims) | Small (just a handle) |
| Performance at scale | Better (no server call) | Introspection adds latency |
| Best for | High-throughput APIs, microservices | Sensitive APIs needing revocation |
### Token Revocation (RFC 7009)
Revocation via the `/connect/revocation` endpoint applies **only to reference access tokens and refresh tokens** — the tokens that are persisted in the operational (persisted grant) store. A JWT access token is stateless and is **not** stored server-side by default, so there is no revocation state to deactivate: a JWT simply remains valid until its `exp`. If you need to invalidate access tokens immediately (logout, compromise, entitlement change), issue **reference** tokens (`AccessTokenType.Reference`).
### Controlling Token Format Per Client
```csharp
// Set on the Client model
client.AccessTokenType = AccessTokenType.Jwt; // default
client.AccessTokenType = AccessTokenType.Reference; // reference tokens
```
## Refresh Tokens
Refresh tokens allow clients to obtain new access tokens without user interaction. They are supported for authorization code, hybrid, and resource owner password credential flows.
### Requesting Refresh Tokens
The client must:
1. Have `AllowOfflineAccess = true` on its configuration
2. Request the `offline_access` scope in the authorize request
```
POST /connect/token
Content-Type: application/x-www-form-urlencoded
client_id=client&
client_secret=secret&
grant_type=refresh_token&
refresh_token=hdh922
```
Using Duende.IdentityModel:
```csharp
using Duende.IdentityModel.Client;
var client = new HttpClient();
var response = await client.RequestRefreshTokenAsync(new RefreshTokenRequest
{
Address = TokenEndpoint,
ClientId = "client",
ClientSecret = "secret",
RefreshToken = "..."
});
```
### Refresh Token Lifetime Settings
| Setting | Description | Recommendation |
| ------------------------------ | -------------------------------------------------------- | ----------------------------------------------------- |
| `AbsoluteRefreshTokenLifetime` | Maximum lifetime regardless of activity (seconds) | Set based on security policy (e.g., 30 days) |
| `SlidingRefreshTokenLifetime` | Extends token life on each use, up to the absolute limit | Use for "remember me" scenarios (e.g., 1 day sliding) |
| `RefreshTokenExpiration` | `Absolute` or `Sliding` | Use `Sliding` with a reasonable absolute cap |
### Rotation (OneTime vs ReUse)
Configured via `RefreshTokenUsage` on the client:
| Mode | Behavior | Trade-offs |
| ---------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------- |
| `ReUse` (default since v7.0) | Same refresh token is reused across requests | Robust to network failures, lower DB pressure |
| `OneTime` | New refresh token issued on each use; old one consumed | Limited security benefit, risk of losing token on network failure |
**Why `ReUse` is the default**: Rotating tokens on every use has limited security benefits regardless of client type. Reusable tokens are robust to network failures — if a one-time-use token is used but the response is lost, the client cannot recover without forcing a new login. Reusable tokens also have better performance since they avoid extra writes to the persisted grant store.
### Accepting Consumed Tokens (Network Failure Resilience)
To make one-time-use tokens more resilient, subclass `DefaultRefreshTokenService` and override `AcceptConsumedTokenAsync`:
```csharp
public class ResilientRefreshTokenService : DefaultRefreshTokenService
{
protected override Task<bool> AcceptConsumedTokenAsync(RefreshToken refreshToken)
{
// Allow consumed tokens for a short grace period
var consumedAt = refreshToken.ConsumedTime ?? DateTime.UtcNow;
if (DateTime.UtcNow - consumedAt < TimeSpan.FromSeconds(30))
{
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
```
Register it:
```csharp
builder.Services.TryAddTransient<IRefreshTokenService, ResilientRefreshTokenService>();
```
**Important**: For this to work, `PersistentGrantOptions.DeleteOneTimeOnlyRefreshTokensOnUse` must be `false` so consumed tokens are marked rather than deleted.
### Replay Detection
If a consumed refresh token is reused, it could indicate a replay attack. You can extend `AcceptConsumedTokenAsync` to revoke all access for the user/client:
- Delete all refresh tokens for the user/client
- Revoke reference access tokens
- End the user's server-side session
- Send back-channel logout notifications
- Alert the user
**Caution**: This is disruptive and can produce false positives from network failures or client bugs.
### Token Cleanup Configuration
```csharp
builder.Services.AddIdentityServer()
.AddOperationalStore(options =>
{
options.EnableTokenCleanup = true;
options.TokenCleanupInterval = 3600; // seconds (default: 1 hour)
options.RemoveConsumedTokens = true; // also clean consumed tokens
options.ConsumedTokenCleanupDelay = 300; // wait 5 min after consumption
});
```
## Token Exchange (RFC 8693)
Token exchange allows translating between token types. Common use cases: impersonation, delegation, SAML-to-JWT conversion.
### Implementing Token Exchange
Implement `IExtensionGrantValidator`:
```csharp
public class TokenExchangeGrantValidator : IExtensionGrantValidator
{
private readonly ITokenValidator _validator;
public TokenExchangeGrantValidator(ITokenValidator validator)
{
_validator = validator;
}
public string GrantType => OidcConstants.GrantTypes.TokenExchange;
public async Task ValidateAsync(ExtensionGrantValidationContext context)
{
context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest);
var customResponse = new Dictionary<string, object>
{
{ OidcConstants.TokenResponse.IssuedTokenType, OidcConstants.TokenTypeIdentifiers.AccessToken }
};
var subjectToken = context.Request.Raw.Get(OidcConstants.TokenRequest.SubjectToken);
var subjectTokenType = context.Request.Raw.Get(OidcConstants.TokenRequest.SubjectTokenType);
if (string.IsNullOrWhiteSpace(subjectToken)) return;
if (!string.Equals(subjectTokenType, OidcConstants.TokenTypeIdentifiers.AccessToken)) return;
var validationResult = await _validator.ValidateAccessTokenAsync(subjectToken);
if (validationResult.IsError) return;
var sub = validationResult.Claims.First(c => c.Type == JwtClaimTypes.Subject).Value;
var clientId = validationResult.Claims.First(c => c.Type == JwtClaimTypes.ClientId).Value;
// Impersonation: set client_id to the original
context.Request.ClientId = clientId;
context.Result = new GrantValidationResult(
subject: sub,
authenticationMethod: GrantType,
customResponse: customResponse);
}
}
```
Register and configure:
```csharp
// Program.cs
idsvrBuilder.AddExtensionGrantValidator<TokenExchangeGrantValidator>();
// Client configuration
client.AllowedGrantTypes = { OidcConstants.GrantTypes.TokenExchange };
```
### Impersonation vs Delegation
| Pattern | `client_id` in new token | `act` claim | Use case |
| ------------- | ------------------------- | ---------------------------------- | --------------------------------------------- |
| Impersonation | Original front-end client | Not present | API1 calls API2 "as if" it were the front-end |
| Delegation | Original front-end client | Contains `{ "client_id": "api1" }` | API2 sees the full call chain |
**Delegation** adds an `act` claim to preserve the call chain:
```csharp
context.Request.ClientId = clientId;
var actor = new { client_id = context.Request.Client.ClientId };
var actClaim = new Claim(JwtClaimTypes.Actor,
JsonSerializer.Serialize(actor),
IdentityServerConstants.ClaimValueTypes.Json);
context.Result = new GrantValidationResult(
subject: sub,
authenticationMethod: GrantType,
claims: new[] { actClaim },
customResponse: customResponse);
```
To emit the `act` claim in tokens, your profile service must handle it:
```csharp
public class ProfileService : IProfileService
{
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
if (context.Subject.GetAuthenticationMethod() == OidcConstants.GrantTypes.TokenExchange)
{
var act = context.Subject.FindFirst(JwtClaimTypes.Actor);
if (act != null)
{
context.IssuedClaims.Add(act);
}
}
}
}
```
### Sensitive Parameter Filtering
Extension grant input parameters are logged by default. Filter sensitive values:
```csharp
builder.Services.AddIdentityServer(options =>
{
options.Logging.TokenRequestSensitiveValuesFilter.Add("custom_secret_param");
});
```
## Claims Customization with IProfileService
The profile service controls which claims are emitted in identity tokens, access tokens, and userinfo responses.
### Strategies
| Strategy | When to use |
| --------------------------------------- | --------------------------------------------------------------- |
| `context.AddRequestedClaims(claims)` | Respects scopes/resources requested by client; supports consent |
| `context.IssuedClaims.AddRange(claims)` | Always emit claims regardless of request |
| Custom logic per user/client | Conditional claims based on identity |
### Recommended Pattern
Extend `DefaultProfileService` and use `AddRequestedClaims`:
```csharp
public class SampleProfileService : DefaultProfileService
{
public override async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var claims = await GetClaimsFromDatabaseAsync(context.Subject);
context.AddRequestedClaims(claims);
}
}
```
### Client Claims
Client claims are defined per-client and emitted in access tokens (prefixed with `client_` by default):
```csharp
var client = new Client
{
ClientId = "client",
Claims = { new ClientClaim("customer_id", "123") }
// Emitted as "client_customer_id" in access tokens
};
```
Change or remove the prefix:
```csharp
client.ClientClaimsPrefix = ""; // no prefix
```
By default, client claims are only sent in client credentials flow. To include them in all flows:
```csharp
client.AlwaysSendClientClaims = true;
```
### Claim Serialization
Claims are serialized based on `ClaimValueType`:
- No type specified → string
- `ClaimValueTypes.Integer`, `Integer32`, `Integer64`, `Double`, `Boolean` → parsed as corresponding type
- `IdentityServerConstants.ClaimValueTypes.Json` → serialized as JSON
## Issuing Internal Tokens
When extensibility code needs to call other APIs, use `IIdentityServerTools` instead of the protocol endpoints:
```csharp
app.MapGet("/myAction", async (IIdentityServerTools tools) =>
{
var token = await tools.IssueClientJwtAsync(
clientId: "client_id",
lifetime: 3600,
audiences: new[] { "backend.api" });
// Use token to call backend API
});
```
## Dynamic Issuer (Multi-Issuer)
By **default**, a single IdentityServer derives the `iss` claim (and the discovery issuer) from the origin of the incoming request. The same deployment can therefore serve multiple hosts/domains and return a different `iss` for each — no extra configuration required.
```csharp
// Requests to https://a.example.com → iss = "https://a.example.com"
// Requests to https://b.example.com → iss = "https://b.example.com"
```
Setting a fixed issuer **disables** this behavior — every token then carries the configured value regardless of host:
```csharp
builder.Services.AddIdentityServer(options =>
{
// Pins iss to a single value; multi-issuer is turned off
options.IssuerUri = "https://identity.example.com";
});
```
> **Multi-issuer is not multi-tenancy.** Returning a per-host `iss` (RFC 7519 §4.1.1) does not isolate users, grants, keys, or any other data per domain. Tenant isolation remains the implementer's responsibility.
## Token Lifetime Best Practices
| Token | Recommended Lifetime | Rationale |
| ------------------------ | ----------------------------------------- | ------------------------------------------------ |
| Identity Token | 5 minutes (default: 300s) | Only used once during authentication |
| JWT Access Token | 5-15 minutes (default: 3600s = 1 hour) | Short-lived; cannot be revoked |
| Reference Access Token | 5-60 minutes | Can be revoked, so slightly longer is acceptable |
| Refresh Token (absolute) | Hours to days depending on security needs | Balance UX vs risk |
| Refresh Token (sliding) | Shorter than absolute (e.g., 1 hour) | Auto-expire unused tokens |
## Common Anti-Patterns
- ❌ Sending identity tokens to APIs for authorization — they are for the client only
- ✅ Use access tokens (JWT or reference) for API authorization
- ❌ Using very long-lived JWT access tokens (hours/days) with no revocation mechanism
- ✅ Keep JWT lifetimes short (5-15 min) and use refresh tokens for longevity
- ❌ Enabling `OneTime` refresh token rotation without considering network failure scenarios
- ✅ Use `ReUse` (default) or implement `AcceptConsumedTokenAsync` with a grace period
- ❌ Putting all user claims directly into access tokens, creating bloated JWTs
- ✅ Use `AddRequestedClaims` to emit only claims requested by scopes; use the userinfo endpoint for additional claims
- ❌ Parsing the `returnUrl` manually instead of using `GetAuthorizationContextAsync`
- ✅ Always use the interaction service to extract authorization context
- ❌ Forgetting to set `AllowOfflineAccess = true` on the client and then wondering why no refresh token is issued
- ✅ Configure both the client property and request the `offline_access` scope
## Common Pitfalls
1. **Reference tokens require introspection**: APIs consuming reference tokens must call the introspection endpoint. Without a configured `ApiSecret` on the `ApiResource`, introspection will fail with `401`.
2. **Refresh token cleanup**: Enable `EnableTokenCleanup` in the operational store options. Without it, expired and consumed tokens accumulate indefinitely.
3. **Token exchange client configuration**: The client performing token exchange must have `AllowedGrantTypes` set to `urn:ietf:params:oauth:grant-type:token-exchange` (use `OidcConstants.GrantTypes.TokenExchange`).
4. **Profile service `Subject` differs by caller**: When called for userinfo requests, the `Subject` property contains claims from the access token, not the authentication session. Check `context.Caller` to determine the source.
5. **Client claims prefix collision**: Client claims are prefixed with `client_` by default. Adjust `ClientClaimsPrefix` if this collides with existing user claim types.
identityserver-token-security27.4 KB
---
name: identityserver-token-security
description: Advanced token security features in Duende IdentityServer including DPoP, mTLS certificate binding, Pushed Authorization Requests (PAR), JWT Secured Authorization Requests (JAR), and FAPI 2.0 compliance configuration.
invocable: false
---
# Advanced Token Security (DPoP, mTLS, PAR, JAR, FAPI)
## When to Use This Skill
- Implementing Proof-of-Possession (PoP) tokens with DPoP or mTLS
- Configuring Pushed Authorization Requests (PAR) for front-channel parameter security
- Setting up JWT Secured Authorization Requests (JAR) for tamperproof authorize requests
- Building FAPI 2.0 compliant authorization servers
- Choosing between DPoP and mTLS for sender-constrained tokens
- Configuring APIs to validate proof-of-possession tokens
- Meeting regulatory or industry security requirements (open banking, e-health, e-government)
Docs: https://docs.duendesoftware.com/identityserver/tokens/
## Proof-of-Possession Tokens: Why They Matter
Default OAuth access tokens are **bearer tokens** -- anyone who possesses the token can use it. If a token leaks, a malicious third party can impersonate the client/user.
**Proof-of-Possession (PoP) tokens** are cryptographically bound to the client that requested them via the `cnf` (confirmation) claim:
```json
{
"iss": "https://identity.example.com",
"aud": "urn:api",
"client_id": "web_app",
"sub": "88421113",
"cnf": "confirmation_method"
}
```
When using reference tokens, the `cnf` claim is returned from the introspection endpoint.
## DPoP vs mTLS: Decision Matrix
| Factor | DPoP | mTLS |
| ------------------------- | ---------------------------------- | -------------------------------------------------- |
| **Edition required** | Enterprise | All editions (binding); Enterprise (some features) |
| **Minimum version** | 6.3 | All versions |
| **Key management** | Application-layer JWK (dynamic) | X.509 certificate (TLS layer) |
| **Infrastructure** | No TLS changes needed | Requires TLS client certificate infrastructure |
| **Deployment complexity** | Lower | Higher (certificate distribution, renewal) |
| **Protocol layer** | HTTP headers (`DPoP` header) | TLS channel |
| **Public clients** | Supported (mobile/SPA) | Harder for public clients |
| **FAPI 2.0** | Accepted | Accepted |
| **Replay protection** | Nonce mechanism + `iat` validation | TLS channel binding |
| **Recommendation** | Start here for most use cases | When TLS infrastructure already exists |
## Mutual TLS (mTLS)
### How It Works
IdentityServer embeds the SHA-256 thumbprint of the client's X.509 certificate into the access token via the `cnf` claim:
```json
{
"cnf": { "x5t#S256": "bwcK0esc3ACC3DB2Y5_lESsXE8o9ltc05O89jdN-dg2" }
}
```
The client must use the same certificate when calling APIs. APIs validate the `cnf` claim against the TLS client certificate thumbprint.
### mTLS for Client Authentication
Configure IdentityServer to accept client certificates:
```csharp
// Program.cs
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.MutualTls.Enabled = true;
options.MutualTls.DomainName = "mtls"; // mTLS endpoints on mtls subdomain
options.MutualTls.ClientCertificateAuthenticationScheme = "Certificate";
});
idsvrBuilder.AddMutualTlsSecretValidators();
builder.Services.AddAuthentication()
.AddCertificate("Certificate", options =>
{
options.AllowedCertificateTypes = CertificateTypes.SelfSigned;
options.ValidateCertificateUse = true;
});
```
Configure the client with certificate-based secrets:
```csharp
new Client
{
ClientId = "mtls.client",
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "api1" },
ClientSecrets =
{
// PKI-based (by distinguished name)
new Secret(@"CN=client, OU=production, O=company", "client.dn")
{
Type = SecretTypes.X509CertificateName
},
// Self-issued (by thumbprint)
new Secret("bca0d040847f843c5ee0fa6eb494837470155868", "mtls.tb")
{
Type = SecretTypes.X509CertificateThumbprint
}
}
}
```
Use `SecretTypes.X509CertificateName` for PKI/chained certificates (matched by distinguished name) and `SecretTypes.X509CertificateThumbprint` for self-issued certificates (matched by thumbprint).
### mTLS Endpoint URL Strategies
`options.MutualTls.DomainName` controls where the mTLS-protected endpoints live:
| `DomainName` value | Endpoint layout | Example |
| ------------------ | --------------------------- | ---------------------------------------- |
| `null` / empty | Path-based on the main host | `https://host/connect/mtls/token` |
| `"mtls"` | Sub-domain | `https://mtls.host/connect/token` |
| full domain | Separate dedicated domain | `https://mtls.example.com/connect/token` |
The mTLS endpoint URLs are published in discovery under `mtls_endpoint_aliases`. Clients must read them from there rather than the standard endpoints:
```csharp
var tokenEndpoint = disco.MtlsEndpointAliases?.TokenEndpoint;
```
### mTLS Deployment: Kestrel (dev) vs Reverse Proxy (production)
**Development — Kestrel terminates TLS directly.** Use `mkcert` to create a locally-trusted CA (the private key stays on your machine, unlike shared sample certificates that ship public private keys). Accept — but don't require — client certificates; IdentityServer's mTLS middleware enforces them on the mTLS endpoint:
```csharp
builder.WebHost.ConfigureKestrel(kestrel =>
{
kestrel.ConfigureHttpsDefaults(https =>
{
// Accept but do not require; the mTLS endpoint enforces the certificate
https.ClientCertificateMode = ClientCertificateMode.AllowCertificate;
});
});
```
> On .NET 10, Kestrel auto-resolves `*.localhost` sub-domains, so the `mtls.localhost` sub-domain strategy works locally with no hosts-file entry.
**Production — a reverse proxy terminates TLS** and forwards the client certificate to Kestrel via a request header. Kestrel itself does not negotiate the certificate (`ClientCertificateMode.NoCertificate`); use certificate forwarding:
```csharp
// Kestrel: the proxy terminates TLS, so Kestrel never asks for a certificate
builder.WebHost.ConfigureKestrel(k =>
k.ConfigureHttpsDefaults(h => h.ClientCertificateMode = ClientCertificateMode.NoCertificate));
builder.Services.AddCertificateForwarding(options =>
{
options.CertificateHeader = "X-SSL-CERT"; // match your proxy
options.HeaderConverter = headerValue =>
{
if (string.IsNullOrWhiteSpace(headerValue)) return null!;
// e.g. Nginx sends URL-encoded PEM; IIS sends base64 DER — decode accordingly
var pem = Uri.UnescapeDataString(headerValue);
return X509Certificate2.CreateFromPem(pem);
};
});
builder.Services.AddAuthentication()
.AddCertificate("Certificate", options =>
{
// Production PKI certificates are chained, not self-signed
options.AllowedCertificateTypes = CertificateTypes.Chained;
});
// ...
app.UseCertificateForwarding(); // MUST come before UseAuthentication()
app.UseAuthentication();
app.UseAuthorization();
```
Proxy header conventions:
| Proxy | Header | Value format |
| ------ | ------------------ | -------------------------------------------- |
| IIS | `X-ARR-ClientCert` | base64 DER |
| Nginx | `X-SSL-CERT` | `$ssl_client_escaped_cert` (URL-encoded PEM) |
| Apache | `X-SSL-CERT` | `%{SSL_CLIENT_CERT}s` (PEM) |
> **Security:** the proxy must strip or overwrite the certificate header on all inbound requests so a client cannot spoof a certificate by sending the header directly.
### mTLS without Client Authentication
You can bind tokens to a client certificate without using the certificate for client authentication. This works with any authentication method, including public clients:
```csharp
// Program.cs
var idsvrBuilder = builder.Services.AddIdentityServer(options =>
{
options.MutualTls.AlwaysEmitConfirmationClaim = true;
});
```
The client creates a certificate on the fly and uses it to establish the TLS channel:
```csharp
static X509Certificate2 CreateClientCertificate(string name)
{
X500DistinguishedName distinguishedName = new X500DistinguishedName($"CN={name}");
using (RSA rsa = RSA.Create(2048))
{
var request = new CertificateRequest(distinguishedName, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
request.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.DataEncipherment |
X509KeyUsageFlags.KeyEncipherment |
X509KeyUsageFlags.DigitalSignature, false));
request.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection { new Oid("1.3.6.1.5.5.7.3.2") }, false));
return request.CreateSelfSigned(
new DateTimeOffset(DateTime.UtcNow.AddDays(-1)),
new DateTimeOffset(DateTime.UtcNow.AddDays(10)));
}
}
```
### .NET Client Requesting mTLS Token
```csharp
static async Task<TokenResponse> RequestTokenAsync()
{
var handler = new SocketsHttpHandler();
var cert = new X509Certificate2("client.p12", "password");
handler.SslOptions.ClientCertificates = new X509CertificateCollection { cert };
var client = new HttpClient(handler);
var disco = await client.GetDiscoveryDocumentAsync(Constants.Authority);
if (disco.IsError) throw new Exception(disco.Error);
var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest
{
Address = disco.MtlsEndpointAliases.TokenEndpoint,
ClientCredentialStyle = ClientCredentialStyle.PostBody,
ClientId = "mtls.client",
Scope = "api1"
});
if (response.IsError) throw new Exception(response.Error);
return response;
}
```
### Validating mTLS in APIs
Add custom middleware to compare the `cnf` claim against the TLS client certificate:
```csharp
// API middleware pipeline
app.UseAuthentication();
app.UseConfirmationValidation(); // custom middleware
app.UseAuthorization();
```
The middleware validates the `x5t#S256` value in the `cnf` claim against the SHA-256 thumbprint of the client certificate on the TLS channel.
## DPoP (Demonstrating Proof-of-Possession at the Application Layer)
**Version:** >= 6.3 (Enterprise Edition)
DPoP binds an asymmetric key (stored as a JWK) to an access token via the `cnf` claim:
```json
{
"cnf": {
"jkt": "JGSVlE73oKtQQI1dypYg8_JNat0xJjsQNyOI5oxaZf4"
}
}
```
The client proves possession of the private key by sending a signed JWT (proof token) via the `DPoP` HTTP header on every request.
### Enabling DPoP in IdentityServer
DPoP can be used dynamically with no server configuration, or enforced per-client:
```csharp
new Client
{
ClientId = "dpop_client",
RequireDPoP = true,
// Optional: control DPoP proof token expiration validation
// DPoPValidationMode = DPoPTokenExpirationValidationMode.Iat (default)
// DPoPClockSkew = TimeSpan.FromMinutes(5) (default)
}
```
### Client-Side DPoP Configuration
Use `Duende.AccessTokenManagement` for automatic DPoP proof token handling.
**Client credentials flow:**
```csharp
// Program.cs
builder.Services.AddClientCredentialsTokenManagement()
.AddClient("demo_dpop_client", client =>
{
client.TokenEndpoint = "https://identity.example.com/connect/token";
client.DPoPJsonWebKey = "..."; // JWK string
});
```
**Authorization code flow:**
```csharp
// Program.cs
builder.Services.AddAuthentication(...)
.AddCookie("cookie", ...)
.AddOpenIdConnect("oidc", ...);
builder.Services.AddOpenIdConnectAccessTokenManagement(options =>
{
options.DPoPJsonWebKey = "..."; // JWK string
});
```
### Creating a DPoP JWK
```csharp
var rsaKey = new RsaSecurityKey(RSA.Create(2048));
var jsonWebKey = JsonWebKeyConverter.ConvertFromSecurityKey(rsaKey);
jsonWebKey.Alg = "PS256";
string jwk = JsonSerializer.Serialize(jsonWebKey);
```
The `DPoPJsonWebKey` is a critical secret. If lost, tokens bound to it cannot be used. If leaked, the security benefits of DPoP are lost.
### DPoP Client Settings Reference
| Property | Default | Description |
| -------------------- | --------------------------------------- | ----------------------------------------------- |
| `RequireDPoP` | `false` | Require DPoP for this client |
| `DPoPValidationMode` | `DPoPTokenExpirationValidationMode.Iat` | Validate via client `iat` and/or server `nonce` |
| `DPoPClockSkew` | 5 minutes | Clock skew for `iat` claim validation |
### Validating DPoP in APIs
Install the DPoP validation package:
```bash
dotnet add package Duende.AspNetCore.Authentication.JwtBearer
```
Configure JWT bearer with DPoP:
```csharp
// API Program.cs
builder.Services.AddAuthentication("token")
.AddJwtBearer("token", options =>
{
options.Authority = Constants.Authority;
options.TokenValidationParameters.ValidateAudience = false;
options.MapInboundClaims = false;
options.TokenValidationParameters.ValidTypes = new[] { "at+jwt" };
});
// Extend with DPoP processing and validation
builder.Services.ConfigureDPoPTokensForScheme("token");
```
DPoP validation requires a distributed cache for replay detection:
```csharp
// Use any IDistributedCache implementation (Redis, CosmosDB, SQL Server, etc.)
builder.Services.AddDistributedMemoryCache(); // in-memory for development only
```
### DPoP Validation Steps (handled by the library)
1. Validate the access token as normal
2. Validate the DPoP proof token from the `DPoP` HTTP request header
3. Ensure the authorization header uses the `DPoP` scheme
4. Validate the JWT format of the proof token
5. Verify the `cnf` claim matches between tokens
6. Validate the HTTP method and URL match the request
7. Detect replay attacks using distributed cache storage
8. Manage nonce generation and validation
9. Handle clock skew between systems
10. Return appropriate error response headers when validation fails
## Pushed Authorization Requests (PAR)
**Version:** >= 7.0 (Business and Enterprise Edition)
PAR moves authorization parameters from the front channel (browser redirect URLs) to the back channel (direct HTTP POST), preventing parameter leakage and tampering.
### Why PAR
- Prevents exposure of authorization parameters (PII in scopes, claims)
- Prevents tampering with parameters (attacker changing scope)
- Keeps request URLs short (avoids browser/infrastructure URL length limits)
- Required by FAPI 2.0 Security Profile
### Server Configuration
```csharp
// Program.cs
builder.Services.AddIdentityServer(options =>
{
// Require PAR globally
options.PushedAuthorization.Required = false; // default
// Lifetime of pushed authorization requests in seconds (default: 600 = 10 minutes)
options.PushedAuthorization.Lifetime = 600; // seconds (int, not TimeSpan)
// Allow redirect URIs not pre-registered (default: false)
options.PushedAuthorization.AllowUnregisteredPushedRedirectUris = false;
});
```
### Per-Client Configuration
```csharp
new Client
{
ClientId = "par_client",
RequirePushedAuthorization = true, // require PAR for this client
PushedAuthorizationLifetime = 600 // 10 minutes, overrides global
}
```
### Client Usage (.NET 9+)
```csharp
// Program.cs
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, oidcOptions =>
{
// PushedAuthorizationBehavior.UseIfAvailable is the default in .NET 9+
// To require PAR:
oidcOptions.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require;
});
```
### Disabling PAR (Starter Edition)
PAR requests are not processed in the Starter edition. Disable the endpoint to reflect this in discovery:
```csharp
// Program.cs
builder.Services.AddIdentityServer(options =>
{
options.Endpoints.EnablePushedAuthorizationEndpoint = false;
});
```
### PAR Configuration Reference
| Property | Default | Description |
| --------------------------------------------------------- | ---------- | -------------------------------- |
| `PushedAuthorization.Required` | `false` | Require PAR globally |
| `PushedAuthorization.Lifetime` | `600` (seconds, `int`) | PAR request lifetime |
| `PushedAuthorization.AllowUnregisteredPushedRedirectUris` | `false` | Allow unregistered redirect URIs |
| `Client.RequirePushedAuthorization` | `false` | Per-client PAR requirement |
| `Client.PushedAuthorizationLifetime` | `null` | Per-client lifetime override |
| `Endpoints.EnablePushedAuthorizationEndpoint` | `true` | Enable/disable PAR endpoint |
## JWT Secured Authorization Requests (JAR)
JAR packages authorization request parameters in a signed JWT, making them tamperproof and enabling front-channel client authentication.
### Server Configuration
Configure the client to require signed request objects:
```csharp
var client = new Client
{
ClientId = "foo",
RequireRequestObject = true,
ClientSecrets =
{
new Secret
{
// X509 cert base64-encoded
Type = IdentityServerConstants.SecretTypes.X509CertificateBase64,
Value = Convert.ToBase64String(cert.Export(X509ContentType.Cert))
},
new Secret
{
// RSA key as JWK
Type = IdentityServerConstants.SecretTypes.JsonWebKey,
Value = "{'e':'AQAB','kid':'...','kty':'RSA','n':'...'}"
}
}
};
```
The same key can be shared between client authentication (private_key_jwt) and signed authorize requests.
### Request JWTs by Reference
If using `request_uri`, IdentityServer fetches the JWT from the specified URL:
```csharp
// Program.cs
idsvrBuilder.AddJwtRequestUriHttpClient(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddTransientHttpErrorPolicy(policy => policy.WaitAndRetryAsync(new[]
{
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(3)
}));
```
Request URI processing is disabled by default. Enable it on the `Endpoints` options.
### Accessing Request Object Data
- In `ValidatedAuthorizeRequest`: use the `RequestObjectValues` dictionary
- In UI code: call `IIdentityServerInteractionService.GetAuthorizationContextAsync`, then access `RequestObjectValues` on the returned `AuthorizationRequest`
## FAPI 2.0 Compliance
**Version:** >= 7.3 (Enterprise Edition)
The FAPI 2.0 Security Profile is a set of OAuth security best practices for high-value scenarios (open banking, e-health, e-government).
### FAPI 2.0 Authorization Server Requirements Checklist
| Requirement | IdentityServer Status | Configuration Needed |
| ------------------------------------------------ | -------------------------------------- | --------------------------------------------------- |
| Distribute discovery metadata | Default behavior | None |
| Reject resource owner password grant | Default behavior (when not configured) | None |
| Only support confidential clients | Configure per-client | Set `RequireClientSecret = true` |
| Only issue sender-constrained tokens | Configure | Enable DPoP or mTLS |
| Authenticate via mTLS or `private_key_jwt` | Configure | Set client secrets accordingly |
| No open redirectors | Default behavior | None |
| Accept only issuer as `aud` in client assertions | Enable strict validation | `StrictClientAssertionAudienceValidation` |
| No refresh token rotation (except extraordinary) | Configure | Set `RefreshTokenUsage = ReUse` |
| DPoP server-provided nonce | Configure | Set `DPoPValidationMode` |
| Authorization code max 60 seconds | Configure | Set `AuthorizationCodeLifetime = 60` |
| JWT clock skew max 10 seconds future | Configure | `JwtValidationClockSkew = TimeSpan.FromSeconds(10)` |
| PAR required | Configure per-client or globally | Set `RequirePushedAuthorization = true` on client or `PushedAuthorization.Required = true` globally |
| PKCE required | Configure per-client | Set `RequirePkce = true` on client (strongly recommended) |
### FAPI 2.0 Server Setup
```csharp
builder.Services.AddIdentityServer(opt =>
{
// Key management with PS256 support
opt.KeyManagement.SigningAlgorithms.Add(
new SigningAlgorithmOptions(SecurityAlgorithms.RsaSsaPssSha256));
// DPoP signing algorithms
opt.DPoP.SupportedDPoPSigningAlgorithms = [
SecurityAlgorithms.RsaSsaPssSha256,
SecurityAlgorithms.RsaSsaPssSha384,
SecurityAlgorithms.RsaSsaPssSha512,
SecurityAlgorithms.EcdsaSha256,
SecurityAlgorithms.EcdsaSha384,
SecurityAlgorithms.EcdsaSha512
];
// Client assertion signing algorithms
opt.SupportedClientAssertionSigningAlgorithms = [
SecurityAlgorithms.RsaSsaPssSha256,
SecurityAlgorithms.RsaSsaPssSha384,
SecurityAlgorithms.RsaSsaPssSha512,
SecurityAlgorithms.EcdsaSha256,
SecurityAlgorithms.EcdsaSha384,
SecurityAlgorithms.EcdsaSha512
];
// Request object signing algorithms
opt.SupportedRequestObjectSigningAlgorithms = [
SecurityAlgorithms.RsaSsaPssSha256,
SecurityAlgorithms.RsaSsaPssSha384,
SecurityAlgorithms.RsaSsaPssSha512,
SecurityAlgorithms.EcdsaSha256,
SecurityAlgorithms.EcdsaSha384,
SecurityAlgorithms.EcdsaSha512
];
// FAPI 2.0 clock skew requirement
opt.JwtValidationClockSkew = TimeSpan.FromSeconds(10);
});
```
### FAPI 2.0 Client Configuration
```csharp
new Client
{
ClientId = "fapi_client",
ClientSecrets = [
new Secret
{
Type = IdentityServerConstants.SecretTypes.JsonWebKey,
Value = "<JWT Key goes here>"
}
],
AllowedGrantTypes = GrantTypes.Code,
RedirectUris = [
"https://example.com/callback",
],
AllowOfflineAccess = true,
AllowedScopes = [ "openid", "profile", "api" ],
RequireDPoP = true, // sender-constrained tokens
RequirePushedAuthorization = true // PAR required
}
```
### FAPI 2.0 API Configuration
```csharp
builder.Services.AddAuthentication()
.AddJwtBearer(options =>
{
options.Authority = configuration.Authority;
options.TokenValidationParameters.ValidateAudience = false;
options.MapInboundClaims = false;
options.TokenValidationParameters.ValidTypes = ["at+jwt"];
});
builder.Services.ConfigureDPoPTokensForScheme(JwtBearerDefaults.AuthenticationScheme,
dpopOptions =>
{
dpopOptions.ProofTokenValidationParameters.ValidAlgorithms =
[
SecurityAlgorithms.RsaSsaPssSha256,
SecurityAlgorithms.RsaSsaPssSha384,
SecurityAlgorithms.RsaSsaPssSha512,
SecurityAlgorithms.EcdsaSha256,
SecurityAlgorithms.EcdsaSha384,
SecurityAlgorithms.EcdsaSha512
];
});
```
### FAPI 2.0 HTTP Redirects
Starting in v8.0, IdentityServer unconditionally uses HTTP 303 (See Other) redirects from POST endpoints, in compliance with FAPI 2.0 Section 5.3.2.2.
### Private Key JWT vs mTLS for FAPI 2.0
Start with private key JWTs. mTLS is relatively challenging to maintain in production. Both are supported and FAPI 2.0 compliant.
## Edition Requirements Summary
| Feature | Starter | Business | Enterprise |
| ----------------------------- | ------- | -------- | ----------- |
| Static key management | Yes | Yes | Yes |
| Automatic key management | No | Yes | Yes |
| PAR | No | Yes | Yes |
| DPoP | No | No | Yes |
| Resource isolation (RFC 8707) | No | No | Yes |
| FAPI 2.0 conformance report | No | No | Yes (v8.0+) |
| mTLS client authentication | Yes | Yes | Yes |
| mTLS token binding | Yes | Yes | Yes |
| JAR (signed requests) | Yes | Yes | Yes |
## Common Pitfalls
1. **Using `ClientCredentialStyle.AuthorizationHeader` with mTLS** - The default `AuthorizationHeader` style does not work in mTLS scenarios. Use `ClientCredentialStyle.PostBody` instead.
2. **Missing distributed cache for DPoP** - DPoP replay detection requires `IDistributedCache`. Without it, replay attacks are possible. Use Redis, SQL Server, or another durable cache in production.
3. **PAR lifetime too short** - The default 10 minutes balances security (FAPI 2.0 recommendation) with usability. If users take longer to authenticate (MFA, consent), increase the lifetime.
4. **DPoP key management** - The `DPoPJsonWebKey` must persist for the lifetime of tokens bound to it. Losing the key makes bound tokens unusable. Leaking it nullifies DPoP's security benefits.
5. **Confusing DPoP with client authentication** - DPoP proves token possession at the application layer. It is separate from client authentication (which proves client identity at the token endpoint). A client can use shared secrets for authentication and DPoP for token binding.
6. **Not enabling `AlwaysEmitConfirmationClaim` for mTLS without mTLS auth** - If you want certificate binding without certificate-based client authentication, you must set `MutualTls.AlwaysEmitConfirmationClaim = true`.
7. **Forgetting to configure DPoP proof validation algorithms** - For FAPI 2.0, explicitly set `ProofTokenValidationParameters.ValidAlgorithms` on the API side. Without this, the API may accept weaker algorithms.
8. **PAR not available in Starter edition** - PAR requests are rejected in the Starter edition. Disable the endpoint via `options.Endpoints.EnablePushedAuthorizationEndpoint = false` so discovery accurately reflects this.
identityserver-ui-flows19.9 KB
---
name: identityserver-ui-flows
description: "Guide for building login, logout, consent, error, and federation gateway UI pages in Duende IdentityServer, including IIdentityServerInteractionService usage, external provider integration, and Home Realm Discovery strategies."
invocable: false
---
# IdentityServer UI Flows: Login, Logout, Consent, and Federation
## When to Use This Skill
- Building or customizing the login page (local credentials, MFA, passwordless)
- Integrating external identity providers (Google, Azure AD, SAML, etc.)
- Implementing the consent page for third-party client authorization
- Building the logout flow with session cleanup and client notifications
- Implementing a federation gateway with Home Realm Discovery (HRD)
- Handling and displaying error pages for protocol errors
- Using `IIdentityServerInteractionService` to interact with the protocol engine
- Redirecting users back to clients after login/logout
Docs: https://docs.duendesoftware.com/identityserver/ui
## Architecture Overview
IdentityServer separates the protocol engine from the user interface. The engine handles OAuth/OIDC endpoints and redirects to your UI pages as needed. Your UI code handles all user interaction and then communicates results back to the engine.
```
Browser → IdentityServer Middleware → UI Pages (Login, Consent, Logout, Error)
↕
IIdentityServerInteractionService
↕
IdentityServer Protocol Engine
```
### Required Pages
| Page | Purpose | Default URL |
| ------- | ------------------------------------- | ----------------------------------------- |
| Login | Establish authentication session | Inferred from cookie handler `LoginPath` |
| Logout | Terminate session, notify clients | Set via `opt.UserInteraction.LogoutUrl` |
| Consent | Grant/deny client access to resources | `/consent` |
| Error | Display protocol error information | `/home/error` |
## Login Page
### Configuring the Login URL
```csharp
// Program.cs — explicit configuration
builder.Services.AddIdentityServer(opt => {
opt.UserInteraction.LoginUrl = "/path/to/login";
});
```
If not set, IdentityServer infers the URL from the cookie handler's `LoginPath`:
```csharp
// Program.cs — with ASP.NET Identity
builder.Services.AddIdentityServer()
.AddAspNetIdentity<ApplicationUser>();
builder.Services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/path/to/login/for/aspnet_identity";
});
```
### Authorization Context
When IdentityServer redirects to the login page, it passes a `returnUrl` query parameter. Use `IIdentityServerInteractionService.GetAuthorizationContextAsync` to extract the original authorization request parameters:
```csharp
public class LoginModel : PageModel
{
private readonly IIdentityServerInteractionService _interaction;
public LoginModel(IIdentityServerInteractionService interaction)
{
_interaction = interaction;
}
public async Task<IActionResult> OnGet(string returnUrl)
{
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
// context contains:
// - Client (the requesting client)
// - IdP (requested identity provider hint)
// - AcrValues (requested authentication context)
// - Tenant (requested tenant)
// - LoginHint (suggested username)
// - Parameters (raw protocol parameters)
// Use context for branding, HRD, MFA decisions, etc.
}
}
```
**Important**: Do not parse the `returnUrl` yourself. Always use the interaction service.
### Establishing the Authentication Session
After validating credentials, create the authentication session:
```csharp
var user = new IdentityServerUser("unique_id_for_your_user")
{
DisplayName = "Bob Smith"
};
await HttpContext.SignInAsync(user);
// Redirect back to the authorization endpoint
return Redirect(returnUrl);
```
Or with explicit claims:
```csharp
var claims = new Claim[] {
new Claim("sub", "unique_id_for_your_user"),
new Claim("name", "Bob Smith"),
new Claim("amr", "pwd"),
new Claim("idp", "local")
};
var identity = new ClaimsIdentity(claims, "pwd");
var principal = new ClaimsPrincipal(identity);
await HttpContext.SignInAsync(principal);
```
### Well-Known Session Claims
| Claim | Purpose | Default |
| ----------- | ------------------------------------------------------------------------- | -------------------------- |
| `sub` | **Required.** Unique user identifier. Must never change or be reassigned. | None — you must provide it |
| `name` | Display name of the user | None |
| `amr` | Authentication method reference | `pwd` |
| `auth_time` | Time user entered credentials (epoch) | Current time |
| `idp` | Identity provider scheme name | `local` |
| `tenant` | Tenant identifier | None |
### Protecting Against Open Redirects
Always validate the `returnUrl` before redirecting:
```csharp
// Option 1: Use ASP.NET Core helper
if (Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
// Option 2: Use IdentityServer interaction service
if (await _interaction.IsValidReturnUrl(returnUrl))
{
return Redirect(returnUrl);
}
```
### Completing Login with CompleteLoginAsync
After establishing the authentication session, redirect the user back to the `returnUrl`. This causes the browser to re-issue the original authorize request, allowing IdentityServer to complete the protocol workflow.
## External Login (Federation)
### Registering External Providers
```csharp
// Program.cs
builder.Services.AddIdentityServer();
builder.Services.AddAuthentication()
.AddOpenIdConnect("AAD", "Employee Login", options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
// configure authority, client ID, etc.
});
```
### Triggering External Authentication
```csharp
var callbackUrl = Url.Action("MyCallback");
var props = new AuthenticationProperties
{
RedirectUri = callbackUrl,
Items =
{
{ "scheme", "AAD" },
{ "returnUrl", returnUrl }
}
};
return Challenge("AAD", props);
```
### Handling the Callback
```csharp
// 1. Read external identity from temporary cookie
var result = await HttpContext.AuthenticateAsync(
IdentityServerConstants.ExternalCookieAuthenticationScheme);
if (result?.Succeeded != true)
throw new Exception("External authentication error");
var externalUser = result.Principal;
var userId = externalUser.FindFirst("sub").Value;
var scheme = result.Properties.Items["scheme"];
var returnUrl = result.Properties.Items["returnUrl"] ?? "~/";
// 2. Find or provision local user
var user = FindUserFromExternalProvider(scheme, userId);
// 3. Establish session
await HttpContext.SignInAsync(new IdentityServerUser(user.SubjectId)
{
DisplayName = user.DisplayName,
IdentityProvider = scheme
});
// 4. Clean up external cookie
await HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme);
// 5. Return to protocol processing
return Redirect(returnUrl);
```
### SignInScheme and SignOutScheme
| Scenario | SignInScheme | SignOutScheme |
| ------------------------ | ------------------------------------------------------------ | --------------------------------------- |
| Without ASP.NET Identity | `IdentityServerConstants.ExternalCookieAuthenticationScheme` | `IdentityServerConstants.SignoutScheme` |
| With ASP.NET Identity | `IdentityServerConstants.ExternalCookieAuthenticationScheme` | `IdentityConstants.ApplicationScheme` |
### State and URL Length
If external provider state makes the URL too long (>2000 chars), use the IdentityServer-provided `IDistributedCache`-backed data format:
```csharp
// Program.cs — all OIDC handlers use server-side state
builder.Services.AddOidcStateDataFormatterCache();
// Or specific schemes only
builder.Services.AddOidcStateDataFormatterCache("aad", "demoidsrv");
```
## Logout Page
### Configuring the Logout URL
```csharp
// Program.cs
builder.Services.AddIdentityServer(opt => {
opt.UserInteraction.LogoutUrl = "/path/to/logout";
});
```
### Logout Steps
1. **End the IdentityServer session** — remove the authentication cookie
2. **Sign out of external provider** — if an external login was used
3. **Notify client applications** — via front-channel, back-channel, or JS-based notifications
4. **Redirect back to client** — if the logout is client-initiated
### Client Notification Mechanisms
| Mechanism | How It Works | Client Setting |
| ------------- | -------------------------------------------------------------------- | ------------------------------------ |
| Front-channel | Render `<iframe>` on logged-out page pointing to client's logout URI | `FrontChannelLogoutUri` |
| Back-channel | Server-to-server HTTP call with a logout JWT (`typ: logout+jwt`) | `BackChannelLogoutUri` |
| JS-based | Client monitors `check_session_iframe` | Built into spec-compliant JS clients |
**Recommendation**: Use back-channel notifications for cross-site architectures. Front-channel and JS-based notifications rely on cookies in iframes, which may not work reliably across different sites.
### Getting Logout Context
```csharp
var context = await _interaction.GetLogoutContextAsync(logoutId);
// context.SignOutIFrameUrl — render in <iframe> for front-channel logout
// context.PostLogoutRedirectUri — where to send the user after logout
```
### Back-Channel Logout
Back-channel logout happens automatically when you call `HttpContext.SignOutAsync()` — IdentityServer uses `IBackChannelLogoutService` to notify all clients that have `BackChannelLogoutUri` configured.
For .NET clients: use the BFF framework which has built-in back-channel logout support, or see the IdentityServer samples.
## Consent Page
### When Consent Is Required
Consent applies **only to user-based (interactive) authorization requests**. Client-credentials (M2M) flows never prompt for consent — there `Client.AllowedScopes` alone governs access.
Consent is controlled per client via `RequireConsent` (default: `false`). Set `RequireConsent = false` for first-party clients to suppress the scope prompt; set `true` for third-party clients. When enabled, IdentityServer redirects to the consent page before completing authorization.
The `offline_access` scope always triggers consent when the client has consent enabled.
### Required vs. Optional Scopes
`IdentityResource` and `ApiScope` expose a `Required` bool:
- If the consent response **omits a `Required` scope**, IdentityServer returns `access_denied` and the request fails.
- **Optional** scopes can be declined and the flow still succeeds — the issued tokens/userinfo simply omit that data.
```csharp
new IdentityResource("profile", /* ... */) { Required = true }; // cannot be declined
new ApiScope("api.read") { Required = false }; // may be declined
```
### Remembered Consent
Enable persistence with `Client.AllowRememberConsent` (bool) and `Client.ConsentLifetime` (expiry). Granted scopes are stored in the operational (persisted grant) store; set `RememberConsent = true` on the `ConsentResponse` to persist a grant.
IdentityServer **re-prompts** for consent when:
- there is no remembered consent, or it has expired,
- a new, not-previously-granted scope is requested,
- the request includes `offline_access`,
- the request contains a parameterized scope value, or
- `AllowRememberConsent = false`.
**Device flow**: in IdentityServer v8, device-flow (Device Authorization Grant) consent is **never remembered** — the user consents on every device authorization.
### Revoking Consent
Use `IIdentityServerInteractionService.RevokeUserConsentAsync(clientId)` for the current user. This removes **all** persisted grants for that user/client — remembered consent, reference tokens, and refresh tokens.
```csharp
await _interaction.RevokeUserConsentAsync("web.app");
```
### Consent Page Flow
```csharp
// 1. Get authorization context
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
// 2. Show user: client info, requested scopes/resources
// context.Client — the requesting client
// Use IClientStore and IResourceStore for additional details
// 3. User grants or denies consent
await _interaction.GrantConsentAsync(context, new ConsentResponse
{
ScopesValuesConsented = new[] { "openid", "profile", "api1" },
RememberConsent = true // persist for future requests
});
// 4. Redirect back
return Redirect(returnUrl);
```
### Denying Consent
```csharp
await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied);
```
### Validating returnUrl
```csharp
// Use interaction service
if (await _interaction.IsValidReturnUrl(returnUrl))
{
return Redirect(returnUrl);
}
// Or check if GetAuthorizationContextAsync returns non-null
```
## User Registration (prompt=create)
The `prompt=create` OIDC parameter sends the user straight to a registration page instead of login.
### Host Configuration
Set `CreateAccountUrl` in `AddIdentityServer`. This makes IdentityServer advertise `create` in `prompt_values_supported` in discovery. If unset, `prompt=create` is **ignored and not advertised**.
```csharp
// Program.cs
builder.Services.AddIdentityServer(options =>
{
options.UserInteraction.CreateAccountUrl = "/Account/Register";
});
```
`prompt=create` must be the **only** prompt value — it cannot be combined with `login`, `consent`, `select_account`, or `none`.
### Triggering Registration from an ASP.NET Core Client
```csharp
return Results.Challenge(
new OpenIdConnectChallengeProperties { Prompt = "create", RedirectUri = "/" },
["oidc"]);
```
### Registration Page Handler (Host)
```csharp
public async Task<IActionResult> OnPost(string returnUrl, CancellationToken ct)
{
// Returns null for an invalid returnUrl → prevents open redirect
var context = await _interaction.GetAuthorizationContextAsync(returnUrl, ct);
if (context is null) return Redirect("~/");
// Create + persist the local user
var user = await _users.CreateAsync(/* ... */);
// Sign in ONLY after email confirmation / approval / MFA — not on submit
await HttpContext.SignInAsync(new IdentityServerUser(user.SubjectId));
return Redirect(returnUrl);
}
```
**Important**: `GetAuthorizationContextAsync` returning `null` signals an invalid `returnUrl` — redirect away instead of trusting it. Establish the session only after any required confirmation/approval/MFA step.
## Error Page
### Configuration
```csharp
// Program.cs
builder.Services.AddIdentityServer(opt => {
opt.UserInteraction.ErrorUrl = "/path/to/error";
opt.UserInteraction.ErrorId = "ErrorQueryStringParamName"; // default: "errorId"
});
```
### Retrieving Error Details
```csharp
var errorContext = await _interaction.GetErrorContextAsync(errorId);
// errorContext contains:
// - Error (error code)
// - ErrorDescription
// - RequestId
// - ClientId
// - DisplayMode
// - UiLocales
```
Errors are commonly due to misconfiguration. The error page should inform the user something went wrong without exposing sensitive details.
## Federation Gateway and Home Realm Discovery
### What Is a Federation Gateway?
A federation gateway architecture shields clients from authentication complexity. Clients trust only IdentityServer; the gateway coordinates with external providers, handling protocol bridging (OIDC, SAML, WS-Fed), claim transformation, and trust management.
### Home Realm Discovery (HRD) Strategies
| Strategy | Description | Best For |
| ------------------------------ | -------------------------------------------------- | -------------------------------------- |
| Show all providers | Present a list of available authentication methods | Simple setups with few providers |
| Email/identifier-based | Ask for email, infer provider from domain | SaaS with corporate federation |
| Client hint via `acr_values` | Client passes `idp:provider_name` | Known provider per client/URL |
| `IdentityProviderRestrictions` | Restrict available providers per client | Multi-tenant with per-client providers |
### Restricting Providers Per Client
```csharp
var client = new Client
{
ClientId = "tenant_a_app",
IdentityProviderRestrictions = { "AAD", "local" }
// Only Azure AD and local login are available
};
```
### HRD via acr_values
Clients can hint at the desired provider:
```
GET /connect/authorize?
client_id=app&
acr_values=idp:AAD&
...
```
Your login page checks `context.IdP` from `GetAuthorizationContextAsync` and can skip the login UI entirely, redirecting straight to the external provider.
## Common Anti-Patterns
- ❌ Parsing `returnUrl` manually to extract authorization parameters
- ✅ Use `IIdentityServerInteractionService.GetAuthorizationContextAsync(returnUrl)`
- ❌ Redirecting to `returnUrl` without validation, enabling open redirect attacks
- ✅ Validate with `Url.IsLocalUrl()` or `_interaction.IsValidReturnUrl()`
- ❌ Forgetting to delete the external authentication cookie after callback processing
- ✅ Always call `HttpContext.SignOutAsync(IdentityServerConstants.ExternalCookieAuthenticationScheme)`
- ❌ Using front-channel logout across different sites/domains (cookie/iframe issues)
- ✅ Use back-channel logout for cross-site architectures
- ❌ Issuing the authentication session without a `sub` claim
- ✅ The `sub` claim is required — it uniquely identifies the user and must never change
- ❌ Hardcoding external provider list without checking both static schemes and dynamic providers
- ✅ Query `IAuthenticationSchemeProvider` for static schemes and `IIdentityProviderStore` for dynamic providers
## Common Pitfalls
1. **Login page does not preserve `returnUrl`**: The `returnUrl` must survive across all page transitions (post-backs, external redirects, MFA steps). Store it in hidden form fields, route data, or the `AuthenticationProperties.Items` dictionary.
2. **Cookie handler `LoginPath` mismatch**: If no explicit `LoginUrl` is configured, IdentityServer infers it from the cookie handler's `LoginPath`. Make sure the cookie handler `LoginPath` matches your actual login page route. The `LogoutUrl` is not inferred from the cookie handler — it must always be set explicitly via `opt.UserInteraction.LogoutUrl`.
3. **SignOutScheme differs with ASP.NET Identity**: When using ASP.NET Identity, the `SignOutScheme` for external providers should be `IdentityConstants.ApplicationScheme`, not `IdentityServerConstants.SignoutScheme`.
4. **Consent persistence is temporary by default**: The consent result between the consent page and authorization endpoint is stored in a cookie. For custom persistence, implement `IConsentMessageStore`.
5. **Error messages are deliberately brief**: For security, error messages returned to clients are minimal. Check the IdentityServer logs (at `Debug` level) for full error details.
6. **External provider `sub` is provider-specific**: The `sub` claim from an external provider is that provider's unique ID. Map it to your local user database — do not use it directly as the IdentityServer `sub`.
identityserver-upgrade-v7-to-v815.1 KB
---
name: identityserver-upgrade-v7-to-v8
description: "Migrating Duende IdentityServer from v7.4 to v8.0: breaking changes, API replacements (ICache→HybridCache, IClock→TimeProvider), CancellationToken additions, EF migrations, and step-by-step upgrade guide."
invocable: false
---
# Upgrading IdentityServer v7 to v8
## When to Use This Skill
- Upgrading a Duende IdentityServer project from v7.4 to v8.0
- Fixing build errors after updating NuGet packages to v8
- Migrating custom stores/services to new v8 interfaces
- Running EF Core database migrations for v8 (SAML tables)
- Replacing deprecated APIs (ICache, IClock, IAuthorizationParametersMessageStore)
## Core Principles
- v8.0 requires **.NET 10** — update TFM before anything else
- All breaking changes are compile-time errors (no silent behavior changes)
- Migration is mechanical — find/replace patterns work for most changes
- Run EF migrations even if you don't use SAML (schema must match)
- **Always check the latest stable 8.x package version** on [NuGet](https://www.nuget.org/packages/Duende.IdentityServer) before upgrading — do not hardcode `8.0.1`; use whatever the latest stable (non-prerelease) 8.x version is at the time of the upgrade.
Docs: https://docs.duendesoftware.com/identityserver/upgrades/v7_4-to-v8_0/
## Step-by-Step Migration
### 1. Update Target Framework
```xml
<!-- ❌ Before -->
<TargetFramework>net8.0</TargetFramework>
<!-- ✅ After -->
<TargetFramework>net10.0</TargetFramework>
```
### 2. Update NuGet Packages
Check [NuGet](https://www.nuget.org/packages/Duende.IdentityServer) for the latest stable 8.x version. At time of writing, that is `8.0.1`, but use whatever is current:
```xml
<PackageReference Include="Duende.IdentityServer" Version="8.0.1" />
<PackageReference Include="Duende.IdentityServer.EntityFramework" Version="8.0.1" />
<!-- Update all Duende.* packages to the latest stable 8.x version -->
```
### 3. Run EF Database Migrations
Two migrations are required — one for the Configuration Store and one for the Operational Store:
```bash
# Configuration Store — adds 7 SAML-related tables
dotnet ef migrations add Update_DuendeIdentityServer_v8_0 \
-c ConfigurationDbContext -o Migrations/ConfigurationDb
dotnet ef database update -c ConfigurationDbContext
# Operational Store — adds 3 SAML session tables
dotnet ef migrations add Update_DuendeIdentityServer_v8_0_Saml \
-c PersistedGrantDbContext -o Migrations/PersistedGrantDb
dotnet ef database update -c PersistedGrantDbContext
```
Both are required even if you don't use SAML (schema must match).
### 4. Replace ICache<T> with HybridCache
```csharp
// ❌ Before (v7)
public class MyService
{
private readonly ICache<MyData> _cache;
public MyService(ICache<MyData> cache) => _cache = cache;
public async Task<MyData> GetAsync(string key)
{
return await _cache.GetOrAddAsync(key,
TimeSpan.FromMinutes(5),
() => LoadFromDbAsync(key));
}
}
// ✅ After (v8) — use Microsoft HybridCache
public class MyService
{
private readonly HybridCache _cache;
public MyService([FromKeyedServices("ConfigurationStoreCache")] HybridCache cache)
=> _cache = cache;
public async Task<MyData> GetAsync(string key, CancellationToken ct)
{
return await _cache.GetOrCreateAsync(key,
async token => await LoadFromDbAsync(key, token),
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(5)
}, cancellationToken: ct);
}
}
```
Key: use keyed service `"ConfigurationStoreCache"` (`ServiceProviderKeys.ConfigurationStoreCache`). `CachingOptions.CacheLockTimeout` is obsolete.
### 5. Replace IClock with TimeProvider
```csharp
// ❌ Before (v7)
public class MyService
{
private readonly IClock _clock;
public MyService(IClock clock) => _clock = clock;
public DateTime Now => _clock.UtcNow.UtcDateTime;
}
// ✅ After (v8)
public class MyService
{
private readonly TimeProvider _timeProvider;
public MyService(TimeProvider timeProvider) => _timeProvider = timeProvider;
public DateTime Now => _timeProvider.GetUtcNow().UtcDateTime;
}
```
Note: `GetUtcNow()` (method) replaces `UtcNow` (property).
### 6. Add CancellationToken to All Async Interfaces
All store and service interfaces now require `CancellationToken ct` as the last parameter:
```csharp
// ❌ Before (v7)
public Task<Client?> FindClientByIdAsync(string clientId)
// ✅ After (v8)
public Task<Client?> FindClientByIdAsync(string clientId, CancellationToken ct)
```
Affected interfaces include: `IClientStore`, `IResourceStore`, `IPersistedGrantStore`, `IDeviceFlowStore`, `ICorsPolicyService`, `IProfileService`, and all custom stores/services.
Also: `ICancellationTokenProvider` is removed entirely.
### 7. Add GetAllClientsAsync to IClientStore
```csharp
// ✅ New required method
public IAsyncEnumerable<Client> GetAllClientsAsync(CancellationToken ct)
```
Used by Financial-Grade Security features and conformance reports.
### 8. Update Refresh Token Service
```csharp
// ❌ Before (v7) — individual parameters
public Task<string> CreateRefreshTokenAsync(
ClaimsPrincipal subject, Token accessToken, Client client)
// ✅ After (v8) — request objects
public Task<string> CreateRefreshTokenAsync(RefreshTokenCreationRequest request, CancellationToken ct)
public Task<string> UpdateRefreshTokenAsync(RefreshTokenUpdateRequest request, CancellationToken ct)
```
### 9. Remove IAuthorizationParametersMessageStore
```csharp
// ❌ Removed in v8 — use PAR (Pushed Authorization Requests) instead
services.AddTransient<IAuthorizationParametersMessageStore, MyStore>();
// ✅ PAR is the replacement for passing large authorization parameters
```
### 10. Fix Return Type Changes
Nine interfaces changed `IEnumerable<T>` → `IReadOnlyCollection<T>`:
```csharp
// ❌ Before
public Task<IEnumerable<ApiScope>> FindApiScopesByNameAsync(IEnumerable<string> scopeNames)
// ✅ After
public Task<IReadOnlyCollection<ApiScope>> FindApiScopesByNameAsync(
IEnumerable<string> scopeNames, CancellationToken ct)
```
### 11. Fix DPoP Type Names
```csharp
// ❌ Typo in v7
DPoPProofValidatonContext → DPoPProofValidationContext
DPoPProofValidatonResult → DPoPProofValidationResult
```
### 12. Update Licensing Code
```csharp
// ❌ Before (v7)
var license = IdentityServerLicense.Current;
var edition = summary.LicenseEdition;
// ✅ After (v8)
var info = LicenseInformation.Current; // from Duende.IdentityServer.Licensing
var skus = summary.EntitledSkus; // collection replaces single edition
```
#### New v8 License Key Format
- v8 introduced a **new license key file format**: the v8 key is a signed **JWT carrying a `kid` header**.
- A **v7/earlier key still works with v8 core** — no new purchase is needed to run v8 core on an existing key.
- A **v8 key does NOT work on v7/earlier OR on the BFF Security Framework runtime**. It fails signature validation with Microsoft.IdentityModel error:
- `IDX10503: Signature validation failed. Token does not have a kid.`
- That exact error is the tell-tale sign of a **v8 key loaded into a v7 or BFF runtime**.
- **Add-ons require a v8-format key in production**: using **SAML** or **Duende User Management** in production on v8 REQUIRES a new v8-format license key. Older-format keys run v8 core, but not these add-ons in production.
#### Runtime License Enforcement Changed (behavioral reversal)
v8 validates feature usage at runtime. When a **license IS present but lacks the entitlement**, behavior splits into two tiers:
| Tier | Behavior when unlicensed | Features |
| ---- | ------------------------ | -------- |
| A | **THROWS** during startup validation | Server-Side Sessions, Automatic Key Management, SAML (IdP and Service Provider) |
| B | **LOGS a warning** (rate-limited ~once/5 min) | DPoP, Resource Isolation, CIBA, Dynamic Identity Providers, Financial-grade/Conformance, User Management |
- If **NO license is configured** (local dev / non-prod), Tier-A features **downgrade to logging** instead of throwing.
- **Guidance**: use your **production license key in lower environments** so entitlement gaps (e.g. Server-Side Sessions) surface before production.
- **Contrast with v7 and earlier**: those versions **disabled** some features at runtime when unlicensed (Server-Side Sessions, DPoP, Resource Isolation, PAR, Dynamic Identity Providers, CIBA). **v8 no longer disables** — it logs or throws per the tiers above.
#### Editions → Plans
The product moved from fixed **Starter / Business / Enterprise** editions to generic **plans**. The old three editions are still honored for legacy/long-term customers only. The **Community edition remains**. Update any code or docs that hard-code "three editions" to reflect the plan model.
### 13. Update EF Identity Provider Store
```csharp
// ❌ Before (v7)
public IdentityProviderStore(IServiceProvider sp, ConfigurationDbContext ctx)
// ✅ After (v8) — new required parameter
public IdentityProviderStore(
IServiceProvider sp, ConfigurationDbContext ctx, IIdentityProviderFactory factory)
```
### 14. Rename AuthorizationError → InteractionError
```csharp
// ❌ Before (v7)
if (result.Error == AuthorizationError.LoginRequired) { }
// ✅ After (v8)
if (result.Error == InteractionError.LoginRequired) { }
```
Values remain the same: `AccessDenied`, `LoginRequired`, `InteractionRequired`.
### 15. Rename DenyAuthorizationAsync → DenyAuthenticationAsync
```csharp
// ❌ Before (v7)
await _interaction.DenyAuthorizationAsync(context, AuthorizationError.AccessDenied);
// ✅ After (v8) — now accepts IAuthenticationContext (protocol-agnostic for OIDC/SAML)
await _interaction.DenyAuthenticationAsync(context, InteractionError.AccessDenied);
```
### 16. Rename ProfileDataRequestContext.Client → .Application
```csharp
// ❌ Before (v7)
var client = context.Client;
// ✅ After (v8)
var client = context.Application;
```
### 17. Update ITokenValidator.ValidateAccessTokenAsync
```csharp
// ❌ Before (v7)
await _validator.ValidateAccessTokenAsync(token);
// ✅ After (v8) — new expectedScope parameter
await _validator.ValidateAccessTokenAsync(token, expectedScope: null, ct);
```
### 18. Relocate PreviewFeatureOptions
`PreviewFeatureOptions` and `IdentityServerOptions.Preview` are removed. Options relocated:
```csharp
// ❌ Before (v7)
options.Preview.EnableDiscoveryDocumentCache = true;
options.Preview.DiscoveryDocumentCacheDuration = TimeSpan.FromMinutes(10);
options.Preview.StrictClientAssertionAudienceValidation = true;
// ✅ After (v8)
options.Discovery.EnableDiscoveryDocumentCache = true;
options.Discovery.DiscoveryDocumentCacheDuration = TimeSpan.FromMinutes(10);
options.StrictClientAssertionAudienceValidation = true; // default changed to false!
```
## Other Notable Changes
- **NRT enabled**: All assemblies use nullable reference types. Fix nullable warnings.
- **HTTP 303**: POST endpoint redirects now unconditionally use 303 (FAPI 2.0 compliance).
- **`PersistedGrantFilter.ClientIds`/`Types`**: Now non-nullable with empty collection defaults. Replace null checks with `.Count > 0`.
- **IUserSession**: Three new SAML session methods added (implement as no-op if not using SAML):
- `AddSamlSessionAsync`, `GetSamlSessionListAsync`, `RemoveSamlSessionAsync`
- **Log levels**: Secret validation failures changed from Error to Debug — update alerting to watch for Warning-level entries at endpoint level instead.
- **Device flow consent**: "Remember My Decision" no longer offered — `RememberConsent` always `false` during device flow (RFC 8628 security).
- **License key from IConfiguration**: IdentityServer now reads license key automatically from `Duende:IdentityServer:LicenseKey` or `Duende:LicenseKey` in configuration.
- **`DPoPExtensions` → `DPoPServiceCollectionExtensions`**: Class renamed in JwtBearer package.
- **Token cleanup performance**: When no `IOperationalStoreNotification` registered, uses single `ExecuteDeleteAsync` call (automatic improvement, no action needed).
- **Orphaned grants revoked on session overwrite**: When server-side sessions enabled and session cookie reused by different user, previous user's grants are automatically revoked.
## Migration Checklist
1. ☐ Update TFM to `net10.0`
2. ☐ Update all Duende.* packages to latest stable 8.x (check [NuGet](https://www.nuget.org/packages/Duende.IdentityServer))
3. ☐ Run EF migrations (both `ConfigurationDbContext` and `PersistedGrantDbContext`)
4. ☐ Replace `ICache<T>` → keyed `HybridCache`
5. ☐ Replace `IClock` → `TimeProvider`
6. ☐ Add `CancellationToken` to all async store/service methods
7. ☐ Remove `ICancellationTokenProvider` references
8. ☐ Add `GetAllClientsAsync` to custom `IClientStore` (returns `IAsyncEnumerable<Client>`)
9. ☐ Update `IRefreshTokenService` implementations (request objects)
10. ☐ Remove `IAuthorizationParametersMessageStore` (use PAR)
11. ☐ Fix `IEnumerable<T>` → `IReadOnlyCollection<T>` return types
12. ☐ Fix DPoP type name typos
13. ☐ Update licensing references (`IdentityServerLicense` → `LicenseInformation`)
14. ☐ Rename `AuthorizationError` → `InteractionError`
15. ☐ Rename `DenyAuthorizationAsync` → `DenyAuthenticationAsync`
16. ☐ Rename `ProfileDataRequestContext.Client` → `.Application`
17. ☐ Update `ITokenValidator.ValidateAccessTokenAsync` calls (add `expectedScope` param)
18. ☐ Relocate `PreviewFeatureOptions` settings
19. ☐ Fix nullable reference type warnings
20. ☐ Test build and run
## Common Pitfalls
1. **Forgetting EF migration**: Even without SAML, the schema must be updated or EF will throw at runtime.
2. **HybridCache keyed service**: Must use `[FromKeyedServices("ConfigurationStoreCache")]` — plain `HybridCache` injection gets a different instance.
3. **CancellationToken propagation**: Don't pass `CancellationToken.None` everywhere — propagate from the method parameter for proper request cancellation.
4. **GetAllClientsAsync performance**: Return all clients from your store; used rarely but must be implemented.
5. **PAR migration**: If you used `IAuthorizationParametersMessageStore` for large auth requests, switch clients to use PAR (`require_pushed_authorization_requests`).
6. **`IDX10503` after dropping in a v8 key**: A v8-format license key (signed JWT with a `kid` header) fails signature validation on v7/earlier or the **BFF Security Framework runtime** with `IDX10503: Signature validation failed. Token does not have a kid.` Keep the v7-format key for those runtimes — it still works on v8 core; only SAML/User Management add-ons in production require the new v8-format key.
7. **Entitlement gaps surface late**: v8 no longer silently disables unlicensed features — Server-Side Sessions, Automatic Key Management, and SAML now **throw at startup** when a license is present but missing the entitlement. Run lower environments with the production license key to catch this before deploying.
## Related Skills
- `identityserver-configuration` — IdentityServer host configuration and options
- `identityserver-stores` — Store implementation patterns (affected by CancellationToken changes)
- `identityserver-saml` — SAML 2.0 support (new in v8, requires EF migration)
- `identityserver-usermanagement` — User Management (new in v8)
identityserver-usermanagement6.36 KB
---
name: identityserver-usermanagement
description: "Setting up Duende User Management with IdentityServer: passwordless authentication (OTP, TOTP, passkeys), storage configuration, user lifecycle, and migration from ASP.NET Identity."
invocable: false
---
# User Management
## When to Use This Skill
- Adding user management to a Duende IdentityServer project
- Setting up passwordless authentication (OTP, TOTP, passkeys)
- Configuring storage providers (PostgreSQL, SQL Server, SQLite)
- Integrating User Management with IdentityServer for claims and login/logout
- Managing user profiles, roles, and groups
- Migrating users from ASP.NET Identity
## Core Principles
- Duende User Management is **passwordless-first** — OTP email/SMS is the default flow
- Requires `Duende.UserManagement.IdentityServer8` NuGet package + .NET 10
- Storage is **document-based** (no EF migrations needed) — schema auto-creates at startup
- Configuration goes **inside** `AddUserManagement()`, not at top level
- Use `app.UseIdentityServer()` (not `UseAuthentication()` separately)
Docs: https://docs.duendesoftware.com/identityserver/usermanagement
## Setup
### 1. Add Packages
```bash
dotnet add package Duende.IdentityServer
dotnet add package Duende.UserManagement.IdentityServer8
dotnet add package Duende.Storage.Sqlite # or .PostgreSQL, .Mssql
```
### 2. Configure Program.cs
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddIdentityServer(options =>
{
options.UserInteraction.LoginUrl = "/Account/Login";
options.UserInteraction.LogoutUrl = "/Account/Logout";
})
.AddInMemoryClients(Config.Clients)
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddUserManagement(options =>
{
// Storage (pick one)
options.AddSqliteStore("Data Source=users.db");
// options.AddPostgreSqlStore(connectionString);
// options.AddSqlServerStore(connectionString);
// OTP delivery
options.UseSmtpOtpDispatcher(smtp =>
builder.Configuration.GetSection("Smtp").Bind(smtp));
});
var app = builder.Build();
// Auto-create database schema
var schema = app.Services.GetRequiredService<IDatabaseSchema>();
await schema.CreateIfNotExistsAsync();
app.UseIdentityServer();
app.MapRazorPages();
app.Run();
```
### 3. OTP Dispatcher
**Console (development):**
```csharp
builder.Services.AddSingleton<IOtpDispatcher, ConsoleOtpDispatcher>();
```
**SMTP (production):**
```csharp
options.UseSmtpOtpDispatcher(x =>
{
x.Host = "smtp.example.com";
x.Port = 587;
x.Username = "noreply@example.com";
x.Password = "secret";
x.FromAddress = "noreply@example.com";
});
```
## Authentication Methods
| Method | Description | Setup |
|--------|-------------|-------|
| **OTP** (default) | One-time codes via email/SMS | `IOtpDispatcher` implementation |
| **TOTP** | Authenticator apps (RFC 6238) | Built-in, user enrollment required |
| **Passkeys** | WebAuthn/FIDO2 phishing-resistant | Built-in, browser support required |
| **Passwords** | Traditional username/password (PBKDF2) | Opt-in, not recommended as primary |
| **External** | OAuth 2.0 / OIDC federated login | Standard ASP.NET Core auth handlers |
| **Recovery codes** | Single-use backup codes | Auto-generated during 2FA setup |
## IdentityServer Integration
`AddUserManagement()` is called on the IdentityServer builder — it automatically:
- Registers `IProfileService` for claims delivery
- Handles login/logout flows
- Maps user attributes to identity token claims
### Claims Mapping
User profile attributes are mapped to claims based on requested scopes:
- `openid` → `sub`
- `profile` → `name`, `given_name`, `family_name`, etc.
- `email` → `email`, `email_verified`
Custom attributes are available through custom identity resources.
## Storage
| Provider | Package | Connection |
|----------|---------|------------|
| SQLite | `Duende.Storage.Sqlite` | `Data Source=users.db` |
| PostgreSQL | `Duende.Storage.PostgreSQL` | Standard connection string |
| SQL Server | `Duende.Storage.Mssql` | Standard connection string |
| In-Memory | (built-in) | `Data Source=:memory:` (testing only) |
Storage is document-based — no EF Core migrations needed. Call `IDatabaseSchema.CreateIfNotExistsAsync()` at startup to ensure schema exists.
## User Lifecycle
- **Creation**: Users are created on first authentication (passwordless) or via admin APIs
- **Profiles**: Custom attributes stored as key-value pairs, organized in attribute groups
- **Roles & Groups**: RBAC support with group membership and role inheritance
- **Deletion**: Full user deletion with cascade
## Migration from ASP.NET Identity
```csharp
options.AddAspNetIdentityMigration(migrationOptions =>
{
migrationOptions.ConnectionString = "existing-aspnet-identity-db";
});
```
Key points:
- Imports users, roles, and claims from existing ASP.NET Identity tables
- Password hashes are preserved (users can still log in with existing passwords)
- Migration runs once; subsequent runs skip already-imported users
- After migration, users can enroll in passwordless methods
## Common Anti-Patterns
❌ Configuring storage outside `AddUserManagement()` — storage config must be inside the options lambda
❌ Using `UseAuthentication()` instead of `UseIdentityServer()` — IdentityServer middleware handles auth
❌ Skipping `CreateIfNotExistsAsync()` — database tables won't exist on first run
❌ Using in-memory storage in production — data is lost on restart
## Common Pitfalls
1. **Storage configuration location**: `AddSqliteStore()`/`AddPostgreSqlStore()` must be called inside the `AddUserManagement(options => { })` lambda, not on the top-level builder.
2. **.NET 10 required**: User Management requires .NET 10 SDK or later.
3. **OTP dispatcher required**: Without an `IOtpDispatcher`, the default OTP flow cannot send codes. Register `ConsoleOtpDispatcher` for development.
4. **LoginUrl/LogoutUrl**: Must be set in IdentityServer options to point to your account pages.
5. **Schema creation**: Call `IDatabaseSchema.CreateIfNotExistsAsync()` before the app starts handling requests.
## Related Skills
- `identityserver-configuration` — IdentityServer host configuration and options
- `identityserver-ui-flows` — Login/logout UI flows
- `identityserver-upgrade-v7-to-v8` — Migration guide for v8 (includes User Management as new feature)
- `aspnetcore-authentication` — ASP.NET Core authentication fundamentals
identity-testing-patterns34.6 KB
---
name: identity-testing-patterns
description: Testing patterns for IdentityServer-based systems including integration testing with WebApplicationFactory, mock token issuance, test authority configuration, protocol response validation, and end-to-end authentication flow testing.
invocable: false
---
# Identity Testing Patterns
## When to Use This Skill
Use this skill when:
- Writing integration tests for applications that issue or validate tokens using Duende IdentityServer
- Hosting IdentityServer in-memory with `WebApplicationFactory<T>` to test grant flows end-to-end
- Creating mock JWT tokens for testing protected APIs without a live authority
- Testing custom `IProfileService` implementations or claim transformation logic
- Verifying `IAuthorizationHandler` and policy-based authorization against specific claim sets
- Testing BFF endpoints that rely on cookie-based sessions and proxied API calls
- Scaffolding an integration-test project from the `duende-is-inmem` template to test IdentityServer itself
- Running a post-deployment login-flow smoke test without a headless browser
## Core Principles
1. **Integration over unit** — Test token issuance, claim mapping, and policy enforcement against a real (in-process) IdentityServer instance. Avoid mocking the token pipeline itself; mock only external I/O (databases, downstream services).
2. **In-process authority** — Use `WebApplicationFactory<T>` to host IdentityServer inside the test process. This avoids network round-trips, eliminates certificate trust issues, and makes tests deterministic.
3. **Predictable signing keys** — Override key management in tests with a static development signing key so token signatures are verifiable without key rotation logic.
4. **Minimal test clients** — Register only the clients, scopes, and resources each test needs. Over-broad test configurations mask permission bugs.
5. **Test auth handler for API tests** — When testing protected APIs in isolation (without a live token endpoint), replace JWT Bearer authentication with a `TestAuthHandler` that accepts a fake scheme. Never disable authorization wholesale.
6. **Builder pattern for test data** — Use fluent builders for `Client`, `ApiScope`, `ApiResource`, and test users to keep test setup readable and reduce duplication.
## Related Skills
- `identityserver-configuration` — Production client and resource registration patterns
- `aspnetcore-authentication` — OIDC and JWT Bearer handler configuration
- `aspnetcore-authorization` — Policy definitions and requirement handlers
- `claims-authorization` — `IProfileService` and claim pipeline internals
- `duende-bff` — BFF session and proxy architecture being tested
Docs: https://docs.duendesoftware.com/identityserver/fundamentals
---
## Sub-Documents
| Document | Description | When to Load |
|----------|-------------|--------------|
| [docs/bff-testing.md](docs/bff-testing.md) | BFF endpoint testing with cookie simulation, antiforgery headers, and OIDC redirect bypass | BFF testing, CookieContainer, x-csrf header, BffFactory, session simulation |
| [docs/aspire-testing.md](docs/aspire-testing.md) | Full-stack Aspire testing with identity server health checks and token endpoint wiring | Aspire testing, DistributedApplicationTestingBuilder, WaitForResourceHealthyAsync, end-to-end |
---
## Testing Strategy Overview
| What to test | Recommended approach |
|---|---|
| Token issuance (client credentials, code flow) | In-process `WebApplicationFactory` hitting `/connect/token` |
| Claim mapping / `IProfileService` | Unit test with `DefaultProfileService` + mock context, or integration test |
| Authorization policy requirements | `IAuthorizationService` + `TestAuthHandler` in integration test |
| `IAuthorizationHandler` logic | Direct unit test with `AuthorizationHandlerContext` |
| Protected API access control | `WebApplicationFactory` with `TestAuthHandler` and constructed `ClaimsPrincipal` |
| BFF endpoints (login/logout/user) | `WebApplicationFactory` with cookie simulation |
| EF Core store implementations | In-memory EF provider or isolated SQL container |
| Deployed login flow (smoke test) | Cookie-aware `HttpClient` + AngleSharp HTML parsing (Pattern 10) |
---
## Pattern 1: WebApplicationFactory for IdentityServer
Host a complete IdentityServer in-memory. Override configuration to inject test clients, resources, and a static signing key.
### Required NuGet Packages
```xml
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="*" />
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />
<PackageReference Include="IdentityModel" Version="*" />
</ItemGroup>
```
### IdentityServer WebApplicationFactory
```csharp
// ✅ Factory that runs a real IdentityServer in-process
public sealed class IdentityServerFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureTestServices(services =>
{
// Remove any existing IdentityServer registration to replace it cleanly
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IConfigureOptions<IdentityServerOptions>));
if (descriptor is not null)
services.Remove(descriptor);
services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseFailureEvents = true;
// Disable automatic key management — use a static key for predictability
options.KeyManagement.Enabled = false;
})
.AddInMemoryClients(TestConfig.Clients)
.AddInMemoryApiScopes(TestConfig.ApiScopes)
.AddInMemoryApiResources(TestConfig.ApiResources)
.AddInMemoryIdentityResources(TestConfig.IdentityResources)
.AddTestUsers(TestConfig.Users)
// Static development signing key — never use this in production
.AddDeveloperSigningCredential(persistKey: false);
});
}
}
```
### Requesting a Token in a Test
```csharp
[Collection("IdentityServer")]
public class TokenEndpointTests : IClassFixture<IdentityServerFactory>
{
private readonly HttpClient _client;
public TokenEndpointTests(IdentityServerFactory factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task ClientCredentials_ShouldReturnAccessToken()
{
var response = await _client.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = "https://localhost/connect/token",
ClientId = "test.service",
ClientSecret = "test-secret",
Scope = "api1"
});
Assert.False(response.IsError, response.Error);
Assert.NotEmpty(response.AccessToken);
Assert.Equal("Bearer", response.TokenType);
}
[Fact]
public async Task ClientCredentials_InvalidScope_ShouldReturnError()
{
var response = await _client.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = "https://localhost/connect/token",
ClientId = "test.service",
ClientSecret = "test-secret",
Scope = "not.allowed" // ❌ scope not granted to this client
});
Assert.True(response.IsError);
Assert.Equal("invalid_scope", response.Error);
}
}
```
### Testing IdentityServer Itself from the `duende-is-inmem` Template
The fastest way to get a real, in-process IdentityServer under test is to scaffold from the in-memory template `duende-is-inmem` (from the **`Duende.Templates`** NuGet package) and add a test project that references the host. Unlike the `TestAuthHandler`/`TestTokenFactory` patterns below — which mock auth in a *downstream API* — this exercises IdentityServer's **real** endpoints and token issuance (no auth mocking).
**Test project setup:**
1. The test project must use the **Web SDK** so ASP.NET Core testing APIs resolve. Change the top of the `.csproj`:
```xml
<!-- ❌ Default test project SDK -->
<Project Sdk="Microsoft.NET.Sdk">
<!-- ✅ Web SDK — required for WebApplicationFactory<T> against a web host -->
<Project Sdk="Microsoft.NET.Sdk.Web">
```
2. Add packages to the test project:
```xml
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="*" /> <!-- WebApplicationFactory<T> -->
<PackageReference Include="Duende.IdentityModel" Version="*" /> <!-- OIDC/OAuth client helpers -->
```
3. Reference the IdentityServer host project (`<ProjectReference Include="..\IdentityServerHost\IdentityServerHost.csproj" />`).
4. Make the template's static `Config` collections mutable so tests can add/clear clients and scopes:
```csharp
// Config.cs — expose List<> instead of IEnumerable<> so tests can mutate
public static List<Client> Clients = [ /* ... */ ];
public static List<ApiScope> ApiScopes = [ /* ... */ ];
public static List<IdentityResource> IdentityResources = [ /* ... */ ];
```
> **Caution:** With xUnit's parallel test execution, mutating shared static `Config` collections causes cross-test interference. Prefer per-test collections (or a fresh factory per test) over mutating shared statics.
**Test class using the primary-constructor `IClassFixture`:**
```csharp
public class IdentityServerTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
// factory.CreateClient() serves the host over https on localhost
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task Discovery_document_is_available()
{
var disco = await _client.GetDiscoveryDocumentAsync();
Assert.False(disco.IsError);
}
[Fact]
public async Task Can_request_client_credentials_token()
{
var token = await _client.RequestClientCredentialsTokenAsync(new()
{
Address = "connect/token",
ClientId = "m2m.client",
ClientSecret = "secret",
Scope = "api1"
});
Assert.False(token.IsError);
Assert.NotNull(token.AccessToken);
}
[Fact]
public void Can_resolve_in_process_services()
{
// Reach into the running host's DI container
using var scope = factory.Services.CreateScope();
var profileService = scope.ServiceProvider.GetRequiredService<IProfileService>();
Assert.NotNull(profileService);
}
}
```
> The discovery/token helpers (`GetDiscoveryDocumentAsync`, `RequestClientCredentialsTokenAsync`) come from **`Duende.IdentityModel`**. Accessing `factory.Services` lets you assert on real in-process services such as `IProfileService`, stores, or options.
---
## Pattern 2: Test Configuration Builders
Use static builders — not scattered inline literals — so every test builds from a consistent baseline.
```csharp
public static class TestConfig
{
public static IEnumerable<Client> Clients =>
[
ClientBuilder.ClientCredentials("test.service", "test-secret")
.WithScopes("api1", "api2.read")
.Build(),
ClientBuilder.AuthorizationCode("test.webapp", "webapp-secret")
.WithRedirectUri("https://testapp/signin-oidc")
.WithScopes("openid", "profile", "api1")
.Build()
];
public static IEnumerable<ApiScope> ApiScopes =>
[
new ApiScope("api1", "Primary API"),
new ApiScope("api2.read", "Read from API 2")
];
public static IEnumerable<ApiResource> ApiResources =>
[
new ApiResource("api1-resource", "API 1 Resource")
{
Scopes = { "api1" }
}
];
public static IEnumerable<IdentityResource> IdentityResources =>
[
new IdentityResources.OpenId(),
new IdentityResources.Profile()
];
public static List<TestUser> Users =>
[
TestUserBuilder.Active("alice", "Password1!")
.WithClaim("email", "alice@example.com")
.WithClaim("role", "admin")
.Build(),
TestUserBuilder.Active("bob", "Password1!")
.WithClaim("email", "bob@example.com")
.Build()
];
}
```
### Client Builder
```csharp
public sealed class ClientBuilder
{
private readonly Client _client = new();
public static ClientBuilder ClientCredentials(string clientId, string secret)
{
var builder = new ClientBuilder();
builder._client.ClientId = clientId;
builder._client.AllowedGrantTypes = GrantTypes.ClientCredentials;
builder._client.ClientSecrets = [new Secret(secret.Sha256())];
return builder;
}
public static ClientBuilder AuthorizationCode(string clientId, string secret)
{
var builder = new ClientBuilder();
builder._client.ClientId = clientId;
builder._client.AllowedGrantTypes = GrantTypes.Code;
builder._client.RequirePkce = true;
builder._client.ClientSecrets = [new Secret(secret.Sha256())];
builder._client.AllowOfflineAccess = true;
return builder;
}
public ClientBuilder WithScopes(params string[] scopes)
{
foreach (var scope in scopes)
_client.AllowedScopes.Add(scope);
return this;
}
public ClientBuilder WithRedirectUri(string uri)
{
_client.RedirectUris.Add(uri);
return this;
}
public Client Build() => _client;
}
```
### TestUser Builder
```csharp
public sealed class TestUserBuilder
{
private readonly TestUser _user = new();
public static TestUserBuilder Active(string username, string password)
{
var builder = new TestUserBuilder();
builder._user.SubjectId = Guid.NewGuid().ToString("N");
builder._user.Username = username;
builder._user.Password = password;
builder._user.IsActive = true;
return builder;
}
public TestUserBuilder WithSubject(string subjectId)
{
_user.SubjectId = subjectId;
return this;
}
public TestUserBuilder WithClaim(string type, string value)
{
_user.Claims.Add(new Claim(type, value));
return this;
}
public TestUser Build() => _user;
}
```
---
## Pattern 3: Mock Token Issuance
When testing a protected API in isolation (no live IdentityServer needed), issue a self-signed JWT in the test and configure the API to trust it. This avoids spinning up an IdentityServer host for every API test.
### Generating a Self-Signed Test Token
```csharp
public static class TestTokenFactory
{
// Static key shared between the token factory and the test auth configuration
private static readonly RsaSecurityKey TestSigningKey = CreateRsaKey();
public static SecurityKey SigningKey => TestSigningKey;
private static RsaSecurityKey CreateRsaKey()
{
var rsa = RSA.Create(2048);
return new RsaSecurityKey(rsa) { KeyId = "test-key-1" };
}
public static string CreateAccessToken(
string subject,
string audience,
IEnumerable<Claim> claims,
TimeSpan? lifetime = null)
{
var allClaims = new List<Claim>
{
new(JwtClaimTypes.Subject, subject),
new(JwtClaimTypes.JwtId, Guid.NewGuid().ToString())
};
allClaims.AddRange(claims);
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(allClaims),
Audience = audience,
Issuer = "https://test-authority",
Expires = DateTime.UtcNow.Add(lifetime ?? TimeSpan.FromMinutes(5)),
SigningCredentials = new SigningCredentials(
TestSigningKey,
SecurityAlgorithms.RsaSha256),
// ✅ RFC 9068: access tokens must carry typ=at+jwt
TokenType = "at+jwt"
};
var handler = new JsonWebTokenHandler();
return handler.CreateToken(tokenDescriptor);
}
}
```
### Configuring the API to Trust the Test Token
```csharp
// ✅ In WebApplicationFactory for the API project
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Remove production JWT Bearer authentication
var jwtDescriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(IConfigureOptions<JwtBearerOptions>));
if (jwtDescriptor is not null)
services.Remove(jwtDescriptor);
// Replace with test-friendly JWT Bearer that trusts our static key
services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = TestTokenFactory.SigningKey,
ValidateIssuer = true,
ValidIssuer = "https://test-authority",
ValidateAudience = true,
ValidAudience = "my-api",
ValidateLifetime = true,
ClockSkew = TimeSpan.Zero
};
});
});
}
```
### Using the Test Token in a Test
```csharp
[Fact]
public async Task GetProducts_WithValidToken_ShouldReturn200()
{
var token = TestTokenFactory.CreateAccessToken(
subject: "user-123",
audience: "my-api",
claims: [new Claim("scope", "api1"), new Claim("role", "viewer")]);
_client.SetBearerToken(token);
var response = await _client.GetAsync("/api/products");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetProducts_WithoutToken_ShouldReturn401()
{
var response = await _client.GetAsync("/api/products");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task DeleteProduct_WithViewerRole_ShouldReturn403()
{
var token = TestTokenFactory.CreateAccessToken(
subject: "user-123",
audience: "my-api",
claims: [new Claim("scope", "api1"), new Claim("role", "viewer")]); // ❌ missing "admin"
_client.SetBearerToken(token);
var response = await _client.DeleteAsync("/api/products/1");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
```
---
## Pattern 4: TestAuthHandler for Protected API Tests
For APIs that use `[Authorize]`, replace the authentication handler entirely with a `TestAuthHandler` that accepts any pre-built `ClaimsPrincipal`. This gives full control over identity in each test without token serialization.
```csharp
// ✅ TestAuthHandler — injects a ClaimsPrincipal directly into the pipeline
public sealed class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "Test";
private readonly ITestClaimsProvider _claimsProvider;
public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ITestClaimsProvider claimsProvider)
: base(options, logger, encoder)
{
_claimsProvider = claimsProvider;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = _claimsProvider.GetClaims();
if (claims is null)
return Task.FromResult(AuthenticateResult.NoResult());
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
// Swap this per-test to change the authenticated user
public interface ITestClaimsProvider
{
IEnumerable<Claim>? GetClaims();
}
public sealed class TestClaimsProvider : ITestClaimsProvider
{
private IEnumerable<Claim>? _claims;
public void SetClaims(IEnumerable<Claim> claims) => _claims = claims;
public void ClearClaims() => _claims = null;
public IEnumerable<Claim>? GetClaims() => _claims;
}
```
### Factory Registration
```csharp
public sealed class ApiFactory : WebApplicationFactory<Program>
{
// Expose so tests can configure the identity per-test
public TestClaimsProvider ClaimsProvider { get; } = new();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.AddSingleton<ITestClaimsProvider>(ClaimsProvider);
services.AddAuthentication(TestAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName, _ => { });
});
}
}
```
### Test Using the Handler
```csharp
public class ProductsApiTests : IClassFixture<ApiFactory>
{
private readonly ApiFactory _factory;
private readonly HttpClient _client;
public ProductsApiTests(ApiFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}
[Fact]
public async Task GetProducts_AsAdmin_ShouldSucceed()
{
_factory.ClaimsProvider.SetClaims(
[
new Claim(JwtClaimTypes.Subject, "user-001"),
new Claim(JwtClaimTypes.Name, "Alice"),
new Claim("role", "admin"),
new Claim("scope", "api1")
]);
var response = await _client.GetAsync("/api/products");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetProducts_Unauthenticated_ShouldReturn401()
{
_factory.ClaimsProvider.ClearClaims(); // No claims = not authenticated
var response = await _client.GetAsync("/api/products");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
```
---
## Pattern 5: Testing IProfileService
Unit test `IProfileService` implementations directly against the `ProfileDataRequestContext` contract. Use real `ProfileDataRequestContext` instances — do not mock the context.
```csharp
public class CustomProfileServiceTests
{
private readonly CustomProfileService _sut;
private readonly Mock<IUserRepository> _userRepo;
public CustomProfileServiceTests()
{
_userRepo = new Mock<IUserRepository>();
_sut = new CustomProfileService(_userRepo.Object);
}
[Fact]
public async Task GetProfileData_ShouldIncludeRoleClaimsForAccessToken()
{
// Arrange: a subject with known sub claim
var subject = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(JwtClaimTypes.Subject, "user-123")
]));
_userRepo
.Setup(r => r.GetRolesAsync("user-123", CancellationToken.None))
.ReturnsAsync(["admin", "billing"]);
var context = new ProfileDataRequestContext(
subject: subject,
client: new Client { ClientId = "test.client" },
caller: "test",
requestedClaimTypes: [JwtClaimTypes.Role]);
// Act
await _sut.GetProfileDataAsync(context);
// Assert
var roles = context.IssuedClaims
.Where(c => c.Type == JwtClaimTypes.Role)
.Select(c => c.Value)
.ToList();
Assert.Contains("admin", roles);
Assert.Contains("billing", roles);
}
[Fact]
public async Task IsActive_WithDeactivatedUser_ShouldSetIsActiveFalse()
{
var subject = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(JwtClaimTypes.Subject, "user-deactivated")
]));
_userRepo
.Setup(r => r.IsActiveAsync("user-deactivated", CancellationToken.None))
.ReturnsAsync(false);
var context = new IsActiveContext(
subject: subject,
client: new Client { ClientId = "test.client" },
caller: "test");
await _sut.IsActiveAsync(context);
Assert.False(context.IsActive);
}
}
```
> **Note:** `ProfileDataRequestContext` and `IsActiveContext` constructors are internal to Duende IdentityServer in some versions. If the constructors are inaccessible, test through the in-process `WebApplicationFactory` by issuing a real token and inspecting its claims with `JsonWebTokenHandler`.
---
## Pattern 6: Testing Authorization Policies
### Unit Testing an IAuthorizationHandler
Test `IAuthorizationHandler` implementations in isolation by constructing `AuthorizationHandlerContext` with synthetic claims.
```csharp
public class MinimumAgeHandlerTests
{
private readonly MinimumAgeHandler _sut = new();
[Fact]
public async Task HandleRequirement_WithSufficientAge_ShouldSucceed()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(JwtClaimTypes.BirthDate, "1990-01-01")
], "Bearer"));
var requirement = new MinimumAgeRequirement(18);
var context = new AuthorizationHandlerContext(
[requirement], user, resource: null);
await _sut.HandleAsync(context);
Assert.True(context.HasSucceeded);
}
[Fact]
public async Task HandleRequirement_WithInsufficientAge_ShouldNotSucceed()
{
var user = new ClaimsPrincipal(new ClaimsIdentity(
[
new Claim(JwtClaimTypes.BirthDate,
DateTime.UtcNow.AddYears(-10).ToString("yyyy-MM-dd"))
], "Bearer"));
var requirement = new MinimumAgeRequirement(18);
var context = new AuthorizationHandlerContext(
[requirement], user, resource: null);
await _sut.HandleAsync(context);
Assert.False(context.HasSucceeded);
}
}
```
### Integration Testing Policy Enforcement
Verify that policies enforce correctly against real endpoints using `TestAuthHandler`:
```csharp
[Fact]
public async Task AdminEndpoint_WithoutAdminRole_ShouldReturn403()
{
_factory.ClaimsProvider.SetClaims(
[
new Claim(JwtClaimTypes.Subject, "user-002"),
new Claim("role", "viewer") // ❌ not an admin
]);
var response = await _client.DeleteAsync("/api/admin/users/42");
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task AdminEndpoint_WithAdminRole_ShouldReturn204()
{
_factory.ClaimsProvider.SetClaims(
[
new Claim(JwtClaimTypes.Subject, "user-001"),
new Claim("role", "admin")
]);
var response = await _client.DeleteAsync("/api/admin/users/42");
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
```
---
## Pattern 7: Testing BFF Endpoints
BFF tests require cookie-based session simulation using `CookieContainer` + `HttpClientHandler`. Set `AllowAutoRedirect = false` so session redirects don't swallow status codes. Include `x-csrf: 1` header on all BFF local API calls — missing it returns 400. Override the OIDC `OnRedirectToIdentityProvider` event to bypass external redirects in tests.
> See [docs/bff-testing.md](docs/bff-testing.md) for the complete `BffFactory`, `CookieContainer` setup, and antiforgery header test examples.
---
## Pattern 8: Testing with Aspire (Full-Stack)
Wire IdentityServer as a named Aspire resource, then use `WaitForResourceHealthyAsync("idp", cts.Token)` before requesting tokens. Obtain `idp` endpoint via `_app.GetEndpoint("idp", "https")` and pass it to `RequestClientCredentialsTokenAsync`.
> See [docs/aspire-testing.md](docs/aspire-testing.md) for the complete AppHost wiring and test fixture setup.
---
## Pattern 9: Validating Issued Token Claims
After issuing a token through the in-process IdentityServer, parse the JWT and assert on its claims without making a separate network call.
```csharp
[Fact]
public async Task IssuedToken_ShouldContainExpectedClaims()
{
var tokenResponse = await _client.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = "https://localhost/connect/token",
ClientId = "test.service",
ClientSecret = "test-secret",
Scope = "api1"
});
Assert.False(tokenResponse.IsError);
// ✅ Parse without validation (signature not verifiable externally)
// or configure validation parameters matching the dev signing key
var handler = new JsonWebTokenHandler();
var jwt = handler.ReadJsonWebToken(tokenResponse.AccessToken);
Assert.Equal("test.service", jwt.GetClaim(JwtClaimTypes.ClientId).Value);
Assert.Contains("api1", jwt.GetClaim(JwtClaimTypes.Scope).Value.Split(' '));
Assert.Equal("https://localhost", jwt.Issuer);
Assert.True(jwt.ValidTo > DateTime.UtcNow);
}
```
---
## Pattern 10: Post-Deployment Login Smoke Test (No Headless Browser)
Verify a real login flow against a deployed environment **without** Playwright/Selenium by driving a cookie-aware `HttpClient` and parsing HTML with **AngleSharp**. This exercises the interactive authorize → login-form → post-back → redirect-back chain end to end.
```
dotnet add package AngleSharp
```
```csharp
using AngleSharp.Html.Parser;
[Fact]
public async Task User_can_log_in_via_the_login_form()
{
// ✅ Cookie-aware client so the antiforgery + auth cookies flow across requests
var cookies = new CookieContainer();
using var handler = new HttpClientHandler { CookieContainer = cookies };
using var client = new HttpClient(handler) { BaseAddress = new Uri("https://app.example.com") };
// 1) GET the protected URL — auto-redirects to the IdentityServer login page
var loginPage = await client.GetAsync("/protected");
// 2) Parse the login HTML and read the antiforgery token from the form
var html = await loginPage.Content.ReadAsStringAsync();
var doc = await new HtmlParser().ParseDocumentAsync(html);
var form = doc.QuerySelector("form")!;
var antiforgery = form.QuerySelector("input[name='__RequestVerificationToken']")!
.GetAttribute("value");
// 3) POST credentials to the form's resolved action URL
var action = new Uri(loginPage.RequestMessage!.RequestUri!, form.GetAttribute("action"));
var result = await client.PostAsync(action, new FormUrlEncodedContent(new Dictionary<string, string>
{
["Username"] = "alice",
["Password"] = "alice",
["__RequestVerificationToken"] = antiforgery!,
["button"] = "login"
}));
// 4) Success = we ended back on the original protected host (login redirected us home)
Assert.Equal(new Uri("https://app.example.com").Host,
result.RequestMessage!.RequestUri!.Host);
}
```
> Field names (`Username`, `Password`, `__RequestVerificationToken`, `button="login"`) assume the **default template login form**. Adjust selectors if you customized the login UI. This is a smoke test — it confirms the deployed flow works, not per-claim correctness (use the in-process patterns above for that).
---
## Common Pitfalls
### 1. Not Disabling Automatic Key Management in Tests
```csharp
// ❌ WRONG — Automatic key management tries to write key files to disk in CI
services.AddIdentityServer();
// ✅ CORRECT — Use a static developer key in tests
services.AddIdentityServer(options =>
{
options.KeyManagement.Enabled = false;
})
.AddDeveloperSigningCredential(persistKey: false);
```
### 2. Disabling Authorization Entirely in Tests
```csharp
// ❌ WRONG — Removing authorization makes every endpoint open; you can't test 403 behavior
services.AddSingleton<IAuthorizationHandler, AllowAllHandler>();
// ✅ CORRECT — Use TestAuthHandler to control the identity per-test
// Authorization runs normally; only the authentication source changes
```
### 3. Hard-Coding Localhost Ports
```csharp
// ❌ WRONG — Port conflicts in CI
new ClientCredentialsTokenRequest
{
Address = "http://localhost:5001/connect/token",
...
}
// ✅ CORRECT — Use the client's BaseAddress via the factory
_client = factory.CreateClient(); // BaseAddress is set to the test server
new ClientCredentialsTokenRequest
{
Address = new Uri(_client.BaseAddress!, "connect/token").ToString(),
...
}
```
### 4. Forgetting to Add the openid Scope for Interactive Flows
```csharp
// ❌ WRONG — Without openid scope, no ID token is returned
new Client
{
AllowedGrantTypes = GrantTypes.Code,
AllowedScopes = { "profile", "api1" } // Missing openid!
}
// ✅ CORRECT
new Client
{
AllowedGrantTypes = GrantTypes.Code,
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
"api1"
}
}
```
### 5. Sharing a Single HttpClient Across Tests with TestAuthHandler
```csharp
// ❌ WRONG — Identity set in one test bleeds into the next
public class MyTests : IClassFixture<ApiFactory>
{
private static readonly HttpClient _sharedClient = factory.CreateClient();
// ClaimsProvider state is shared and can be set by different tests in parallel
// ✅ CORRECT — Create a fresh client per test, or reset ClaimsProvider in IAsyncLifetime
public async Task InitializeAsync()
{
_factory.ClaimsProvider.ClearClaims();
await Task.CompletedTask;
}
```
### 6. Not Awaiting Token Endpoint During Aspire Startup
```csharp
// ❌ WRONG — IdentityServer may not be ready when the first test runs
await _app.StartAsync(cts.Token);
// Immediately request a token — connection refused
// ✅ CORRECT — Wait for the identity service to be healthy first
await _app.ResourceNotifications.WaitForResourceHealthyAsync("idp", cts.Token);
```
### 7. Incorrect Audience in Self-Signed Test Tokens
```csharp
// ❌ WRONG — Audience in token doesn't match API's expected audience
var token = TestTokenFactory.CreateAccessToken(
subject: "user-1",
audience: "wrong-api", // API expects "my-api"
claims: []);
// ✅ CORRECT — Audience must match ValidAudience in the token validation parameters
var token = TestTokenFactory.CreateAccessToken(
subject: "user-1",
audience: "my-api",
claims: []);
```
---
## Resources
- [Duende IdentityServer Quickstarts](https://docs.duendesoftware.com/identityserver/quickstarts/)
- [Duende IdentityServer Samples — GitHub](https://github.com/DuendeSoftware/Samples/tree/main/IdentityServer)
- [ASP.NET Core Integration Tests with WebApplicationFactory](https://learn.microsoft.com/aspnet/core/test/integration-tests)
- [IProfileService Reference — Duende Docs](https://docs.duendesoftware.com/identityserver/reference/services/profile-service/)
- [Protecting APIs with JWT — Duende Docs](https://docs.duendesoftware.com/identityserver/apis/aspnetcore/jwt/)
- [ASP.NET Core Authorization Tests — Microsoft Docs](https://learn.microsoft.com/aspnet/core/security/authorization/policies)
- [IdentityModel Client Library](https://docs.duendesoftware.com/identitymodel/)
- [Duende BFF Samples](https://docs.duendesoftware.com/bff/samples/)
Referenced files: 2
oauth-oidc-protocols19.1 KB
---
name: oauth-oidc-protocols
description: OAuth 2.0 and OpenID Connect protocol fundamentals including authorization code flow with PKCE, client credentials, refresh tokens, discovery documents, JWKS, and token introspection. Protocol-level troubleshooting and compliance.
invocable: false
---
# OAuth 2.0 & OpenID Connect Protocols
## When to Use This Skill
Use this skill when:
- Choosing the correct OAuth 2.0 grant type for a scenario
- Debugging token exchange flows or redirect-based authentication
- Understanding what claims and headers appear in identity tokens vs access tokens
- Implementing or troubleshooting PKCE (Proof Key for Code Exchange)
- Working with discovery documents, JWKS endpoints, or token introspection
- Reviewing security properties of different protocol flows
- Implementing refresh token rotation or token revocation
## Core Principles
1. **OAuth 2.0 is for Authorization, OIDC is for Authentication** — OAuth alone does not tell you *who* the user is. OpenID Connect adds an identity layer (the ID token) on top of OAuth.
2. **Authorization Code + PKCE is the Universal Flow** — Use it for web apps, SPAs (via BFF), native apps, and any interactive scenario. It replaced implicit flow.
3. **Tokens are Opaque to Clients** — Clients should not parse access tokens. Only the resource server (API) validates access tokens. Clients use the ID token for authentication.
4. **Discovery Documents are the Source of Truth** — Always resolve endpoints from `/.well-known/openid-configuration` rather than hardcoding URLs.
5. **Refresh Tokens Require Secure Storage** — Refresh tokens are long-lived credentials. Rotate them on every use (`OneTimeOnly`) and store them server-side.
## Related Skills
- `identityserver-configuration` — Server-side configuration of clients, resources, and scopes
- `aspnetcore-authentication` — Implementing OIDC authentication in ASP.NET Core apps
- `token-management` — Automated token lifecycle with Duende.AccessTokenManagement
- `identity-security-hardening` — Security hardening including DPoP, PAR, and FAPI
- `duende-bff` — Backend-for-Frontend pattern for SPAs
Docs: https://docs.duendesoftware.com/identityserver/fundamentals
---
## Concept 1: The OAuth 2.0 / OIDC Mental Model
### Roles
| Role | OAuth 2.0 Term | OIDC Term | Example |
|------|---------------|-----------|---------|
| User | Resource Owner | End-User | A person logging in |
| Browser/App | Client | Relying Party (RP) | ASP.NET Core web app |
| Token Server | Authorization Server | OpenID Provider (OP) | Duende IdentityServer |
| API | Resource Server | — | ASP.NET Core Web API |
### Tokens
| Token | Purpose | Who Consumes It | Format |
|-------|---------|----------------|--------|
| **ID Token** | Proves user identity | Client application | Always JWT |
| **Access Token** | Authorizes API calls | Resource server (API) | JWT or reference |
| **Refresh Token** | Obtains new access tokens | Client application | Opaque handle |
> **Critical Rule:** Clients authenticate users with the **ID token**. Clients authorize API calls with the **access token**. Never use an access token to determine who a user is. Never send an ID token to an API.
---
## Concept 2: Grant Types (Flows)
### Authorization Code + PKCE (Recommended for All Interactive Scenarios)
The authorization code flow with PKCE is the recommended flow for all clients that involve a user. PKCE prevents authorization code interception attacks.
**How it works:**
1. Client generates a random `code_verifier` and its SHA256 hash `code_challenge`
2. Client redirects user to the authorize endpoint with `code_challenge`
3. User authenticates at IdentityServer and consents (if required)
4. IdentityServer redirects back with an authorization `code`
5. Client exchanges the `code` + `code_verifier` at the token endpoint
6. IdentityServer verifies the verifier matches the original challenge
7. IdentityServer returns ID token + access token (+ refresh token if `offline_access` scope)
```
┌──────┐ ┌──────────┐ ┌──────────────┐
│Client│ │ Browser │ │IdentityServer│
└──┬───┘ └────┬─────┘ └──────┬───────┘
│ 1. Generate PKCE │ │
│ code_verifier │ │
│ code_challenge │ │
│ │ │
│ 2. Redirect ───────────────────────────► │
│ /authorize?code_challenge=... │
│ │ 3. User logs in │
│ │ ◄──────────────────► │
│ │ │
│ 4. Redirect back ◄────────────────────── │
│ ?code=abc123 │ │
│ │ │
│ 5. POST /token ─────────────────────────►│
│ code=abc123&code_verifier=... │
│ │ │
│ 6. Tokens ◄──────────────────────────────│
│ { id_token, access_token, │
│ refresh_token } │
└───────────────────┴───────────────────────┘
```
**When to use:** Web applications, native apps, SPAs (via BFF pattern).
### Client Credentials
For machine-to-machine communication with no user involvement.
```
┌──────────┐ ┌──────────────┐
│ Service │ │IdentityServer│
└────┬─────┘ └──────┬───────┘
│ POST /token │
│ grant_type=client_credentials │
│ client_id=... │
│ client_secret=... │
│ scope=api1 │
│ ───────────────────────────► │
│ │
│ { access_token } │
│ ◄─────────────────────────── │
└─────────────────────────────────┘
```
**When to use:** Background services, daemons, server-to-server API calls.
**Key difference:** No user identity — the access token contains only client claims, not user claims.
### Refresh Token Exchange
```
┌──────┐ ┌──────────────┐
│Client│ │IdentityServer│
└──┬───┘ └──────┬───────┘
│ POST /token │
│ grant_type=refresh_token │
│ refresh_token=old_rt │
│ ─────────────────────────────► │
│ │
│ { access_token, │
│ refresh_token: new_rt } │
│ ◄───────────────────────────── │
└────────────────────────────────────┘
```
> With `RefreshTokenUsage = OneTimeOnly`, each refresh returns a **new** refresh token. The old one is invalidated. This enables **refresh token rotation**, a key security measure. Note: the default changed to `ReUse` in IdentityServer v7.0 — set `OneTimeOnly` explicitly for rotation.
### Deprecated / Discouraged Flows
| Flow | Status | Why |
|------|--------|-----|
| Implicit (`token` / `id_token`) | **Deprecated** | Tokens in URL fragments; no PKCE protection |
| Resource Owner Password (ROPC) | **Discouraged** | Client handles credentials directly; no MFA support |
| Hybrid | **Replaced** | Use Authorization Code + PKCE instead |
---
## Concept 3: Discovery and JWKS
### Discovery Document
Every OpenID Connect provider publishes a discovery document at `/.well-known/openid-configuration`. This JSON document advertises:
- Endpoint URLs (authorize, token, userinfo, introspection, revocation, end\_session)
- Supported grant types, scopes, claims, and response types
- Signing algorithms and JWKS URI
- Token endpoint authentication methods
```csharp
// ✅ Use IdentityModel to fetch discovery programmatically
using var httpClient = new HttpClient();
var disco = await httpClient.GetDiscoveryDocumentAsync("https://identity.example.com");
if (disco.IsError) throw new Exception(disco.Error);
var tokenEndpoint = disco.TokenEndpoint;
var jwksUri = disco.JwksUri;
```
> **Best Practice:** Never hardcode endpoint URLs. Always resolve them from the discovery document. This ensures your application adapts to URL changes and load balancer configurations.
### JWKS (JSON Web Key Set)
The JWKS endpoint (advertised via the `jwks_uri` field in the discovery document; in Duende IdentityServer this is `/.well-known/openid-configuration/jwks`) publishes the public keys used to verify token signatures. APIs and clients fetch this to validate JWTs.
**Key rotation:** When IdentityServer rotates signing keys, the new key appears in JWKS during the propagation period before it becomes the active signing key. Client libraries cache JWKS for 24 hours by default.
---
## Concept 4: Token Anatomy
### ID Token (JWT)
```json
{
"iss": "https://identity.example.com",
"sub": "818727",
"aud": "web.app",
"exp": 1311281970,
"iat": 1311280970,
"nonce": "n-0S6_WzA2Mj",
"auth_time": 1311280969,
"at_hash": "77QmUPtjPfzWtF2AnpK9RQ",
"amr": ["pwd", "mfa"],
"name": "Alice Smith",
"email": "alice@example.com"
}
```
**Key claims:**
- `iss` — Issuer (must match your IdentityServer URL)
- `sub` — Subject (unique user identifier)
- `aud` — Audience (must match the client ID)
- `nonce` — Replay protection (sent in authorize request, echoed in token)
- `at_hash` — Hash of the access token (binds the ID token to the access token)
- `amr` — Authentication methods used
### Access Token (JWT)
```json
{
"iss": "https://identity.example.com",
"aud": "https://api.example.com",
"client_id": "web.app",
"sub": "818727",
"scope": "openid profile api1",
"exp": 1311284570,
"iat": 1311280970,
"jti": "unique-token-id"
}
```
**Key claims:**
- `aud` — The API resource(s) this token is valid for
- `client_id` — Which client requested this token
- `scope` — Granted permissions
- `jti` — Unique token identifier (for revocation tracking)
### Reference Tokens
Reference tokens are **not** self-contained JWTs. Instead, the access token is an opaque identifier. The API must call the **introspection endpoint** to validate it:
```
POST /connect/introspect
Content-Type: application/x-www-form-urlencoded
token=<reference_token>&token_type_hint=access_token
```
**When to use reference tokens:**
- Tokens contain sensitive claims you don't want exposed to intermediaries
- You need immediate token revocation (JWT lifetimes are not revocable until expiry)
- Token size is a concern (reference tokens are small opaque strings)
---
## Concept 5: Token Introspection and Revocation
### Introspection
APIs validate reference tokens by calling the introspection endpoint. The API authenticates itself with its own secret:
```csharp
// Using IdentityModel
var introspectionResponse = await httpClient.IntrospectTokenAsync(
new TokenIntrospectionRequest
{
Address = disco.IntrospectionEndpoint,
ClientId = "api1",
ClientSecret = "api1-secret",
Token = accessToken
});
if (!introspectionResponse.IsActive)
{
// Token is invalid, expired, or revoked
}
```
### Revocation
Clients can revoke access tokens and refresh tokens:
```csharp
var revocationResponse = await httpClient.RevokeTokenAsync(
new TokenRevocationRequest
{
Address = disco.RevocationEndpoint,
ClientId = "web.app",
ClientSecret = "secret",
Token = refreshToken,
TokenTypeHint = "refresh_token"
});
```
> **What can actually be revoked:** revocation (RFC 7009) only deactivates **reference access tokens and refresh tokens**, which are persisted server-side. A **JWT access token is stateless and cannot be revoked** — it stays valid until `exp`. Use reference tokens when you need immediate invalidation.
### Client Authentication: `private_key_jwt`
Confidential clients can authenticate with a signed JWT assertion instead of a shared secret. The assertion's `aud` MUST be the authorization server's **issuer identifier** (`disco.Issuer`), never the token endpoint URL — the latter enabled token-endpoint confusion attacks (CVE-2025-27370 / CVE-2025-27371). Set the JWT `typ` header to `client-authentication+jwt` to opt into strict audience validation (RFC 7523bis).
---
## Concept 6: Scopes and Claims Mapping
### Scopes Control What's in Tokens
| Scope requested | What it controls | Token affected |
|----------------|-----------------|----------------|
| `openid` | Returns `sub` claim | ID token |
| `profile` | Returns name, family\_name, etc. | ID token / userinfo |
| `email` | Returns email, email\_verified | ID token / userinfo |
| `api1` | Grants access to API | Access token |
| `offline_access` | Returns refresh token | Refresh token issued |
### Claims Destinations
By default, IdentityServer emits identity claims to the ID token and the userinfo endpoint. Claims associated with API scopes go into the access token. The `IProfileService` controls claim emission:
```csharp
public class ProfileService : IProfileService
{
public Task GetProfileDataAsync(ProfileDataRequestContext context)
{
// Add claims based on the requested resources
var claims = GetClaimsForUser(context.Subject);
context.IssuedClaims.AddRange(
claims.Where(c => context.RequestedClaimTypes.Contains(c.Type)));
return Task.CompletedTask;
}
public Task IsActiveAsync(IsActiveContext context)
{
context.IsActive = true; // Check if user account is still active
return Task.CompletedTask;
}
}
```
---
## Concept 7: Security Extensions
### Pushed Authorization Requests (PAR)
PAR moves the authorization parameters from the query string to a backchannel POST, preventing parameter tampering and URL length issues:
```
1. Client POSTs parameters to /connect/par → gets a request_uri
2. Client redirects user to /authorize?request_uri=...&client_id=...
```
### DPoP (Demonstrating Proof-of-Possession)
DPoP binds access tokens to a client's cryptographic key, preventing token theft and replay:
```
1. Client generates a key pair
2. Client creates a DPoP proof (signed JWT with the public key)
3. Client sends the DPoP proof in the DPoP header with the token request
4. IdentityServer binds the token to the key via a "cnf" claim
5. API verifies the DPoP proof matches the token's "cnf" claim
```
### FAPI 2.0
Financial-grade API profile requires PAR, DPoP or mTLS, and stricter validation. Duende IdentityServer supports FAPI 2.0 compliance from v7.3+.
---
## Common Pitfalls
### 1. Using Access Tokens for Authentication
```csharp
// ❌ WRONG — Access tokens are for authorization, not authentication
var userId = accessToken.Claims.First(c => c.Type == "sub").Value;
// The access token's audience is the API, not your app
// ✅ CORRECT — Use the ID token (via the authentication middleware)
var userId = User.FindFirst("sub")?.Value;
```
### 2. Parsing Access Tokens in the Client
```csharp
// ❌ WRONG — Clients should treat access tokens as opaque
var handler = new JwtSecurityTokenHandler();
var jwt = handler.ReadJwtToken(accessToken);
// This breaks when the server switches to reference tokens
// ✅ CORRECT — Only the resource server (API) validates access tokens
// The client just forwards the token in the Authorization header
httpClient.SetBearerToken(accessToken);
```
### 3. Missing PKCE
```csharp
// ❌ WRONG — No PKCE leaves authorization code flow vulnerable
// Duende IdentityServer requires PKCE by default (RequirePkce = true)
// ✅ The ASP.NET Core OIDC handler sends PKCE automatically since .NET 7
// No extra configuration needed on the client side
```
### 4. Ignoring Token Expiration
```csharp
// ❌ WRONG — Using a token without checking expiration
httpClient.SetBearerToken(cachedAccessToken); // Might be expired
// ✅ CORRECT — Use Duende.AccessTokenManagement for automatic refresh
// See the `token-management` skill
builder.Services.AddOpenIdConnectAccessTokenManagement();
```
### 5. Hardcoding Endpoint URLs
```csharp
// ❌ WRONG — Breaks when server URL changes
var tokenEndpoint = "https://identity.example.com/connect/token";
// ✅ CORRECT — Resolve from discovery
var disco = await httpClient.GetDiscoveryDocumentAsync(authority);
var tokenEndpoint = disco.TokenEndpoint;
```
---
## Protocol Debugging Checklist
When a token exchange fails, check these in order:
1. **Discovery document** — Is `/.well-known/openid-configuration` reachable? Does it return valid JSON?
2. **Client ID** — Does the client ID in the request exactly match the server registration?
3. **Redirect URI** — Exact string match including scheme, host, port, path, and trailing slash
4. **Scopes** — Are all requested scopes registered in `AllowedScopes` on the client?
5. **Grant type** — Is the grant type in the request allowed by the client's `AllowedGrantTypes`?
6. **PKCE** — Is the client sending `code_challenge` and `code_verifier`? Duende IS requires PKCE by default.
7. **Client secret** — Is the secret correct? Check for encoding issues (Sha256 hash, not plaintext).
8. **Clock skew** — Is the server time within acceptable bounds for token validation? (default: 5 min)
9. **HTTPS** — Is the authorize redirect using HTTPS? Mixed content blocks cause silent failures.
10. **CORS** — If calling the token endpoint from a browser, is the origin in `AllowedCorsOrigins`?
---
## Resources
- [OAuth 2.0 (RFC 6749)](https://tools.ietf.org/html/rfc6749)
- [OAuth 2.0 for Browser-Based Apps (RFC draft)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps)
- [PKCE (RFC 7636)](https://tools.ietf.org/html/rfc7636)
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
- [JWT Access Tokens (RFC 9068)](https://datatracker.ietf.org/doc/html/rfc9068)
- [Resource Indicators (RFC 8707)](https://tools.ietf.org/html/rfc8707)
- [DPoP (RFC 9449)](https://datatracker.ietf.org/doc/html/rfc9449)
- [PAR (RFC 9126)](https://datatracker.ietf.org/doc/html/rfc9126)
- [Duende IdentityServer Specs — Duende Docs](https://docs.duendesoftware.com/identityserver/overview/specs/)
token-management32.7 KB
---
name: token-management
description: Token management patterns using Duende.AccessTokenManagement. Covers client credential token caching, user token refresh, token storage, HttpClientFactory integration, DPoP support, and common configuration pitfalls. Also includes Blazor Server token management.
invocable: false
---
# Token Management
## When to Use This Skill
Use this skill when:
- Building a .NET worker service or daemon that calls APIs using the client credentials flow
- Building an ASP.NET Core web application that calls APIs on behalf of the currently logged-in user
- Integrating `Duende.AccessTokenManagement` or `Duende.AccessTokenManagement.OpenIdConnect` with `IHttpClientFactory`
- Configuring token caching — in-memory, distributed (Redis), or hybrid — for machine-to-machine tokens
- Adding DPoP (Demonstrating Proof-of-Possession) key binding to access tokens
- Implementing API-to-API delegation where a downstream service calls further APIs with either user tokens or client credentials
- Revoking refresh tokens on user sign-out
## Core Principles
1. **Prefer Automatic Over Manual** — Use `IHttpClientFactory`-integrated clients; they acquire, cache, refresh, and attach tokens transparently. Call `GetAccessTokenAsync` manually only when the factory pattern is insufficient.
2. **Never Cache Tokens in Code** — The library owns the cache. Do not store tokens in instance fields, static variables, or application-managed caches. Call the service on every request and let it serve from cache.
3. **`SaveTokens = true` Is Required for User Tokens** — The OIDC handler must persist tokens into the authentication session. This is the most common misconfiguration.
4. **Refresh Tokens Must Be Revoked at Sign-Out** — Call `e.HttpContext.RevokeRefreshTokenAsync()` in `OnSigningOut` to revoke the refresh token at the authorization server, preventing reuse after logout.
5. **v4 Uses `HybridCache`; v3 Uses `IDistributedCache`** — The caching layer changed between major versions. v4's `HybridCache` is two-tier and automatic; v3 requires an explicit `AddDistributedMemoryCache()` or Redis registration.
6. **Resiliency Is Included in `AddClientCredentialsHttpClient`** — This registration adds a once-retry handler for `401 Unauthorized` responses (handles token expiry and DPoP nonce challenges). When using `AddClientCredentialsTokenHandler` directly, add it explicitly.
## Related Skills
- `aspnetcore-authentication` — cookie and OIDC handler setup required for user token management
- `identityserver-configuration` — configuring the authorization server that issues tokens
- `oauth-oidc-protocols` — protocol fundamentals underlying client credentials and refresh token flows
- `duende-bff` — BFF pattern integrates this library automatically for proxied API calls
Docs: https://docs.duendesoftware.com/accesstokenmanagement/
---
## Pattern 1: Machine-to-Machine (Client Credentials) — Worker Services
### Package
```bash
dotnet add package Duende.AccessTokenManagement
```
### Registration
```csharp
// ✅ Register one or more named client definitions
services.AddClientCredentialsTokenManagement()
.AddClient("catalog.client", client =>
{
client.TokenEndpoint = new Uri("https://sts.company.com/connect/token");
client.ClientId = ClientId.Parse("6f59b670-990f-4ef7-856f-0dd584ed1fac");
client.ClientSecret = ClientSecret.Parse("d0c17c6a-ba47-4654-a874-f6d576cdf799");
client.Scope = Scope.Parse("catalog inventory");
})
.AddClient("invoice.client", client =>
{
client.TokenEndpoint = new Uri("https://sts.company.com/connect/token");
client.ClientId = ClientId.Parse("ff8ac57f-5ade-47f1-b8cd-4c2424672351");
client.ClientSecret = ClientSecret.Parse("4dbbf8ec-d62a-4639-b0db-aa5357a0cf46");
client.Scope = Scope.Parse("invoice customers");
});
```
Available client options:
- `TokenEndpoint` — URL of the OAuth token endpoint
- `ClientId` / `ClientSecret` — client credentials
- `ClientCredentialStyle` — `AuthorizationHeader` (default) or `PostBody`
- `Scope` — requested scope (optional; overridable per request)
- `Resource` — resource indicator per RFC 8707 (optional)
- `HttpClientName` — custom backchannel HTTP client name from the factory
- `DPoPJsonWebKey` — JWK for DPoP-bound tokens (see Pattern 5)
### Automatic via HttpClientFactory (Recommended)
```csharp
// ✅ Named client — token acquired, cached, and attached automatically
services.AddClientCredentialsHttpClient(
"invoices",
ClientCredentialsClientName.Parse("invoice.client"),
client => { client.BaseAddress = new Uri("https://apis.company.com/invoice/"); });
// ✅ Typed client — identical behaviour, strongly typed
services.AddHttpClient<CatalogClient>(client =>
{
client.BaseAddress = new Uri("https://apis.company.com/catalog/");
})
.AddClientCredentialsTokenHandler(ClientCredentialsClientName.Parse("catalog.client"));
```
Usage — no token code required at the call site:
```csharp
public sealed class WorkerHttpClient(IHttpClientFactory factory) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// ✅ Token acquired, cached, and refreshed transparently
var client = factory.CreateClient("invoices");
var response = await client.GetAsync("list", stoppingToken);
// ...
}
}
}
```
> **Resiliency handler** — `AddClientCredentialsHttpClient` automatically adds a resiliency handler that retries once on `401 Unauthorized`. This covers token expiry and DPoP nonce challenges. When using `AddClientCredentialsTokenHandler` directly, add it explicitly:
>
> ```csharp
> services.AddHttpClient<CatalogClient>(...)
> .AddDefaultAccessTokenResiliency()
> .AddClientCredentialsTokenHandler("catalog.client");
> ```
### Manual Token Retrieval (Advanced)
```csharp
// ✅ Inject IClientCredentialsTokenManager (v4)
public sealed class WorkerManual(
IHttpClientFactory factory,
IClientCredentialsTokenManager tokenManager) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var tokenResult = await tokenManager.GetAccessTokenAsync(
ClientCredentialsClientName.Parse("catalog.client"),
ct: stoppingToken);
if (!tokenResult.Succeeded)
{
// log and handle — do not call .GetToken() without checking first
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
continue;
}
var token = tokenResult.GetToken();
var client = factory.CreateClient();
client.SetBearerToken(token.AccessToken.ToString());
var response = await client.GetAsync("https://apis.company.com/catalog/list", stoppingToken);
// ...
}
}
}
```
> In v3, the service was `IClientCredentialsTokenManagementService` and the result was read via `.Value`. In v4 it is `IClientCredentialsTokenManager` and the result is `TokenResult<ClientCredentialsToken>` — use `.Succeeded` / `.GetToken()`.
---
## Pattern 2: User Token Management — Web Applications
### Package
```bash
dotnet add package Duende.AccessTokenManagement.OpenIdConnect
```
### Registration
```csharp
// ✅ Full setup: cookie + OIDC handler + token management
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options =>
{
options.Cookie.Name = "web";
// ✅ Revoke refresh token at sign-out
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://sts.company.com";
options.ClientId = "webapp";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.ResponseMode = "query";
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("invoice");
options.Scope.Add("offline_access"); // ← required for refresh tokens
options.GetClaimsFromUserInfoEndpoint = true;
options.MapInboundClaims = false;
// ✅ REQUIRED — persists access and refresh tokens into the auth session
options.SaveTokens = true;
});
// ✅ Adds all token management services
builder.Services.AddOpenIdConnectAccessTokenManagement();
```
> **`SaveTokens = true` is mandatory.** Without it, the library cannot read or refresh the user's access token. This is the most common misconfiguration causing `InvalidOperationException` at runtime.
### Automatic via HttpClientFactory (Recommended)
```csharp
// ✅ Named client using the current user's access token
builder.Services.AddUserAccessTokenHttpClient(
"invoices",
configureClient: client =>
{
client.BaseAddress = new Uri("https://api.company.com/invoices/");
});
// ✅ Typed client using the current user's access token
builder.Services.AddHttpClient<InvoiceClient>(client =>
{
client.BaseAddress = new Uri("https://api.company.com/invoices/");
})
.AddUserAccessTokenHandler();
// ✅ Named client using a client credentials token (machine-to-machine, user-independent)
builder.Services.AddClientAccessTokenHttpClient(
"masterdata.client",
configureClient: client =>
{
client.BaseAddress = new Uri("https://api.company.com/masterdata/");
});
// ✅ Typed client using a client credentials token
builder.Services.AddHttpClient<MasterDataClient>(client =>
{
client.BaseAddress = new Uri("https://api.company.com/masterdata/");
})
.AddClientAccessTokenHandler();
```
Usage in a controller:
```csharp
public sealed class ApiController(IHttpClientFactory httpClientFactory) : Controller
{
public async Task<IActionResult> CallApi(CancellationToken ct)
{
// ✅ Token attached automatically; refreshed silently if expired
var client = httpClientFactory.CreateClient("invoices");
var response = await client.GetAsync("list", ct);
// ...
}
}
```
### Manual Token Retrieval (Advanced)
```csharp
// ✅ v4 — inject IUserTokenManager
public sealed class HomeController(
IHttpClientFactory httpClientFactory,
IUserTokenManager userTokenManager) : Controller
{
public async Task<IActionResult> CallApi(CancellationToken ct)
{
var token = await userTokenManager.GetAccessTokenAsync(User, ct: ct);
var client = httpClientFactory.CreateClient();
client.SetBearerToken(token.Value);
var response = await client.GetAsync("https://api.company.com/invoices", ct);
// ...
}
}
```
`HttpContext` extension methods are also available:
```csharp
// ✅ User access token — refreshed automatically via refresh token if expired
var userToken = await HttpContext.GetUserAccessTokenAsync();
// ✅ Client credentials token — re-requested from the token server if expired
var clientToken = await HttpContext.GetClientAccessTokenAsync();
// ✅ Revoke refresh token explicitly (also wired into OnSigningOut above)
await HttpContext.RevokeRefreshTokenAsync();
```
### gRPC Support
Use `AddUserAccessTokenHandler` and `AddClientAccessTokenHandler` when registering typed gRPC clients:
```csharp
// ✅ gRPC client using the current user's access token
builder.Services.AddGrpcClient<Greeter.GreeterClient>(o =>
{
o.Address = new Uri("https://grpc.company.com");
})
.AddUserAccessTokenHandler();
// ✅ gRPC client using a client credentials token
builder.Services.AddGrpcClient<Inventory.InventoryClient>(o =>
{
o.Address = new Uri("https://grpc.company.com");
})
.AddClientAccessTokenHandler();
```
---
## Pattern 3: Token Caching
### v4 — HybridCache (Default)
In v4, client credentials tokens are cached using `HybridCache` (ASP.NET Core 9+). It is two-tier: in-memory L1 + optional remote L2. No explicit registration is required for the default in-memory tier.
```csharp
// ✅ Add a distributed remote cache (e.g., Redis) — HybridCache picks it up automatically as L2
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
```
Global cache options:
```csharp
// ✅ Configure lifetime buffer and key prefix
services.AddClientCredentialsTokenManagement(options =>
{
// Cache tokens 60 s before they expire to avoid serving a near-expired token
options.CacheLifetimeBuffer = 60;
options.CacheKeyPrefix = "MyApp.ATM::";
});
```
Default cache key format:
```
{CacheKeyPrefix}::{client_name}::hashed({scope})::hashed({resource})
```
`scope` and `resource` values are MD5-hashed to keep key length bounded. Implement `IClientCredentialsCacheKeyGenerator` to supply custom keys when adding custom `TokenRequestParameters`.
### v3 — IDistributedCache
```csharp
// ✅ v3: must explicitly register a distributed cache implementation
services.AddDistributedMemoryCache(); // development / single-instance only
// ✅ v3: Redis for production
services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "redis.company.com:6379";
});
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 60;
});
```
### Encrypting Cached Tokens (v4)
When sharing a remote cache with other applications, encrypt tokens at rest using a custom `IHybridCacheSerializer<ClientCredentialsToken>`:
```csharp
// ✅ Register the encrypted serializer for the ClientCredentialsToken type
services.AddHybridCache()
.AddSerializer<ClientCredentialsToken, EncryptedHybridCacheSerializer>();
services.AddDataProtection();
public sealed class EncryptedHybridCacheSerializer : IHybridCacheSerializer<ClientCredentialsToken>
{
private readonly IDataProtector _protector;
public EncryptedHybridCacheSerializer(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("ClientCredentialsToken");
}
public ClientCredentialsToken Deserialize(ReadOnlySequence<byte> source)
{
var unprotected = _protector.Unprotect(source.ToArray());
return JsonSerializer.Deserialize<ClientCredentialsToken>(unprotected)!;
}
public void Serialize(ClientCredentialsToken value, IBufferWriter<byte> target)
{
var json = JsonSerializer.SerializeToUtf8Bytes(value);
target.Write(_protector.Protect(json));
}
}
```
### Scoping a Custom Cache to This Library Only
```csharp
// ✅ Inject a custom HybridCache only for AccessTokenManagement (uses service keys)
services.AddKeyedSingleton<HybridCache>(
ServiceProviderKeys.ClientCredentialsTokenCache,
new MyCustomCacheImplementation());
```
---
## Pattern 4: Configuration Options
### `ClientCredentialsTokenManagementOptions`
```csharp
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 60; // seconds subtracted from token lifetime in cache
options.CacheKeyPrefix = "MyApp.ATM::"; // prefix for all cache keys
});
```
### `UserTokenManagementOptions`
```csharp
builder.Services.AddOpenIdConnectAccessTokenManagement(options =>
{
// Override the OIDC challenge scheme if not using the default
options.ChallengeScheme = "oidc";
// Enable separate token stores per OIDC scheme (multi-provider setups)
options.UseChallengeSchemeScopedTokens = false;
// Scope and resource sent when requesting client credentials tokens from
// the configured OIDC provider (cannot be inferred from OIDC metadata)
options.ClientCredentialsScope = "api1 api2";
options.ClientCredentialsResource = "urn:myapi";
// How client credentials are sent to the token endpoint
options.ClientCredentialStyle = ClientCredentialStyle.PostBody;
// DPoP key for all user token requests from this application
options.DPoPJsonWebKey = jwk;
});
```
### Per-Request Parameter Overrides
```csharp
// ✅ Force a fresh token even if a cached one exists
var token = await tokenManager.GetAccessTokenAsync(
ClientCredentialsClientName.Parse("catalog.client"),
new TokenRequestParameters { ForceRenewal = true },
ct: stoppingToken);
// ✅ Override scope per user token request
var token = await userTokenManager.GetAccessTokenAsync(
User,
new UserTokenRequestParameters
{
Scope = "invoice:write",
ForceRenewal = false,
ChallengeScheme = "oidc"
},
ct: ct);
```
For `IHttpClientFactory` clients, parameters are wired at registration time:
```csharp
builder.Services.AddUserAccessTokenHttpClient(
"invoices",
parameters: new UserTokenRequestParameters { ForceRenewal = true },
configureClient: client => { client.BaseAddress = new Uri("https://api.company.com/invoices/"); });
```
---
## Pattern 5: DPoP (Demonstrating Proof-of-Possession)
DPoP binds an access token to a client-held asymmetric key, preventing replay attacks even if the token is stolen. Configure it by setting `DPoPJsonWebKey` on the client credentials client or `UserTokenManagementOptions`, and optionally implement `IDPoPKeyStore` for runtime key rotation.
> **Full details in sub-document** — See [`docs/dpop.md`](docs/dpop.md) for JWK generation, per-client configuration, `IDPoPKeyStore`, session size implications, and the key-persistence pitfall.
---
## Pattern 6: API-to-API Token Delegation
An API can either forward the user's access token to a downstream API (when the downstream accepts the same audience) or use a dedicated client credentials token (when the downstream requires a service identity).
> **Full details in sub-document** — See [`docs/api-delegation.md`](docs/api-delegation.md) for both approaches with complete code examples and a decision guide.
---
## Pattern 7: Dynamic Client Configuration
Use `IConfigureNamedOptions<ClientCredentialsClient>` when token endpoint configuration must be resolved at runtime — for example, from OIDC discovery:
```csharp
public sealed class ClientCredentialsConfigureOptions(DiscoveryCache cache)
: IConfigureNamedOptions<ClientCredentialsClient>
{
public void Configure(string? name, ClientCredentialsClient options)
{
if (name == "catalog.client")
{
// ✅ Resolve token endpoint from OIDC discovery document
var disco = cache.GetAsync().GetAwaiter().GetResult();
options.TokenEndpoint = new Uri(disco.TokenEndpoint);
options.ClientId = ClientId.Parse("...");
options.ClientSecret = ClientSecret.Parse("...");
options.Scope = Scope.Parse("catalog");
}
}
public void Configure(ClientCredentialsClient options) => Configure(string.Empty, options);
}
// Registration
services.AddClientCredentialsTokenManagement();
services.AddSingleton(new DiscoveryCache("https://sts.company.com"));
services.AddSingleton<IConfigureOptions<ClientCredentialsClient>, ClientCredentialsConfigureOptions>();
```
---
## Pattern 8: Custom Token Storage
### User Tokens — Replace the Default Cookie Session Store
By default, user access and refresh tokens are stored inside the ASP.NET Core authentication cookie. Replace `IUserTokenStore` when this is insufficient — for example, when using server-side sessions:
```csharp
// ✅ Register a custom implementation backed by server-side session storage
builder.Services.AddSingleton<IUserTokenStore, ServerSideSessionUserTokenStore>();
```
### Client Credentials — Replace the Cache Implementation
```csharp
// ✅ Override the entire cache with a custom IClientCredentialsTokenCache
services.AddSingleton<IClientCredentialsTokenCache, MyCustomTokenCache>();
```
---
## Pattern 9: Blazor Server Token Management
Blazor Server circuits outlive the initial HTTP request. Once a circuit is established, `HttpContext` is `null`, making the default cookie-based `IUserTokenStore` unusable. Use `AddBlazorServerAccessTokenManagement<T>()` with a persistent `IUserTokenStore` (e.g., database-backed), and capture tokens in `OnTokenValidated` during the initial OIDC flow.
> **Full details in sub-document** — See [`docs/blazor-server.md`](docs/blazor-server.md) for the full `IUserTokenStore` implementation, `OnTokenValidated` setup, and the `HttpContext`-null pitfall.
---
## Pattern 10: Client Assertions (private_key_jwt)
Use `IClientAssertionService` to authenticate with signed JWTs instead of shared client secrets. **CRITICAL**: set the JWT `Audience` to the authorization server's issuer URL — NOT the token endpoint URL. Using the token endpoint URL was the root cause of CVE-2025-27370 and CVE-2025-27371.
> **Full details in sub-document** — See [`docs/client-assertions.md`](docs/client-assertions.md) for the full `IClientAssertionService` implementation, correct audience configuration, and CVE context.
---
## Pattern 11: Custom Token Request Customization
Use `ITokenRequestCustomizer` (v4) to dynamically modify token request parameters per outgoing HTTP request — useful for multi-tenant scenarios where different tenants require different API resources or scopes. Use the `with` expression to create a modified copy of `baseParameters`; do not mutate it.
> **Full details in sub-document** — See [`docs/customization.md`](docs/customization.md) for the full `ITokenRequestCustomizer` implementation and registration pattern.
---
## Pattern 12: Custom Token Retrieval
Implement `AccessTokenRequestHandler.ITokenRetriever` to completely replace the default token retrieval logic with custom selection or caching behavior.
> **Full details in sub-document** — See [`docs/customization.md`](docs/customization.md) for the full `ITokenRetriever` implementation and `AddHttpMessageHandler` registration pattern.
---
## Sub-Documents
Load these sub-documents when the user's question specifically targets one of these areas:
| Document | Description | When to Load |
|----------|-------------|--------------|
| [docs/dpop.md](docs/dpop.md) | DPoP proof-of-possession token binding | DPoP, `DPoPJsonWebKey`, `IDPoPKeyStore`, key generation, key rotation |
| [docs/blazor-server.md](docs/blazor-server.md) | Blazor Server circuit-scoped token management | Blazor, SignalR circuit, `HttpContext` null, `AddBlazorServerAccessTokenManagement` |
| [docs/client-assertions.md](docs/client-assertions.md) | Client assertions (private_key_jwt) + CVE guidance | `IClientAssertionService`, JWT client auth, CVE-2025-27370, CVE-2025-27371 |
| [docs/customization.md](docs/customization.md) | `ITokenRequestCustomizer` & `ITokenRetriever` | Multi-tenant token params, custom token retrieval, `AccessTokenRequestHandler` |
| [docs/api-delegation.md](docs/api-delegation.md) | API-to-API delegation patterns | Downstream API calls, forwarding user tokens, service identity |
---
## Complete Example: Web App with User and Client Credentials
```csharp
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// User token management via OIDC
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options =>
{
options.Cookie.Name = "web";
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
})
.AddOpenIdConnect("oidc", options =>
{
options.Authority = "https://identity.example.com";
options.ClientId = "web_app";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.Scope.Add("api1");
options.Scope.Add("offline_access");
options.SaveTokens = true;
});
builder.Services.AddOpenIdConnectAccessTokenManagement();
// User token HTTP client — attaches the logged-in user's access token
builder.Services.AddUserAccessTokenHttpClient("user-api",
configureClient: client =>
{
client.BaseAddress = new Uri("https://api.example.com");
});
// Client credentials for service-to-service (v4 types)
builder.Services.AddClientCredentialsTokenManagement()
.AddClient(ClientCredentialsClientName.Parse("service-client"), client =>
{
client.TokenEndpoint = new Uri("https://identity.example.com/connect/token");
client.ClientId = ClientId.Parse("web_app_service");
client.ClientSecret = ClientSecret.Parse("service_secret");
client.Scope = Scope.Parse("backend.api");
});
builder.Services.AddClientCredentialsHttpClient("service-api",
ClientCredentialsClientName.Parse("service-client"),
configureClient: client =>
{
client.BaseAddress = new Uri("https://backend.example.com");
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// User-context API call
app.MapGet("/user-data", async (IHttpClientFactory factory) =>
{
var client = factory.CreateClient("user-api");
var response = await client.GetAsync("/data");
return Results.Ok(await response.Content.ReadAsStringAsync());
}).RequireAuthorization();
// Service-to-service API call (no user context needed)
app.MapGet("/backend-data", async (IHttpClientFactory factory) =>
{
var client = factory.CreateClient("service-api");
var response = await client.GetAsync("/internal/data");
return Results.Ok(await response.Content.ReadAsStringAsync());
});
app.Run();
```
---
## Common Pitfalls
### 1. Missing `SaveTokens = true` for User Tokens
```csharp
// ❌ Tokens never stored in session — library throws InvalidOperationException at runtime
.AddOpenIdConnect("oidc", options =>
{
// SaveTokens not set — defaults to false
});
// ✅ Always set it when using AddOpenIdConnectAccessTokenManagement
options.SaveTokens = true;
```
### 2. Missing `offline_access` Scope
```csharp
// ❌ No refresh token issued — access token expires and user must re-authenticate
options.Scope.Add("openid");
options.Scope.Add("profile");
// offline_access missing
// ✅
options.Scope.Add("offline_access");
```
### 3. Not Revoking Refresh Tokens at Sign-Out
```csharp
// ❌ Refresh token remains valid at the authorization server after sign-out
.AddCookie("cookie", options =>
{
// No OnSigningOut handler — refresh token never revoked
});
// ✅
.AddCookie("cookie", options =>
{
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
});
```
### 4. Caching Tokens Manually Alongside the Library
```csharp
// ❌ Double-caching — your cache won't stay in sync; stale token after expiry
private string? _cachedToken;
public async Task<string> GetToken()
{
if (_cachedToken != null) return _cachedToken;
var token = await _tokenManager.GetAccessTokenAsync(...);
_cachedToken = token.AccessToken.ToString(); // never invalidated
return _cachedToken;
}
// ✅ Call GetAccessTokenAsync every time — the library serves from cache transparently
public async Task<string> GetToken(CancellationToken ct)
{
var result = await _tokenManager
.GetAccessTokenAsync(ClientCredentialsClientName.Parse("my.client"), ct: ct)
.GetToken();
return result.AccessToken.ToString();
}
```
### 5. Calling `.GetToken()` Without Checking `Succeeded`
```csharp
// ❌ .GetToken() throws InvalidOperationException when token retrieval fails;
// the actual error is swallowed unless you inspect Succeeded first
var tokenResult = await tokenManager.GetAccessTokenAsync(...);
var token = tokenResult.GetToken(); // throws when Succeeded == false
// ✅ Check success before accessing the token value
var tokenResult = await tokenManager.GetAccessTokenAsync(...);
if (!tokenResult.Succeeded)
{
logger.LogError("Failed to obtain access token");
return Problem("Authentication failure", statusCode: StatusCodes.Status503ServiceUnavailable);
}
var token = tokenResult.GetToken();
```
### 6. Using `AddClientCredentialsTokenHandler` Without Resiliency
```csharp
// ❌ A 401 from an expired token is returned directly to the caller — no retry
services.AddHttpClient<CatalogClient>(...)
.AddClientCredentialsTokenHandler("catalog.client");
// ✅ Add the resiliency handler before the token handler
services.AddHttpClient<CatalogClient>(...)
.AddDefaultAccessTokenResiliency()
.AddClientCredentialsTokenHandler("catalog.client");
```
### 7. v3 — Forgetting to Register a Distributed Cache
```csharp
// ❌ v3: no cache registered — runtime exception on first token request
services.AddClientCredentialsTokenManagement()
.AddClient("catalog.client", client => { /* ... */ });
// Missing: services.AddDistributedMemoryCache();
// ✅ v3: always register a distributed cache (in-memory for dev, Redis for prod)
services.AddDistributedMemoryCache();
```
### 8. Regenerating DPoP Keys on Every Process Restart
```csharp
// ❌ New key generated on every restart — all previously issued DPoP-bound tokens
// become unusable, causing 401 errors until new tokens are obtained
var rsaKey = new RsaSecurityKey(RSA.Create(2048)); // ephemeral — lost on restart
// ✅ Load from stable, secure storage
var jwkJson = configuration["DPoP:JsonWebKey"]; // from Key Vault / secrets
services.AddClientCredentialsTokenManagement()
.AddClient("my.client", client =>
{
client.DPoPJsonWebKey = jwkJson;
});
```
### 9. Setting Client Assertion Audience to the Token Endpoint URL
```csharp
// ❌ Audience set to the token endpoint — security vulnerability
// Root cause of CVE-2025-27370 and CVE-2025-27371
Audience = "https://identity.example.com/connect/token"
// ✅ Audience must be the authorization server's issuer URL
Audience = "https://identity.example.com"
```
> CVE-2025-27370 and CVE-2025-27371 were caused by this exact mistake. Authorization servers that accept both values allow token endpoint confusion attacks.
### 10. Using `HttpContext` to Access Tokens in Blazor Server Components
```csharp
// ❌ HttpContext is null after circuit establishment — this will fail at runtime
var token = await HttpContext.GetUserAccessTokenAsync(); // throws NullReferenceException
// ✅ Use AddBlazorServerAccessTokenManagement<T>() with a custom IUserTokenStore
builder.Services.AddOpenIdConnectAccessTokenManagement()
.AddBlazorServerAccessTokenManagement<ServerSideTokenStore>();
// Capture tokens in OnTokenValidated (see Pattern 9)
```
### 11. Setting `CacheLifetimeBuffer` to 0
```csharp
// ❌ Buffer set to 0 — tokens served until exact expiry; a token may expire
// in transit between retrieval and use at the API, causing unnecessary 401s
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 0;
});
// ✅ Keep the default (60 s) or set a positive value that accounts for network latency
services.AddClientCredentialsTokenManagement(options =>
{
options.CacheLifetimeBuffer = 60; // default — refresh 60 s before expiry
});
```
---
## Version Reference: v3 → v4
| Area | v3 | v4 |
|---|---|---|
| Client credentials service | `IClientCredentialsTokenManagementService` | `IClientCredentialsTokenManager` |
| User token service | `IUserTokenManagementService` | `IUserTokenManager` |
| Token result type | `TokenResponse` — read `.Value` | `TokenResult<T>` — use `.Succeeded` / `.GetToken()` |
| Client name type | `string` | `ClientCredentialsClientName` (strongly typed) |
| Token cache | `IDistributedCache` (explicit `AddDistributedMemoryCache()` required) | `HybridCache` (automatic; picks up `IDistributedCache` as remote L2 tier) |
| Resiliency | Manual | `AddDefaultAccessTokenResiliency()` built into `AddClientCredentialsHttpClient` |
---
## Resources
- [Access Token Management Overview](https://docs.duendesoftware.com/accesstokenmanagement/)
- [Service Workers / Background Tasks](https://docs.duendesoftware.com/accesstokenmanagement/workers/)
- [Web Applications (User Tokens)](https://docs.duendesoftware.com/accesstokenmanagement/web-apps/)
- [Blazor Server](https://docs.duendesoftware.com/accesstokenmanagement/blazor-server/)
- [Advanced: Client Credentials Options](https://docs.duendesoftware.com/accesstokenmanagement/advanced/client-credentials/)
- [Advanced: User Token Options](https://docs.duendesoftware.com/accesstokenmanagement/advanced/user-tokens/)
- [Advanced: Client Assertions](https://docs.duendesoftware.com/accesstokenmanagement/advanced/client-assertions/)
- [Advanced: DPoP](https://docs.duendesoftware.com/accesstokenmanagement/advanced/dpop/)
- [Advanced: Extensibility](https://docs.duendesoftware.com/accesstokenmanagement/advanced/extensibility/)
- [v3 → v4 Upgrade Guide](https://docs.duendesoftware.com/accesstokenmanagement/upgrading/atm-v3-to-v4/)
- [NuGet: Duende.AccessTokenManagement](https://www.nuget.org/packages/Duende.AccessTokenManagement/)
- [NuGet: Duende.AccessTokenManagement.OpenIdConnect](https://www.nuget.org/packages/Duende.AccessTokenManagement.OpenIdConnect/)
- [GitHub: DuendeSoftware/foss (access-token-management)](https://github.com/DuendeSoftware/foss/tree/main/access-token-management)
Referenced files: 5
Package details
Publisher declarations from the archived package. These are separate from our research and the live service's terms.
- Package license
- MIT
- Package author
- Duende Software
- Keywords
- identityserver, oauth, oidc, openid-connect, aspnetcore, authentication, authorization, bff, token-management, duende, dotnet, security
Declared capabilities
- Read
Package observed Oct 2, 2026.
Technical details
- First seen
- Sep 30, 2026 · 22:02 UTC
- Last seen
- Oct 2, 2026 · 12:00 UTC
- Collection status
- Collected
plugins_6a86acf7816881918552f3b43bc0db69
Download plugin data (JSON)