When a user signs in to an application, their Identity Provider (IdP) provides metadata about the user’s identity. This static information was provided by the user when the account was created, like the user’s name, email address, and country of origin. The amount of data available depends on the IdP implementation and requirements. Based on the requested (and consented) scopes, the IdP provides some or all of this information as claims to the client application.
The default mechanism that Duende IdentityServer uses for storing claims containing user information is a client-side cookie. Too much information bloats the cookie, increasing the size of each request and degrading performance. Additionally, the web client is storing access tokens in the browser, which goes against today’s best practices (e.g., using Backend-for-Frontend). We can work around these issues by storing the cookie data on the server using Duende IdentityServer server-side sessions.
Improving Cookie Size and Performance
An ASP.NET Core authentication cookie varies in size. Testing a simple identity with a few claims is around 1104 bytes. Adding more claims or extra information about the user will bloat the cookie, increasing the amount of data sent between client and server on each request. Some IdPs provide so much data that it’s split across multiple cookies to avoid hitting the browser’s cookie size limit.
Instead of transferring all that data back and forth to the server on each request, the cookie data can be stored server-side in Duende IdentityServer by enabling server-side sessions. This causes all session information to be stored on the server, and the client-side cookie only holds an id for session lookup, bringing the cookie size down to 400 bytes.
To enable Server-Side Sessions in Duende IdentityServer, call the AddServerSideSessions() method when registering Identity Server with your ASP.NET application.
//Program.cs
builder.Services
.AddIdentityServer()
.AddServerSideSessions()
Note: By default, Duende IdentityServer keeps sessions in memory. You'll want a persistent store in a production scenario, which can be done using the Duende IdentityServer Entity Framework Core store, or you can implement and register your own.
Querying User Session Data
Another benefit to storing session information on the server is that it can be queried across multiple active sessions for the same user. The data stored is serialized and encrypted through ASP.NET Core's Data Protection, so you can’t easily run a SQL query directly against the database. Enabling server-side sessions with Duende IdentityServer automatically registers an instance of ISessionManagementService with the IoC container, allowing you to query or terminate sessions stored in the Operational Store.
var userSessions = await _sessionManagementService.QuerySessionsAsync(
new SessionQuery
{
CountRequested = 10,
SubjectId = "12345",
DisplayName = "Bob",
}, HttpContext.RequestAborted);
Querying session information loads it into memory, allowing your Duende IdentityServer application to process any business requirements or audit logging you need. For example, if a user has two active sessions, they might be using a laptop and a phone. If they have 200 active sessions across 150 different geographic locations, their account may have been compromised, and you can act on it.
Extending Session Data
We made the comment that a user with 200 active sessions across 150 different geographic regions likely has a compromised account. To even reach that conclusion, we need to store the extra user data somewhere. ASP.NET Core lets us extend this data with AuthenticationProperties.
AuthenticationProperties provides a way to store additional state during the authentication process. We can use its key-value store to add any information we want, so long as it’s stored as a string. These aren’t claims added to the user’s identity and aren't shared with any client application. They are extra metadata you can store for later retrieval/processing. Duende IdentityServer persists this data along with the user’s session information and is only accessible to Duende IdentityServer when it loads the session information. It does not share that information with other services, though you can choose to do that with custom code.
At the lowest level, a call to HttpContext.SignInAsync lets you add additional AuthenticationProperties where data can be added that Duende IdentityServer's server-side sessions:
HttpContext.SignInAsync(
new ClaimsPrincipal(identity),
new AuthenticationProperties
{
Items =
{
["IpCountry"] = "Iceland",
["IpCity"] = "Stykkishólmur"
}
});
Extended session information is added when the user signs in to the application, and there are multiple ways to do this in ASP.NET Core.
Aside: Why Not Custom Claims For Geographic Location
Many applications already add custom claims during user sign-in by creating a new ClaimsPrincipal and adding the claims that are communicated with the client application:
var identity = new ClaimsIdentity(
IdentityServerConstants.DefaultCookieAuthenticationScheme);
// Claims for client aplication
identity.AddClaim(new Claim(JwtClaimTypes.Subject, "9fe57b70"));
identity.AddClaim(new Claim(JwtClaimTypes.Name, "AL"));
identity.AddClaim(new Claim(JwtClaimTypes.FamilyName, "Rodriguez"));
HttpContext.SignInAsync(new ClaimsPrincipal(identity));
It might seem like claims are a good place to store custom user metadata, such as their geographic location, but it’s not. Claims are meant to store static information, like a user’s Id, name, and email address. These values do not change when a user has signed in to an application. They’re also values that will be used across user requests. If a user is signed in through a phone and a laptop, they would expect the site to work the same way on both devices.
ASP.NET Identity Custom SignInManager
There are multiple ways to add session metadata during sign-in. They each work by hooking into a different phase of the sign-in process. Ultimately, the sign-in is done with a call to HttpContext.SignInAsync, which ASP.NET Identity abstracts through the SignInManager class. SignInManager provides several methods for handling sign-in, making it harder to adjust and extend the identity. Fear not: there is one method in SignInManager that you can override to get access to the identity being signed-in: SignInWithClaimsAsync.
You can override SignInWithClaimsAsync to add additional claims or custom AuthenticationProperties. You’ll need to create a custom SignInManager class that inherits from Microsoft.AspNetCore.Identity.SignInManager<TUser>, which you also must register with the IoC container.
//Program.cs
builder.Services
.AddIdentity<IdentityUser, IdentityRole>()
// Register your custom SignInManager class
.AddSignInManager<MyCustomSignInManager>();
This custom SignInManager<> adds session data by overriding the virtual SignInWithClaimsAsync() method.
// MyCustomSignInManager.cs
public class MyCustomSignInManager(
UserManager<IdentityUser> userManager,
IHttpContextAccessor contextAccessor,
IUserClaimsPrincipalFactory<IdentityUser> claimsFactory,
IOptions<IdentityOptions> optionsAccessor,
ILogger<SignInManager<IdentityUser>> logger,
IAuthenticationSchemeProvider schemes,
IUserConfirmation<IdentityUser> confirmation)
: SignInManager<IdentityUser>(
userManager, contextAccessor, claimsFactory,
optionsAccessor, logger, schemes,confirmation)
{
public override async Task SignInWithClaimsAsync(
IdentityUser user,
AuthenticationProperties? authenticationProperties,
IEnumerable<Claim> additionalClaims)
{
authenticationProperties ??= new AuthenticationProperties();
// Add user's location to AuthenticationProperties
authenticationProperties.Items["IpCountry"] = "Iceland";
authenticationProperties.Items["IpCity"] = "Stykkishólmur";
await base.SignInWithClaimsAsync(
user,
authenticationProperties,
additionalClaims);
}
}
Note: The
SignInManager<TUser>class has multiplevirtual SignInWithClaimsAsync()methods. The one we override in the example above is called by each of the otherSignInWithClaimsAsync()methods, meaning you only need to worry about overriding that one to ensure the custom metadata is added to the identity.
Add Directly To The ASP.NET Core Identity Cookie
ASP.NET Core also provides a callback allowing you to extend the identity being signed in. The code sample below adds to the AuthenticationProperties in the same way we added them with the custom SignInManager<TUser> above.
//Program.cs
builder.Services.ConfigureApplicationCookie(options =>
{
options.Events.OnSigningIn = async context =>
{
context.Properties.SetString("IpCountry", "Iceland");
context.Properties.SetString("IpCity", "Stykkishólmur");
await Task.CompletedTask;
};
});
Note: This does not work in every setup. Duende IdentityServer uses a specific cookie scheme when it runs without ASP.NET Identity, also known as Standalone IdentityServer. In that case, Duende IdentityServer uses a cookie name which will not raise the event in the code sample above.
What Can We Do With Custom Session Data?
Now that we are storing user data, we can act on it. It can be displayed to users on a dedicated web page, or even queried and processed by an internal auditing service.
Surfacing Data To Users
A common way to track this data is to surface it to users so they can act on it. Some web services allow users to view all of their open sessions and sign out directly from a web page.
The demo site for Duende IdentityServer illustrates this concept. After navigating to https://demo.duendesoftware.com/ServerSideSessions and signing-in, you’ll see a page that displays all active sessions for that user.

