Help us improve
Share bugs, ideas, or general feedback.
From duende-skills
Claims transformation and profile service patterns for Duende IdentityServer — IProfileService, IClaimsTransformation, claim type mapping, token claim filtering, extension grant validators, and dynamic claims loading.
npx claudepluginhub duendesoftware/duende-skills --plugin duende-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/duende-skills:claims-authorizationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
- You are implementing or customizing `IProfileService` to control which claims are emitted into identity tokens, access tokens, or the userinfo endpoint.
Configures JWT Bearer authentication for .NET APIs with access tokens, refresh tokens, token rotation, and user context extraction from claims.
Guides ASP.NET Core authentication and authorization with JWT bearer tokens, OpenID Connect, ASP.NET Identity, policies, roles, claims, and API keys. For login, endpoint protection, and auth rules.
Guides deployment workflows with CI/CD patterns, rolling/blue-green/canary strategies, multi-stage Dockerfiles for Node.js, health checks, rollbacks, and production checklists for web apps.
Share bugs, ideas, or general feedback.
IProfileService to control which claims are emitted into identity tokens, access tokens, or the userinfo endpoint.IdentityResource, ApiScope, or ApiResource UserClaims collections and need to understand how requested scopes drive ProfileDataRequestContext.RequestedClaimTypes.IExtensionGrantValidator and need to emit custom claims into the resulting access token.MapInboundClaims, JwtClaimTypes vs. Microsoft ClaimTypes).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.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.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.| Document | Description | When to Load |
|---|---|---|
| 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 | External provider login callback with claim mapping, Google/AAD normalization, and ClaimActions | External provider, Google, Azure AD, OIDC callback, claim mapping, ExternalCookieAuthenticationScheme |
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 is the primary extensibility point for claims in Duende IdentityServer. Register your implementation with AddProfileService<T>() during startup.
// 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);
}
| 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 via scopes/resources. |
IssuedClaims | Populate this collection with claims to include in the token. |
AddRequestedClaims(IEnumerable<Claim>) | Helper that filters your claims to only those in RequestedClaimTypes. |
// ✅ 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.Calleris aProfileIsActiveCallersconstant 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.
// Program.cs
builder.Services.AddIdentityServer()
.AddProfileService<ApplicationProfileService>();
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:
// ✅ 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);
}
// ❌ 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;
}
The Caller property lets you tailor claims for each token type:
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);
}
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:
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));
}
sub, auth_time, amr, idp, sid, nonce.// ✅ Prefer userinfo for profile data — keep id_token lean
// On the client (ASP.NET Core OIDC handler):
options.GetClaimsFromUserInfoEndpoint = true;
options.SaveTokens = true;
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).
// ⚠️ Use sparingly — increases id_token size significantly
var client = new Client
{
ClientId = "native_app",
AlwaysIncludeUserClaimsInIdToken = true,
AllowedScopes = { "openid", "profile", "email" },
};
sub, client_id, scope, jti, iss, exp, + any user claims from profile service.ApiResource only appear when that resource is requested via resource indicator.Declare claims on ApiResource to scope them to that specific API:
// ✅ 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
}
Set ClaimValueType to ensure correct JSON serialization in the JWT:
// ✅ 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),
};
// ❌ Without ClaimValueType, all values serialize as JSON strings
// { "account_id": "42" } ← wrong, should be 42
new Claim("account_id", "42")
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.
The default JWT bearer handler maps short JWT claim names to long Microsoft WS-Federation names. Disable this:
// ✅ 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
});
// ✅ 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;
});
// ❌ Without MapInboundClaims = false:
// User.FindFirst("sub") → null
// User.FindFirst("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier") → found
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.
ClaimsPrincipal with application-specific roles from a local database, after validating a token from IdentityServer.tenant_id) for use in authorization policies.// ✅ 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;
}
}
// Program.cs — in the API
builder.Services.AddTransient<IClaimsTransformation, TenantClaimsTransformation>();
Do not use
IClaimsTransformationon the IdentityServer host to modify token claims. It runs during cookie sign-in/validation and does not affect token content — useIProfileServicethere instead.
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 for a full token exchange validator implementation with error handling and custom claim propagation.
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 for the full callback controller implementation with Google/AAD claim mapping and
ClaimActionsexamples.
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.
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:
GetProfileDataAsyncis called on every token issuance, including refresh token redemptions. Use caching (IMemoryCache,IDistributedCache) for expensive lookups, keyed bysubjectId + clientId. Cache TTL should be shorter than your access token lifetime.
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 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.
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 = trueon the client definition.
For dynamic client claims (e.g. set based on runtime context), implement a custom token request validator:
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));
}
}
UserClaims: The claim type must be listed in the UserClaims collection of the IdentityResource, ApiScope, or ApiResource that the client requests.// ❌ "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" }
}
Client not requesting the scope: The client must include the scope in AllowedScopes and request it at authorization time.
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.
Caused by not setting MapInboundClaims = false. The JWT bearer handler renames sub to the long WS-Federation URI. Fix:
// ✅ Always set this in APIs consuming IdentityServer tokens
options.MapInboundClaims = false;
ClaimsPrincipal instances can be cached and reused. Always create a new ClaimsIdentity and add it to the principal rather than mutating an existing identity:
// ✅ 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"));
Setting AlwaysIncludeUserClaimsInIdToken = true embeds all profile claims in the identity token. This:
Prefer options.GetClaimsFromUserInfoEndpoint = true in the client OIDC handler.
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.
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.
aspnetcore-authorization — policy-based authorization, IAuthorizationRequirement, resource-based authorization using claims in the ClaimsPrincipalidentityserver-configuration — configuring IdentityResource, ApiScope, ApiResource, and Client definitions that drive which claims are requestedaspnetcore-authentication — cookie authentication, OIDC handler configuration, MapInboundClaims, and GetClaimsFromUserInfoEndpoint