From duende-skills
Configures server-side sessions, dynamic identity providers, and CIBA flows in Duende IdentityServer. Includes session management, inactivity timeout, and custom stores.
How this skill is triggered — by the user, by Claude, or both
Slash command
/duende-skills:identityserver-sessions-providersThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
- Enabling and configuring server-side sessions for authentication state management
ISessionManagementServiceIServerSideSessionStoreDocs: https://docs.duendesoftware.com/identityserver/ui/sessions
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.
Server-side sessions are part of the Duende IdentityServer Business and Enterprise Edition.
// 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.
// 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.
Implement IServerSideSessionStore and register it:
// Program.cs — two-step registration
builder.Services.AddIdentityServer()
.AddServerSideSessions()
.AddServerSideSessionStore<YourCustomStore>();
// Or one-step registration
builder.Services.AddIdentityServer()
.AddServerSideSessions<YourCustomStore>();
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:
// Program.cs
builder.Services.AddIdentityServer(options => {
options.ServerSideSessions.UserDisplayNameClaimType = "name";
}).AddServerSideSessions();
var userSessions = await _sessionManagementService.QuerySessionsAsync(new SessionQuery
{
CountRequested = 10,
SubjectId = "12345",
DisplayName = "Bob",
});
// 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,
});
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.
Terminate sessions and optionally revoke tokens, consents, and send back-channel logout notifications:
// Revoke everything for a user
await _sessionManagementService.RemoveSessionsAsync(new RemoveSessionsContext
{
SubjectId = "12345"
});
Selective revocation (filtering by SessionId or ClientIds is also supported):
// 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,
});
| 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.
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.
Server-side sessions at IdentityServer provide the central record for monitoring user activity:
ISessionCoordinationService (this is an implementation detail, not a public API for consumers).Three features must be enabled:
// 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:
var client = new Client
{
ClientId = "my_app",
CoordinateLifetimeWithUserSession = true
};
| 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.
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.
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 |
// Program.cs
builder.Services.AddIdentityServer(options => {
options.ServerSideSessions.RemoveExpiredSessionsFrequency = TimeSpan.FromSeconds(60);
}).AddServerSideSessions();
builder.Services.AddIdentityServer(options => {
options.ServerSideSessions.RemoveExpiredSessions = false;
}).AddServerSideSessions();
The server-side session lifetime is inherited from the cookie authentication handler:
options.Authentication.CookieLifetime (defaults to 10 hours)ConfigureApplicationCookie(options => options.ExpireTimeSpan = ...) (defaults to 14 days)Dynamic identity providers are part of the Duende IdentityServer Enterprise Edition.
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.
Dynamic providers are loaded from a store at runtime, avoiding DI overhead and enabling live configuration changes.
| Store | Implementation |
|---|---|
| In-memory | AddInMemoryIdentityProviders() |
| Entity Framework Core | Via ConfigurationDbContext |
| Custom | Implement IIdentityProviderStore |
// Program.cs
builder.Services.AddIdentityServer()
.AddInMemoryIdentityProviders(new[]
{
new OidcProvider
{
Scheme = "oidc",
DisplayName = "Sample provider",
Enabled = true,
// ... more properties
}
});
// 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();
}
}
By default, dynamic provider configuration is loaded from the store on every request. Enable caching:
AddConfigurationStoreCache()AddIdentityProviderStoreCache<T>()Merge static and dynamic providers:
// 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);
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:
builder.Services.AddIdentityServer(options =>
{
options.DynamicProviders.PathPrefix = "/fed";
});
To add providers like Google or SAML:
Step 1: Create a custom IdentityProvider type:
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:
// Program.cs
builder.Services.AddIdentityServer(options =>
{
options.DynamicProviders
.AddProviderType<GoogleHandler, GoogleOptions, GoogleIdentityProvider>(
GoogleIdentityProvider.ProviderType);
});
Step 3: Configure options mapping:
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:
builder.Services.ConfigureOptions<GoogleDynamicConfigureOptions>();
Implement IConfigureNamedOptions<OpenIdConnectOptions> for per-scheme customization:
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 is part of the Duende IdentityServer Enterprise Edition.
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.
/connect/ciba endpointIBackchannelAuthenticationUserValidator (you must implement this)IBackchannelAuthenticationRequestStoreIBackchannelAuthenticationUserNotificationService (you must implement this — e.g., push notification, email, SMS)IBackchannelAuthenticationInteractionService.CompleteLoginRequestAsync| 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 |
// In your CIBA approval UI
var request = await _cibaInteraction.GetLoginRequestByInternalIdAsync(internalId);
await _cibaInteraction.CompleteLoginRequestAsync(new CompleteBackchannelLoginRequest(internalId)
{
ScopesValuesConsented = request.ValidatedResources.RawScopeValues,
// Or a subset if the user partially consents
});
IdentityServer supports the poll mode for clients to obtain results.
❌ 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
Registration order matters: AddServerSideSessions() must be called after any custom IRefreshTokenService registration.
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.
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.
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).
CIBA requires Enterprise Edition: Attempting to use CIBA features without the Enterprise Edition license will fail at runtime.
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.
npx claudepluginhub duendesoftware/duende-skills --plugin duende-skillsProvides CDSS development patterns for drug interaction checking, dose validation, clinical scoring (NEWS2, qSOFA), and alert classification integrated into EMR workflows.