← Files Duende SkillsARCHIVED FILE
skills/claims-authorization/SKILL.md
30.9 KB · Oct 5, 2026 · 18:31 UTC
---
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`
SHA-256: 98815465fb9f16fd29035274e2c46c8cefde161dd9a74266258daa8fc7be5f30