Claims and Claims Transformation

Control how token claims become a ClaimsPrincipal, fix role and name mapping, and add derived claims safely with IClaimsTransformation.

Syntaxbuilder.Services.AddTransient<IClaimsTransformation, PermissionClaimsTransformation>(); // JwtBearer: o.MapInboundClaims = false; // o.TokenValidationParameters.RoleClaimType = "roles";

After authentication, HttpContext.User is a ClaimsPrincipal holding one or more ClaimsIdentity objects, each a list of Claim (type, value, issuer). Two properties of the identity matter more than they look: NameClaimType decides what User.Identity.Name returns and RoleClaimType decides which claims IsInRole, RequireRole and [Authorize(Roles = ...)] look at. The authentication handler sets both.

Inbound claim mapping

JwtBearer's MapInboundClaims is true by default and renames short JWT claim names to long legacy URIs:

In the tokenDefault (mapped)MapInboundClaims = false
subClaimTypes.NameIdentifiersub
role, rolesClaimTypes.Rolerole, roles
emailClaimTypes.Emailemail

That is why User.FindFirst("sub") returns null in a default project. Choose one convention for the whole API. Most teams turn mapping off, so claim names match the token, the identity provider's documentation and other services, and then set TokenValidationParameters.NameClaimType = "name" and RoleClaimType = "roles" (or "role") so Name and IsInRole keep working. Forgetting the second step leaves every role check false.

IClaimsTransformation

Register one IClaimsTransformation and IAuthenticationService.AuthenticateAsync calls TransformAsync after every successful authentication. That is not once per request: the authentication middleware, a policy that names schemes, and any explicit HttpContext.AuthenticateAsync() each trigger it. Handlers cache their own result, so the same original principal comes back each time. AuthenticationService also remembers, per request, the principals it has already transformed and skips the transformation when handed one of them. When you return a clone, the handler's original is never in that set, so TransformAsync runs again on every call and gets the original each time. The rules follow from that:

  • Add claims to a clone (principal.Clone()) or a separate identity instead of mutating the input: the principal a handler returns may be shared or cached outside the request, for example a cookie ticket.
  • Be cheap: cache any database or HTTP lookup, keyed on what it depends on, with a short expiry so revoked permissions disappear.
  • Normalise, do not invent: flatten nested role claims and map external group ids to your role names. Decisions about a specific order belong in authorization handlers.

Alternatives

JwtBearerEvents.OnTokenValidated runs once per token validation and belongs to one scheme; use it when the enrichment depends on that issuer. IClaimsTransformation is scheme-agnostic. Often the best option is no extra claim at all: a handler that looks up permissions when a policy needs them keeps tokens small and decisions current.

Production failure modes

  • Keycloak puts realm roles in a nested realm_access JSON object, so IsInRole is always false until something flattens them.
  • A transformation that queries the database on every call doubles the load of every authenticated request.
  • Large role or permission lists added as claims inflate cookies, headers and logs.
  • Claims are a snapshot of the token: a role removed in the identity provider still works until the token expires.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Make [Authorize(Roles = "finance")] work with Entra ID tokens by setting RoleClaimType to "roles" with MapInboundClaims off
  • Flatten Keycloak's realm_access.roles into role claims so RequireRole("warehouse") succeeds
  • Add refunds:issue and invoices:read permission claims for the finance role, cached for five minutes

More examples

JwtBearer with token claim names kept as-is

Turning off <code>MapInboundClaims</code> keeps the claim names the identity provider documents, so <code>FindFirstValue("sub")</code> works and other services read the same names. The price is that the identity's name and role claim types must be set explicitly, because the defaults point at the long URIs that no longer appear. Entra ID app roles arrive in <code>roles</code>; check what your provider actually emits before choosing.

Example · csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = "orders-api";
        // Keep "sub", "roles" and "email" instead of the long ClaimTypes URIs.
        options.MapInboundClaims = false;
        // Without these, User.Identity.Name is null and IsInRole is always false.
        options.TokenValidationParameters.NameClaimType = "name";
        options.TokenValidationParameters.RoleClaimType = "roles";
    });
builder.Services.AddAuthorization();