Processing Extended Data
Another reason to store the extended session data is so that an internal service can audit it. Not every user will be security conscious and remember to sign-out of every service. It’s been years since I last audited my own active sessions at http://www.google.com/devices.
We talked earlier about a user having 200 active sessions across 150 geographic locations. This code shows how we can query all active sessions a user has and view the data we stored in AuthenticationProperties. From there we can add code to handle the account, based on business rules.
var sessions = await _sessionManagementService.QuerySessionsAsync(
new SessionQuery
{
ResultsToken = Token,
RequestPriorResults = Prev == "true",
DisplayName = DisplayNameFilter,
SessionId = SessionIdFilter,
SubjectId = SubjectIdFilter
}, HttpContext.RequestAborted);
if (sessions is null)
{
return null;
}
var citiesCount = sessions.Results
.Select(x => x.AuthenticationTicket.Properties.Items["IpCity"])
.Distinct()
.Count();
if (citiesCount > 20)
{
// Handle compromised account
}
Summary
Custom session data can be added during user sign-in with custom code. It’s important to keep cookie sizes small because they affect the performance of all requests to/from your applications, so storing this information server-side offers performance benefits. It also allows us to query the information we stored in AuthenticationProperties and process it.
Thanks for stopping by!
We hope this post helped you on your identity and security journey. If you need a hand with implementation, our docs are always open. For everything else, come hang out with the team and other developers on GitHub.
If you want to get early access to new features and products while collaborating with experts in security and identity standards, join us in our Duende Product Insiders program. And if you prefer your tech content in video form, our YouTube channel is the place to be. Don't forget to like and subscribe!
Questions? Comments? Just want to say hi? Leave a comment below and let's start a conversation.