Updated on
To add custom claims to a Duende IdentityServer access token, we implement IProfileService and add our claims in GetProfileDataAsync(). That’s the supported extension point, and it takes about twenty lines.
For user claims that already exist (like roles), there is an even shorter path: declare them in the UserClaims of the ApiResource or ApiScope the client requests, and Duende includes them automatically.
As a rule, identity providers support the ability to load custom claims into both ID and access tokens.
Duende IdentityServer requires a licence for production use. Evaluation, development, test environments and personal projects do not. Production is free on the Community Edition for either a for-profit organisation with under $1M USD projected annual gross revenue and access to under $3M USD in capital facilities, or a non-profit or registered charity with a published annual budget under $1M USD — but only for running it on our own infrastructure for our own use.
Shipping it inside software we sell, or standing it up for a client, needs a different licence regardless of our size.
With that, let’s start.
Create Duende Identity Server
First, we need an instance of the Duende Identity Server and a client application to reproduce the process of obtaining access tokens. A complete guide on creating the Duende Identity Server and web client is available on the official site.
Once the identity server is running, initiate the web client. The web client only allows authenticated users to access its pages, redirecting unauthenticated users to the Identity Server login page. This flow is the same pattern we cover in JWT authentication in ASP.NET Core, with the identity server taking over token issuance.
We will use one of the default accounts, such as alice/alice or bob/bob, to log in on the identity server. Upon successful login, the identity server issues ID and access tokens, providing them to the web client. Subsequently, the web client signs in the user by creating a local cookie that, among other things, stores the issued access token. To read that token back in our own code, see how to get an access token from HttpContext.
We can locate it on the rendered page:
Now let’s look inside the access token. We decode it with an online tool like jwt.io:
By default, Duende Identity Server includes a basic set of claims in its access tokens. One of the most important for developers is the sub claim, whose value identifies the user who requested a token. Occasionally, we might need to expand the set of standard claims to include additional values.
Add Custom Claims to Access Token
Duende Identity Server defines an IProfileService interface. By implementing this interface, developers can add custom claims to the issued access tokens in Duende.
Duende’s current reference describes the extension point in one line: GetProfileDataAsync() “is called whenever claims about the user are requested (e.g. during token creation or via the userinfo endpoint)” (Profile Service, Duende Docs, read 2026-08-11).
To start, let’s create a CustomProfileService class in the IdentityServer project, which will be a simple implementation of the IProfileService interface:
public sealed class CustomProfileService : IProfileService
{
public Task GetProfileDataAsync(ProfileDataRequestContext context, CancellationToken ct)
{
if (context.Application?.Identifier == "web")
{
context.IssuedClaims.Add(new Claim("tenant", "main"));
}
if (context.RequestedClaimTypes.Any())
{
context.AddRequestedClaims(new[]
{
new Claim("payments.discount", "20"),
new Claim(JwtClaimTypes.Role, "admin")
});
}
return Task.CompletedTask;
}
public Task IsActiveAsync(IsActiveContext context, CancellationToken ct)
{
if (context.Subject.GetSubjectId() == "3")
{
context.IsActive = false;
}
return Task.CompletedTask;
}
}
If we are upgrading from an older IdentityServer, two signature changes stand out immediately. Both methods now take a CancellationToken ct parameter — IdentityServer 8 added one to every async store and service method — and context.Client is now context.Application, typed as the new IConnectedApplication interface, so we read the client id as Application?.Identifier instead of Client.ClientId.
The ?. is not defensive noise: version 8 turned nullable reference types on across its assemblies, so context.Application is annotated nullable. One more rename to know about: the IdentityModel namespace is now Duende.IdentityModel, which is where JwtClaimTypes comes from.
The IsActiveAsync() method can determine if an authenticated user is allowed to obtain tokens. Its current implementation doesn’t allow issuing tokens for requests from particular users. To prevent a token from being issued, we set the IsActive property of the context to false:
context.IsActive = false;
The GetProfileDataAsync method is responsible for determining which claims will be included in tokens. Its input context argument provides various useful properties to build flexible logic for adding standard or custom claims. For example, in our code sample, we include a custom claim named tenant in access tokens issued only when requested by the client web. The access token will include it even though the client didn’t explicitly request this claim, because we added it directly to the IssuedClaims list.
Additionally, we can force any client to request custom claims if needed. For that, we can provide custom claims in custom API scopes:
public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
new(name: "payments", displayName: "Allow payments", userClaims: new[] { "payments.discount" })
};
After we include the payments scope in the list of available scopes for the client web, the highlighted part of the GetProfileDataAsync() method in the CustomProfileService class comes into play:
public Task GetProfileDataAsync(ProfileDataRequestContext context, CancellationToken ct)
{
if (context.Application?.Identifier == "web")
{
context.IssuedClaims.Add(new Claim("tenant", "main"));
}
if (context.RequestedClaimTypes.Any())
{
context.AddRequestedClaims(new[]
{
new Claim("payments.discount", "20"),
new Claim(JwtClaimTypes.Role, "admin")
});
}
return Task.CompletedTask;
}
When the client requests the payments scope, the RequestedClaimTypes collection in the context will contain the custom claim’s type payments.discount. Notice that we use the AddRequestedClaims method to add this claim and its value, ensuring that only requested claims will be added to the access token. The role claim added alongside it is covered in the next section.
Lastly, let’s not forget to register CustomProfileService in HostingExtensions:
isBuilder.AddProfileService<CustomProfileService>();
Now, let’s check how the custom profile service works.
Restart the identity server and web client, sign out the user if still authenticated, and log in again. Finally, decode the new access token:
Here, we can make sure the new access token contains our custom claims, payments.discount and tenant.
How to Include Role Claims in a Duende Access Token
By default, Duende IdentityServer does not include role claims in access tokens. A claim only makes it into the token when something declares that the API needs it. The shortest supported path is the UserClaims property: we add role to the ApiResource (or a specific ApiScope) that the client requests, and Duende pulls the user’s role claims from the profile service into every access token issued for that audience. Duende’s reference states it: “You can specify that an access token for an API resource (regardless of which scope is requested) should contain additional user claims.”
No custom code is required while a built-in profile service still serves them, as ASP.NET Core Identity does via AddAspNetIdentity<ApplicationUser>(). Registering our own IProfileService replaces that service, so it then has to issue the role claim itself.
On the consuming API, two details remain: we turn the JWT handler’s legacy inbound claim mapping off and point RoleClaimType at role, or [Authorize(Roles = "admin")] fails silently.
public static IEnumerable<ApiResource> ApiResources =>
[
new ApiResource("paymentsapi", "Payments API")
{
Scopes = { "payments" },
UserClaims = { JwtClaimTypes.Role }
}
];
Notice the UserClaims = { JwtClaimTypes.Role } line: that’s what tells Duende to ask for the user’s role claim on every token issued for this API resource. If we use ApiScope.UserClaims instead, the claim rides with the scope rather than the audience; the mechanism is the same. Either way our CustomProfileService is the one that has to hand the claim over, because registering it replaced the profile service Duende would otherwise have used.
On the consuming API, the JWT bearer handler needs to know which claim type carries the role — and it must stop rewriting the incoming claim types first:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://localhost:5001";
options.Audience = "paymentsapi";
options.MapInboundClaims = false;
options.TokenValidationParameters.RoleClaimType = JwtClaimTypes.Role;
});
Notice MapInboundClaims = false. Left at its default, the handler renames the incoming role claim to the legacy http://schemas.microsoft.com/ws/2008/06/identity/claims/role type, RoleClaimType stops matching, and [Authorize(Roles = "admin")] fails silently with a 403 instead of throwing.
| Approach | What it does | Use it when |
|---|---|---|
ApiScope.UserClaims | Adds the user claim whenever the client requests that scope | Claim belongs to a capability (e.g., payments scope carries payments.discount) |
ApiResource.UserClaims | Adds the user claim to every token for that API audience | Claim belongs to the API as a whole (the standard place for role) |
IProfileService | Full programmatic control over issued claims | Claim is computed, tenant-specific, or comes from an external store |
Replacing ITokenService | Rebuilds token creation itself | Almost never (unsupported territory); prefer IProfileService |
How Does Duende Create Access Tokens Internally?
When a token request arrives, Duende’s DefaultTokenService (the built-in implementation of ITokenService) runs a two-step pipeline. CreateAccessTokenAsync() assembles the token model: it collects the standard protocol claims, then calls into IClaimsService, which in turn invokes our IProfileService.GetProfileDataAsync() with the requested claim types gathered from the client’s scopes and API resources.
This is the moment every claim decision happens, and it is why IProfileService is the extension point: by the time the token exists, the claim set is final. CreateSecurityTokenAsync() then takes that model and produces the wire format: it signs the JWT with the server’s signing credentials or, for reference tokens, stores the token and hands back its handle.
Searches for overriding CreateAccessTokenAsync() usually mean the asker wants a claim added; implementing IProfileService achieves that without touching the token pipeline. It stays the supported seam across upgrades, though not an untouched one: IdentityServer 8 added a CancellationToken to both of its methods and renamed ProfileDataRequestContext.Client to Application.
For the IdentityServer4-era take on these mechanisms, see our guide on IdentityServer4 authorization and working with claims, and for keeping sessions alive once the access token expires, see using refresh tokens in ASP.NET Core.
Conclusion
Custom claims in access tokens provide a way to add additional information or attributes about the authenticated user or the context of the authentication process in the application. Including relevant information as custom claims in the access token can help reduce the need for additional queries to the back end or external services during the authorization process. This can lead to more efficient and faster authorization decisions.
Duende IdentityServer provides the IProfileService interface. It is a flexible extension point that allows developers to provide extended information to the access tokens.
Tested with .NET 10.0.10 and Duende IdentityServer 8.0.5.



