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
dotnet add package Keycloak.AuthServices.Authorization.TokenIntrospectionvar 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:
{
"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:
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:
- Skip check — if
realm_accessorresource_accessclaims are already present, the token is not lightweight and introspection is skipped. - Token extraction — the bearer token is extracted directly from the
Authorizationheader. - Cache lookup —
HybridCache.GetOrCreateAsyncchecks for a cached response (keyed byjtior SHA256 hash). - Introspection call — on cache miss, POSTs to
/realms/{realm}/protocol/openid-connect/token/introspectwithtoken,client_id, andclient_secret. - Claim enrichment — introspected claims are added to the
ClaimsIdentity. - Role mapping —
KeycloakRolesClaimsTransformationthen maps roles from the enriched claims.
Default Claim Mapping
| Claim category | Behavior |
|---|---|
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 values | Each array element becomes a separate claim |
| Object values | Stored as a single claim with ValueType = "JSON" |
| Scalar values | Stored as a single string claim |
| Existing claims | Claims already present on the identity are not duplicated |
Extending Introspection
Use OnTokenIntrospected to customize claim mapping after the default enrichment runs:
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:
builder.Services
.AddKeycloakTokenIntrospection(builder.Configuration)
.AddStandardResilienceHandler();Sample
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