Skip to content

Token Introspection

Keycloak 24+ supports lightweight access tokens — valid signed JWTs that are stripped of business claims like realm_access, resource_access, and preferred_username. This reduces token size but means role-based authorization will silently fail (403 Forbidden) because the claims are missing from the JWT.

AddKeycloakTokenIntrospection() solves this by calling the Keycloak token introspection endpoint to resolve the full claim set and enrich the ClaimsPrincipal before authorization runs.

Table of Contents:

Setup

bash
dotnet add package Keycloak.AuthServices.Authorization.TokenIntrospection
csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddKeycloakWebApiAuthentication(builder.Configuration);

builder.Services.AddKeycloakTokenIntrospection(builder.Configuration);

builder.Services
    .AddKeycloakAuthorization(options =>
    {
        builder.Configuration.GetSection("Keycloak").Bind(options);
        options.EnableRolesMapping = RolesClaimTransformationSource.All;
    })
    .AddAuthorizationBuilder()
    .AddPolicy("AdminOnly", policy => policy.RequireRealmRoles("Admin"));

Configuration

Token introspection binds to the same "Keycloak" configuration section. A confidential client with credentials.secret is required:

json
{
  "Keycloak": {
    "realm": "Test",
    "auth-server-url": "http://localhost:8080/",
    "resource": "test-client",
    "credentials": {
      "secret": "your-client-secret"
    }
  }
}

Cache Duration

Introspection results are cached per token using HybridCache (keyed by jti claim or SHA256 hash of the token). Default cache duration is 60 seconds:

csharp
builder.Services.AddKeycloakTokenIntrospection(options =>
{
    builder.Configuration.GetSection("Keycloak").Bind(options);
    options.CacheDuration = TimeSpan.FromSeconds(120);
});

Startup Validation

The library validates at startup that AuthServerUrl, Realm, Resource, and Credentials.Secret are all set. Missing values cause a fail-fast exception.

How It Works

The introspection pipeline runs as an IClaimsTransformation:

  1. Skip check — if realm_access or resource_access claims are already present, the token is not lightweight and introspection is skipped.
  2. Token extraction — the bearer token is extracted directly from the Authorization header.
  3. Cache lookupHybridCache.GetOrCreateAsync checks for a cached response (keyed by jti or SHA256 hash).
  4. Introspection call — on cache miss, POSTs to /realms/{realm}/protocol/openid-connect/token/introspect with token, client_id, and client_secret.
  5. Claim enrichment — introspected claims are added to the ClaimsIdentity.
  6. Role mappingKeycloakRolesClaimsTransformation then maps roles from the enriched claims.

Default Claim Mapping

Claim categoryBehavior
Skipped (active, iat, exp, nbf, iss, sub, aud, jti, typ, azp, auth_time, session_state, acr, sid)Not added — these are infrastructure claims already present from JWT validation
JSON claims (resource_access, realm_access, organization)Stored as a single claim with ValueType = "JSON" and the raw JSON as value
Array valuesEach array element becomes a separate claim
Object valuesStored as a single claim with ValueType = "JSON"
Scalar valuesStored as a single string claim
Existing claimsClaims already present on the identity are not duplicated

Extending Introspection

Use OnTokenIntrospected to customize claim mapping after the default enrichment runs:

csharp
builder.Services.AddKeycloakTokenIntrospection(options =>
{
    builder.Configuration.GetSection("Keycloak").Bind(options);

    options.OnTokenIntrospected = (identity, claims) =>
    {
        // Add a custom claim from the introspection response
        if (claims.TryGetValue("department", out var dept))
        {
            identity.AddClaim(new Claim("department", dept.GetString()!));
        }

        // Remove a claim added by default enrichment
        var unwanted = identity.FindFirst("some_claim");
        if (unwanted is not null)
        {
            identity.RemoveClaim(unwanted);
        }
    };
});

The delegate receives the ClaimsIdentity (already enriched by default logic) and the raw Dictionary<string, JsonElement> from the introspection response.

Resilience

AddKeycloakTokenIntrospection() returns IHttpClientBuilder, so you can chain resilience policies:

csharp
builder.Services
    .AddKeycloakTokenIntrospection(builder.Configuration)
    .AddStandardResilienceHandler();

Sample

cs
using System.Security.Claims;
using Keycloak.AuthServices.Authorization;
using Microsoft.AspNetCore.Authentication;

var builder = WebApplication.CreateBuilder(args);

var configuration = builder.Configuration;
var services = builder.Services;
var useCustomTransform = configuration.GetValue<bool>("CustomTransform");

services.AddKeycloakWebApiAuthentication(configuration);

if (useCustomTransform)
{
    services.AddTransient<IClaimsTransformation, CustomClaimsTransformation>();
}

services
    .AddKeycloakAuthorization(options =>
    {
        configuration.GetSection("Keycloak").Bind(options);
        options.EnableRolesMapping = RolesClaimTransformationSource.All;
    })
    .AddAuthorizationBuilder()
    .AddPolicy("AdminOnly", policy => policy.RequireRealmRoles("Admin"));

services.AddKeycloakTokenIntrospection(configuration);

var app = builder.Build();

app.UseAuthentication().UseAuthorization();

app.MapGet(
        "/me",
        (ClaimsPrincipal user) =>
            Results.Ok(
                new
                {
                    user.Identity?.Name,
                    Claims = user.Claims.Select(c => new { c.Type, c.Value }),
                }
            )
    )
    .RequireAuthorization();

app.MapGet(
        "/roles",
        (ClaimsPrincipal user) =>
        {
            var roles = user.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToList();
            return Results.Ok(new { Roles = roles });
        }
    )
    .RequireAuthorization();

app.MapGet("/admin", () => Results.Ok(new { Message = "Admin area" }))
    .RequireAuthorization("AdminOnly");

await app.RunAsync();

internal sealed class CustomClaimsTransformation : IClaimsTransformation
{
    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        var identity = principal.Identity as ClaimsIdentity;
        if (identity is not null && !identity.HasClaim("custom_marker", "true"))
        {
            identity.AddClaim(new Claim("custom_marker", "true"));
        }

        return Task.FromResult(principal);
    }
}

See sample source code: keycloak-authorization-services-dotnet/tree/main/samples/TokenIntrospection