Skip to content

Adding Authentication with Keycloak in Granit

Keycloak is the default open-source identity provider for self-hosted .NET deployments. The JWT structure it issues, however, does not look like an ASP.NET Core developer expects: roles live in realm_access.roles or resource_access.{client}.roles, never in the role claim. The name claim is the human-readable display name, not a stable identifier. Without translation, [Authorize(Roles = "admin")] returns 403 even with a valid admin token.

Granit.Authentication.JwtBearer.Keycloak is the thin module that handles this. One [DependsOn], one config block — JWT validation, claims transformation, back-channel logout, and an Admin policy all wire up. This article walks through the full setup.

  • JWT Bearer validation against your Keycloak realm’s discovery document.
  • Claims transformationrealm_access.roles and resource_access.{ClientId}.roles mapped to ClaimTypes.Role so [Authorize(Roles = "admin")] works.
  • NameClaimType = preferred_usernameUser.Identity.Name returns the Keycloak username instead of the GUID sub.
  • Back-channel logout — when a session is revoked at Keycloak, your API rejects the token immediately.
  • Pluggable role source — switch between realm and client roles via a single config key.

You need three things in your realm:

  1. A client for your API (my-backend in the examples below). Set Access Type: confidential if you’ll need a client secret, otherwise bearer-only.
  2. The realm or client roles your API will check (admin, doctor, support…).
  3. Role assignments for your test users.

If you also have a public client (SPA, mobile app), configure it separately to obtain tokens — the API itself only validates them.

AppModule.cs
[DependsOn(typeof(GranitAuthenticationJwtBearerKeycloakModule))]
public sealed class AppModule : GranitModule
{
public override void OnApplicationInitialization(
ApplicationInitializationContext context)
{
context.App.UseAuthentication();
context.App.UseAuthorization();
// Optional: enables OIDC back-channel logout
context.App.MapGranitBackChannelLogout();
}
}

That is the entire C# setup. The module declares [DependsOn(GranitAuthenticationJwtBearerModule)] itself, so the generic JWT Bearer middleware is registered transitively.

appsettings.json
{
"Authentication": {
"Authority": "https://keycloak.example.com/realms/clinic",
"Audience": "my-backend",
"RequireHttpsMetadata": true,
"BackChannelLogout": {
"Enabled": true,
"EndpointPath": "/auth/back-channel-logout",
"SessionRevocationTtl": "01:00:00"
}
},
"Authentication:Keycloak": {
"Authority": "https://keycloak.example.com/realms/clinic",
"ClientId": "my-backend",
"RoleClaimsSource": "realm_access"
}
}

The Keycloak section overrides the generic JWT Bearer values via PostConfigure<JwtBearerOptions>. You can keep both blocks or set everything under Keycloak — the module reads from there at resolution time.

KeyPurpose
AuthorityOIDC issuer URL — the realm endpoint
ClientIdKeycloak client name — defaults the audience
AudienceExpected aud claim (defaults to ClientId)
RoleClaimsSourcerealm_access (default) or resource_access
RequireHttpsMetadataSet to false only for local dev with HTTP Keycloak

A typical Keycloak access token payload looks like this:

{
"sub": "8f45a91b-3c12-4f6d-a5e0-3c9c1e1f0a44",
"preferred_username": "alice@clinic.example.com",
"realm_access": {
"roles": ["admin", "doctor"]
},
"resource_access": {
"my-backend": {
"roles": ["manage-patients"]
}
}
}

KeycloakClaimsTransformation runs on every authenticated request. It reads the JSON realm_access.roles array (or resource_access.{ClientId}.roles if you set RoleClaimsSource = "resource_access") and emits one ClaimTypes.Role per entry:

// After transformation, the principal carries:
new Claim(ClaimTypes.Role, "admin")
new Claim(ClaimTypes.Role, "doctor")
new Claim(ClaimTypes.Role, "manage-patients") // if RoleClaimsSource = resource_access

The standard ASP.NET Core authorization stack now works:

PatientEndpoints.cs
patients.MapGet("/", ListPatientsAsync)
.RequireAuthorization() // Any authenticated user
.WithName("ListPatients");
patients.MapDelete("/{id:guid}", DeletePatientAsync)
.RequireAuthorization(policy => policy.RequireRole("admin"))
.WithName("DeletePatient");

Realm roles vs client roles — which to use?

Section titled “Realm roles vs client roles — which to use?”
SourceWhenTrade-off
realm_accessRoles meaningful across the whole realm (e.g. admin, support)Simpler, but every API in the realm sees the same role names
resource_accessRoles scoped to one API (e.g. manage-patients on my-backend)Better isolation, more administrative work in Keycloak

For multi-API realms, prefer resource_access. It scopes role names to your client, so a manage-patients role on the clinic API does not collide with a manage-patients role on a billing API.

Granit.Authentication.JwtBearer registers ICurrentUserService — a clean abstraction over IHttpContextAccessor that you inject anywhere:

AuditService.cs
public class AuditService(ICurrentUserService currentUser, IClock clock)
{
public AuditEntry Capture(string action)
{
return new AuditEntry
{
UserId = currentUser.UserId, // sub claim
UserName = currentUser.UserName, // preferred_username
Roles = currentUser.Roles.ToArray(), // ClaimTypes.Role values
OccurredAt = clock.Now,
Action = action,
};
}
}

Tests substitute ICurrentUserService with a fake — no HttpContext plumbing.

Token expiry is not enough. If an admin revokes a user at the Keycloak admin console, that user’s token remains valid until it expires, sometimes 30 minutes later. For regulated environments (healthcare, finance), that gap is unacceptable.

OIDC defines back-channel logout for exactly this. Keycloak POSTs a signed logout token to your API; you record the revoked session in distributed cache and reject any subsequent token bound to it.

Granit ships the endpoint and the validation:

context.App.MapGranitBackChannelLogout();

This wires POST /auth/back-channel-logout (anonymous — Keycloak calls it directly), validates the logout token signature against the realm JWKS, extracts the sid claim, and stores it in IDistributedCache for SessionRevocationTtl. The JWT Bearer events handler checks this cache on every authenticated request and returns 401 for revoked sessions.

In the Keycloak admin console, configure your client’s Backchannel Logout URL to https://your-api.example.com/auth/back-channel-logout.

The fastest path to a working setup:

Terminal window
docker run --rm -p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:25.0 start-dev

Open http://localhost:8080, create a realm clinic, a client my-backend, a role admin, and a user with that role. Then point your API at it:

{
"Authentication": {
"Authority": "http://localhost:8080/realms/clinic",
"Audience": "my-backend",
"RequireHttpsMetadata": false
},
"Authentication:Keycloak": {
"Authority": "http://localhost:8080/realms/clinic",
"ClientId": "my-backend"
}
}

Grab a token from /realms/clinic/protocol/openid-connect/token (Direct Access Grants) and call your API with Authorization: Bearer <token>. Roles flow through, [Authorize(Roles = "admin")] works, and User.Identity.Name is the Keycloak username.

  • One [DependsOn(typeof(GranitAuthenticationJwtBearerKeycloakModule))] registers JWT Bearer, claims transformation, and the audit-friendly ICurrentUserService.
  • realm_access.roles and resource_access.{ClientId}.roles map to ClaimTypes.Role automatically — [Authorize(Roles = "...")] just works.
  • NameClaimType = "preferred_username" makes User.Identity.Name the Keycloak username.
  • Back-channel logout closes the token-revocation gap; enable it for any compliance-sensitive deployment.
  • Switching to Entra ID, Cognito, or Google Cloud is a one-line change — the surrounding code stays identical.