← Files Duende SkillsARCHIVED FILE

skills/claims-authorization/SKILL.md

30.9 KB · Oct 2, 2026 · 00:31 UTC

↓ Download file

---
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