var app = builder.Build();

app.MapGet("/me", (ClaimsPrincipal user) => TypedResults.Ok(new
{
    customerId = user.FindFirstValue("sub"),
    name = user.Identity?.Name,
    isFinance = user.IsInRole("finance"),
})).RequireAuthorization();

app.MapPost("/refunds", () => TypedResults.Accepted("/refunds/7781"))
    .RequireAuthorization(p => p.RequireRole("finance"));

app.Run();

Flattening Keycloak realm roles

<code>ClaimsPrincipal.IsInRole</code> asks each identity in turn, using that identity's own <code>RoleClaimType</code>, so the added identity is created with the role type set to <code>roles</code>. The marker check only guards against being handed an already-transformed principal, for example when a test calls the transformation directly; inside ASP.NET Core each <code>AuthenticateAsync</code> passes the handler's original principal, so the parsing runs again every time. If that matters, cache the result as the permissions demo does with <code>IMemoryCache</code>. Register it with <code>AddTransient&lt;IClaimsTransformation, KeycloakRolesTransformation&gt;()</code>; only one transformation is used, so combine steps in one class if you need several.

Example · csharp
using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;

namespace Shop.Api.Authentication;

// Keycloak sends: "realm_access": { "roles": ["finance", "offline_access"] }
public sealed class KeycloakRolesTransformation : IClaimsTransformation
{
    private const string RoleType = "roles";
    private const string MarkerType = "keycloak-roles";

    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        if (principal.Identity is not ClaimsIdentity { IsAuthenticated: true } source
            || principal.Identities.Any(i => i.AuthenticationType == MarkerType))
            return Task.FromResult(principal);

        var realmAccess = principal.FindFirst("realm_access")?.Value;
        if (realmAccess is null) return Task.FromResult(principal);

        using var document = JsonDocument.Parse(realmAccess);
        if (!document.RootElement.TryGetProperty("roles", out var roles) || roles.ValueKind != JsonValueKind.Array)
            return Task.FromResult(principal);

        var roleClaims = roles.EnumerateArray()
            .Select(r => r.GetString())
            .OfType<string>()
            .Select(r => new Claim(RoleType, r));

        var clone = principal.Clone();
        // Same role claim type as the main identity, so IsInRole finds these claims.
        clone.AddIdentity(new ClaimsIdentity(roleClaims, MarkerType, source.NameClaimType, RoleType));
        return Task.FromResult(clone);
    }
}

Enriching once per token with OnTokenValidated

<code>OnTokenValidated</code> runs inside the JwtBearer handler, once per validated token per request, and its result is cached with the handler's authentication result, so repeated <code>AuthenticateAsync</code> calls do not repeat the lookup. It only applies to this scheme, which is right when the enrichment depends on that issuer. Mutating <code>context.Principal</code> is fine here because the principal was just created for this request.

Example · csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<ICustomerDirectory, CustomerDirectory>();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false;
        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = async context =>
            {
                var subject = context.Principal?.FindFirstValue("sub");
                if (subject is null) return;

                var directory = context.HttpContext.RequestServices.GetRequiredService<ICustomerDirectory>();
                var tier = await directory.GetLoyaltyTierAsync(subject, context.HttpContext.RequestAborted);
                context.Principal!.AddIdentity(new ClaimsIdentity([new Claim("loyalty_tier", tier)], "directory"));
            },
        };
    });
builder.Services.AddAuthorization();

var app = builder.Build();
app.MapGet("/offers", (ClaimsPrincipal user) => TypedResults.Ok(new { tier = user.FindFirstValue("loyalty_tier") }))
    .RequireAuthorization();
app.Run();

public interface ICustomerDirectory
{
    Task<string> GetLoyaltyTierAsync(string customerId, CancellationToken cancellationToken);
}

public sealed class CustomerDirectory : ICustomerDirectory
{
    public Task<string> GetLoyaltyTierAsync(string customerId, CancellationToken cancellationToken) =>
        Task.FromResult(customerId.StartsWith("vip-", StringComparison.Ordinal) ? "gold" : "standard");
}

Discussion

  • Be the first to comment on this lesson.