Core Concepts
Keycloak is an open-source identity and access management server. It handles authentication, authorization, user federation, and identity brokering so your .NET application does not have to. It speaks OpenID Connect, OAuth 2.0, and SAML 2.0.
Realm
A realm is a namespace. It manages a set of users, credentials, roles, and groups. Each realm is completely isolated. Your Keycloak server comes with a "master" realm for admin tasks. You create additional realms for your applications. A realm maps roughly to a tenant.
Client
A client is an application that can request authentication. In .NET terms, your ASP.NET Core web app is a confidential client (it has a server-side secret), and your SPA or mobile app is a public client (it cannot keep a secret). Each client gets a Client ID and optionally a Client Secret.
User
A user belongs to a realm. Users have credentials (password, OTP), attributes (email, name, custom fields), and can be assigned roles directly or through groups.
Role
Roles come in two kinds: realm roles (global to the realm) and client roles (scoped to a specific client). In .NET, these map to claims in your JWT token and can be used with [Authorize(Roles = "admin")].
Group
Groups are collections of users. You assign roles to a group, and every user in that group inherits those roles. Think of them as Active Directory groups.
Scope
Client scopes define what claims and roles are included in tokens. Default scopes are always included. Optional scopes must be explicitly requested. Standard scopes include openid, profile, email, roles.
Protocol Mapper
Protocol mappers transform user or client data into token claims. They control exactly what ends up in your JWT. You can add custom mappers in the Keycloak admin console to include any user attribute as a claim.
Identity Provider
An external login source configured per realm: Google, GitHub, Azure AD, a SAML IdP, or another Keycloak instance. Users can link their accounts to multiple providers.
Realm structure
How it maps to .NET
When your .NET app authenticates with Keycloak, the flow is: your app redirects the user to Keycloak's login page, Keycloak authenticates them, then redirects back with an authorization code. Your app exchanges that code for tokens. The ID token contains user identity claims. The access token is sent to your APIs. Your APIs validate the access token using Keycloak's public keys (JWKS).
Keycloak issues three token types:
| Token | Purpose | Default lifetime |
|---|---|---|
| ID Token | Contains user identity claims (name, email, roles). Used by your frontend or MVC app. | 5 minutes |
| Access Token | Sent as Bearer token to your API. Contains roles and scopes. This is what your API validates. | 5 minutes |
| Refresh Token | Used to get new access and ID tokens without re-login. Never send this to an API. | 30 minutes |
What a Keycloak JWT looks like
The access token is a JWT with three parts. Here is the structure of the payload that matters to your .NET code:
Keycloak Endpoints
Every Keycloak realm exposes a set of well-known URLs. Replace {host} with your Keycloak server address and {realm} with your realm name.
Discovery (start here)
This is the single most important URL. Set it as the Authority in your .NET app and the middleware discovers everything else automatically.
https://{host}/realms/{realm}/.well-known/openid-configuration
All endpoints
| Endpoint | URL path | Purpose |
|---|---|---|
| Authorization | /realms/{realm}/protocol/openid-connect/auth | Redirects user to login page. Starts the Authorization Code flow. |
| Token | /realms/{realm}/protocol/openid-connect/token | Exchange code for tokens. Also handles client_credentials and refresh_token grants. |
| UserInfo | /realms/{realm}/protocol/openid-connect/userinfo | Returns claims about the authenticated user. Requires a valid access token. |
| Introspect | /realms/{realm}/protocol/openid-connect/token/introspect | Server-side token validation. Returns active/inactive and metadata. |
| End Session | /realms/{realm}/protocol/openid-connect/logout | Logs the user out. Supports front-channel and back-channel logout. |
| JWKS (Certs) | /realms/{realm}/protocol/openid-connect/certs | Public keys for verifying token signatures. Your JWT middleware fetches this automatically. |
| Revoke | /realms/{realm}/protocol/openid-connect/revoke | Revokes a refresh token or access token. |
| Device Auth | /realms/{realm}/protocol/openid-connect/auth/device | Device authorization flow for TVs, CLI tools, IoT. |
| Registration | /realms/{realm}/clients-registrations/openid-connect | Dynamic client registration (if enabled). |
| Admin API | /admin/realms/{realm} | REST API for managing users, roles, clients, groups. |
| Account | /realms/{realm}/account | Self-service UI for users to manage profile, sessions, credentials. |
Authorization endpoint parameters
| Parameter | Required | Description |
|---|---|---|
| client_id | Yes | Your registered client ID. |
| redirect_uri | Yes | Where to send the user after login. Must exactly match Keycloak client config. |
| response_type | Yes | Usually "code" for Authorization Code flow. |
| scope | Yes | Space-separated scopes. Minimum: "openid". |
| state | Yes | Random string for CSRF protection. Your middleware generates this. |
| nonce | Recommended | Random string to prevent replay attacks. Included in ID token. |
| code_challenge | Recommended | PKCE challenge. base64url(SHA256(code_verifier)). Required for public clients. |
| code_challenge_method | Recommended | Usually "S256" for PKCE. |
| prompt | No | "login" forces re-auth, "consent" forces consent screen, "none" for silent auth. |
| login_hint | No | Pre-fills the username field on the login page. |
| kc_idp_hint | No | Keycloak-specific. Skips login page and redirects to a specific identity provider. |
| acr_values | No | Request specific authentication levels (step-up authentication). |
| ui_locales | No | Preferred language for the login page. |
Token endpoint parameters
| Parameter | Used with | Description |
|---|---|---|
| grant_type | Always | "authorization_code", "client_credentials", "refresh_token", "password" (deprecated), "urn:ietf:params:oauth:grant-type:token-exchange" |
| code | authorization_code | The authorization code received from the auth endpoint. |
| redirect_uri | authorization_code | Must match the one used in the auth request. |
| client_id | Always | Your client ID. |
| client_secret | Confidential clients | Your client secret. Not needed for public clients with PKCE. |
| refresh_token | refresh_token | The refresh token to exchange for new tokens. |
| scope | Optional | Requested scopes. Can narrow down from original request. |
| code_verifier | PKCE | The original random string used to generate the code_challenge. |
| subject_token | token-exchange | The token to exchange. |
| requested_token_type | token-exchange | Type of token you want back. |
| audience | token-exchange | Target client for the exchanged token. |
Token response fields
| Field | Description |
|---|---|
| access_token | JWT string. Send as Bearer token to APIs. |
| id_token | JWT with user identity claims. |
| refresh_token | Used to get new tokens without re-login. |
| expires_in | Seconds until access_token expires. |
| refresh_expires_in | Seconds until refresh_token expires. |
| token_type | Always "Bearer". |
| session_state | Keycloak session identifier. |
| scope | Scopes granted in the token. |
OpenID Connect Integration
This is the standard way to add Keycloak authentication to an ASP.NET Core web application (MVC or Razor Pages). It uses the Authorization Code flow with server-side token handling.
Install
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
How the middleware works
Minimal setup
// Program.cs
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://keycloak.example.com/realms/my-realm";
options.ClientId = "my-dotnet-app";
options.ClientSecret = "my-client-secret";
options.ResponseType = "code";
options.SaveTokens = true;
});
app.UseAuthentication();
app.UseAuthorization();
That is the minimum. The middleware discovers all endpoints from the Authority automatically by fetching the .well-known/openid-configuration document.
All OpenIdConnectOptions
| Parameter | Type | Default | Description |
|---|---|---|---|
| Authority | string | null | Base URL of your Keycloak realm. Required. The middleware appends /.well-known/openid-configuration. |
| ClientId | string | null | The client_id registered in Keycloak. Required. |
| ClientSecret | string | null | The client_secret from Keycloak. Required for confidential clients. |
| ResponseType | string | "id_token" | Set to "code" for Authorization Code flow. This is what you want. |
| UsePkce | bool | true (.NET 7+) | Enables PKCE. Adds code_challenge to the auth request. Always keep this on. |
| SaveTokens | bool | false | Stores access_token, refresh_token, and id_token in the auth properties. Set to true. |
| GetClaimsFromUserInfoEndpoint | bool | false | Calls the UserInfo endpoint after login and merges claims into the identity. |
| Scope | ICollection | openid, profile | Scopes to request. Clear defaults first, then add what you need. |
| CallbackPath | string | "/signin-oidc" | Path Keycloak redirects to after login. Must match redirect_uri in Keycloak config. |
| SignedOutCallbackPath | string | "/signout-callback-oidc" | Path Keycloak redirects to after logout. |
| RemoteSignOutPath | string | "/signout-oidc" | Path for front-channel logout notifications from Keycloak. |
| RequireHttpsMetadata | bool | true | Set to false only in development when Keycloak runs on HTTP. |
| MapInboundClaims | bool | true | When false, preserves original JWT claim names. Set to false for cleaner claims. |
| TokenValidationParameters | object | defaults | Fine-grained control: ValidateIssuer, ValidateAudience, ClockSkew, NameClaimType, RoleClaimType. |
| MetadataAddress | string | null | Override the well-known config URL. Use if Keycloak is behind a reverse proxy. |
| ResponseMode | string | "form_post" | How the auth response is returned. "form_post" or "query". |
| SignInScheme | string | Cookies | Auth scheme used to persist identity after login. |
| BackchannelTimeout | TimeSpan | 60s | Timeout for backchannel HTTP calls to Keycloak. |
| MaxAge | TimeSpan? | null | Forces re-auth if the Keycloak session is older than this. |
| Prompt | string? | null | "login" forces re-auth, "consent" forces consent screen, "none" for silent auth. |
| AccessDeniedPath | string | null | Where to redirect if access is denied. |
| RefreshInterval | TimeSpan | 12 hours | How often to refresh cached discovery metadata. |
| Events | object | null | Hooks: OnTokenValidated, OnAuthorizationCodeReceived, OnRedirectToIdentityProvider, etc. |
Recommended full configuration
.AddOpenIdConnect(options =>
{
options.Authority = "https://keycloak.example.com/realms/my-realm";
options.ClientId = "my-dotnet-app";
options.ClientSecret = "your-secret-here";
options.ResponseType = "code";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.RequireHttpsMetadata = true;
options.MapInboundClaims = false;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("roles");
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "preferred_username",
RoleClaimType = "realm_access.roles",
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = "my-dotnet-app",
};
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = context =>
{
// Extract realm roles and add as standard role claims
var identity = context.Principal?.Identity as ClaimsIdentity;
var realmAccess = context.Principal?
.FindFirst("realm_access")?.Value;
if (realmAccess != null)
{
var parsed = JsonDocument.Parse(realmAccess);
if (parsed.RootElement.TryGetProperty("roles", out var roles))
{
foreach (var role in roles.EnumerateArray())
{
identity?.AddClaim(new Claim(
ClaimTypes.Role,
role.GetString()!
));
}
}
}
return Task.CompletedTask;
},
};
});
Keycloak client settings (admin console)
| Setting | Value |
|---|---|
| Client Protocol | openid-connect |
| Access Type | confidential |
| Standard Flow Enabled | ON |
| Valid Redirect URIs | https://yourapp.com/signin-oidc |
| Post Logout Redirect URIs | https://yourapp.com/signout-callback-oidc |
| Web Origins | https://yourapp.com |
Role mapping from Keycloak tokens
Keycloak puts roles inside a nested JSON object in the token, not as flat claims. You need to extract them. Here is a reusable ClaimsTransformation:
public class KeycloakRolesClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = principal.Identity as ClaimsIdentity;
if (identity == null) return Task.FromResult(principal);
// Realm roles
var realmAccess = principal.FindFirst("realm_access")?.Value;
if (realmAccess != null)
{
var parsed = JsonDocument.Parse(realmAccess);
if (parsed.RootElement.TryGetProperty("roles", out var roles))
{
foreach (var role in roles.EnumerateArray())
{
identity.AddClaim(new Claim(ClaimTypes.Role, role.GetString()!));
}
}
}
// Client roles
var resourceAccess = principal.FindFirst("resource_access")?.Value;
if (resourceAccess != null)
{
var parsed = JsonDocument.Parse(resourceAccess);
if (parsed.RootElement.TryGetProperty("my-dotnet-app", out var client)
&& client.TryGetProperty("roles", out var clientRoles))
{
foreach (var role in clientRoles.EnumerateArray())
{
identity.AddClaim(new Claim(ClaimTypes.Role, role.GetString()!));
}
}
}
return Task.FromResult(principal);
}
}
// Register in Program.cs
builder.Services.AddTransient<IClaimsTransformation, KeycloakRolesClaimsTransformation>();
Retrieving stored tokens
// In a controller or endpoint (requires SaveTokens = true)
var accessToken = await HttpContext.GetTokenAsync("access_token");
var refreshToken = await HttpContext.GetTokenAsync("refresh_token");
var idToken = await HttpContext.GetTokenAsync("id_token");
JWT Bearer Authentication
Use this when your ASP.NET Core app is a pure API. No browser login, no cookies, no redirects. Clients send an access token in the Authorization header and the middleware validates it against Keycloak's public keys.
Install
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
Minimal setup
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://keycloak.example.com/realms/my-realm";
options.Audience = "my-api-client";
});
app.UseAuthentication();
app.UseAuthorization();
All JwtBearerOptions
| Parameter | Type | Default | Description |
|---|---|---|---|
| Authority | string | null | Keycloak realm URL. Middleware fetches JWKS and validates issuer from here. |
| Audience | string | null | Expected audience claim. Keycloak sets this to the client_id by default. |
| RequireHttpsMetadata | bool | true | Require HTTPS for metadata. Set false only in dev. |
| MapInboundClaims | bool | true | Set false to keep original JWT claim names. |
| SaveToken | bool | false | Store the raw token string, accessible via HttpContext.GetTokenAsync("access_token"). |
| IncludeErrorDetails | bool | true | Include error details in WWW-Authenticate header on 401. |
| MetadataAddress | string | null | Override the full discovery URL. Useful behind a proxy. |
| RefreshOnIssuerKeyNotFound | bool | true | Auto-refresh JWKS if a token has an unknown kid. |
| BackchannelTimeout | TimeSpan | 60s | Timeout for HTTP calls to Keycloak. |
| Events | object | null | Hooks: OnTokenValidated, OnAuthenticationFailed, OnChallenge, OnMessageReceived. |
TokenValidationParameters
| Parameter | Type | Default | Description |
|---|---|---|---|
| ValidateIssuer | bool | true | Validate that the token issuer matches the Authority. |
| ValidIssuer | string | from Authority | Explicit issuer value. Auto-populated from discovery if not set. |
| ValidateAudience | bool | true | Validate the audience claim. |
| ValidAudience | string | from Audience | Expected audience value. |
| ValidateLifetime | bool | true | Reject expired tokens. |
| ClockSkew | TimeSpan | 5 minutes | Tolerance for clock differences. Reduce for tighter security. |
| ValidateIssuerSigningKey | bool | true | Validate signature using JWKS keys. |
| NameClaimType | string | ClaimTypes.Name | Claim for User.Identity.Name. Set to "preferred_username". |
| RoleClaimType | string | ClaimTypes.Role | Claim for role checks. |
| RequireExpirationTime | bool | true | Reject tokens without an exp claim. |
| RequireSignedTokens | bool | true | Reject unsigned tokens. |
| ValidAlgorithms | IEnumerable | null | Restrict accepted signing algorithms. Keycloak uses RS256 by default. |
Recommended full configuration
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://keycloak.example.com/realms/my-realm";
options.Audience = "my-api-client";
options.RequireHttpsMetadata = true;
options.MapInboundClaims = false;
options.SaveToken = true;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "preferred_username",
RoleClaimType = ClaimTypes.Role,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
options.Events = new JwtBearerEvents
{
OnTokenValidated = context =>
{
// Transform Keycloak nested roles into flat role claims
return Task.CompletedTask;
},
OnAuthenticationFailed = context =>
{
Console.WriteLine($"Auth failed: {context.Exception.Message}");
return Task.CompletedTask;
},
};
});
Protecting endpoints
// Require authentication
[Authorize]
public class SecureController : ControllerBase { }
// Require specific role
[Authorize(Roles = "admin")]
public IActionResult AdminOnly() => Ok();
// Policy-based authorization
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("CanEditPosts", policy =>
policy.RequireRole("editor", "admin"));
options.AddPolicy("PremiumUser", policy =>
policy.RequireClaim("subscription", "premium"));
});
[Authorize(Policy = "CanEditPosts")]
public IActionResult EditPost() => Ok();
Token Flows
These are the raw HTTP requests your code makes to Keycloak. Understanding these helps when debugging, writing custom integrations, or working without middleware.
1. Authorization Code Flow
The standard web app flow. User logs in via browser, your server exchanges the code for tokens.
Step 1: Redirect user to Keycloak
GET https://keycloak.example.com/realms/my-realm/protocol/openid-connect/auth
?client_id=my-dotnet-app
&redirect_uri=https://myapp.com/signin-oidc
&response_type=code
&scope=openid profile email
&state=random-csrf-token
&nonce=random-nonce
&code_challenge=BASE64URL(SHA256(code_verifier))
&code_challenge_method=S256
Step 2: User logs in, Keycloak redirects back
GET https://myapp.com/signin-oidc?code=AUTH_CODE&state=random-csrf-token
Step 3: Server exchanges code for tokens
POST https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://myapp.com/signin-oidc
&client_id=my-dotnet-app
&client_secret=your-secret
&code_verifier=ORIGINAL_RANDOM_STRING
.NET code for direct token exchange
var client = new HttpClient();
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = authCode,
["redirect_uri"] = "https://myapp.com/signin-oidc",
["client_id"] = "my-dotnet-app",
["client_secret"] = "your-secret",
["code_verifier"] = codeVerifier,
});
var response = await client.PostAsync(
"https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token",
content);
var json = await response.Content.ReadFromJsonAsync<JsonDocument>();
var accessToken = json.RootElement.GetProperty("access_token").GetString();
var refreshToken = json.RootElement.GetProperty("refresh_token").GetString();
var idToken = json.RootElement.GetProperty("id_token").GetString();
2. Client Credentials Flow
Service-to-service authentication. No user involved. Your backend authenticates itself.
POST https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials &client_id=my-service &client_secret=service-secret &scope=openid
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "my-service",
["client_secret"] = "service-secret",
["scope"] = "openid",
});
var response = await httpClient.PostAsync(tokenEndpoint, content);
var token = (await response.Content.ReadFromJsonAsync<JsonDocument>())
.RootElement.GetProperty("access_token").GetString();
// Use it
var apiClient = new HttpClient();
apiClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
3. Refresh Token Flow
Exchange a refresh token for new access and ID tokens without the user logging in again.
POST https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token Content-Type: application/x-www-form-urlencoded grant_type=refresh_token &refresh_token=REFRESH_TOKEN_HERE &client_id=my-dotnet-app &client_secret=your-secret
4. Token Introspection
Server-side check if a token is still active (not just valid by signature).
POST https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token/introspect Content-Type: application/x-www-form-urlencoded token=ACCESS_TOKEN_HERE &client_id=my-dotnet-app &client_secret=your-secret
5. Token Revocation
POST https://keycloak.example.com/realms/my-realm/protocol/openid-connect/revoke Content-Type: application/x-www-form-urlencoded token=REFRESH_TOKEN_HERE &token_type_hint=refresh_token &client_id=my-dotnet-app &client_secret=your-secret
Admin REST API
Keycloak exposes a full REST API for managing realms, users, roles, groups, and clients. You need an admin access token to use it.
Getting an admin token
// Using a service account with admin roles
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = "admin-cli",
["client_secret"] = "admin-secret",
});
var response = await httpClient.PostAsync(
"https://keycloak.example.com/realms/master/protocol/openid-connect/token",
content);
var token = (await response.Content.ReadFromJsonAsync<JsonDocument>())
.RootElement.GetProperty("access_token").GetString();
Common endpoints
Base URL: https://keycloak.example.com/admin/realms/{realm}
| Operation | Method | Path |
|---|---|---|
| List users | GET | /admin/realms/{realm}/users?first=0&max=20 |
| Search users | GET | /admin/realms/{realm}/users?search=john |
| Get user by ID | GET | /admin/realms/{realm}/users/{user-id} |
| Create user | POST | /admin/realms/{realm}/users |
| Update user | PUT | /admin/realms/{realm}/users/{user-id} |
| Delete user | DELETE | /admin/realms/{realm}/users/{user-id} |
| Reset password | PUT | /admin/realms/{realm}/users/{user-id}/reset-password |
| List realm roles | GET | /admin/realms/{realm}/roles |
| Assign realm roles | POST | /admin/realms/{realm}/users/{user-id}/role-mappings/realm |
| List groups | GET | /admin/realms/{realm}/groups |
| Add user to group | PUT | /admin/realms/{realm}/users/{user-id}/groups/{group-id} |
| List clients | GET | /admin/realms/{realm}/clients |
| User sessions | GET | /admin/realms/{realm}/users/{user-id}/sessions |
| Logout user | POST | /admin/realms/{realm}/users/{user-id}/logout |
Create a user
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", adminToken);
var user = new
{
username = "newuser",
email = "[email protected]",
firstName = "New",
lastName = "User",
enabled = true,
emailVerified = true,
credentials = new[]
{
new
{
type = "password",
value = "initial-password",
temporary = true
}
}
};
var response = await httpClient.PostAsJsonAsync(
"https://keycloak.example.com/admin/realms/my-realm/users",
user);
// New user ID is in the Location header
var userId = response.Headers.Location?.Segments.Last();
Using Keycloak.AuthServices.Sdk
dotnet add package Keycloak.AuthServices.Sdk
// Program.cs
builder.Services.AddKeycloakAdminHttpClient(builder.Configuration);
// appsettings.json
{
"Keycloak": {
"realm": "my-realm",
"auth-server-url": "https://keycloak.example.com/",
"resource": "admin-cli",
"credentials": {
"secret": "admin-secret"
}
}
}
// In a service
public class UserService
{
private readonly IKeycloakUserClient _userClient;
public UserService(IKeycloakUserClient userClient)
=> _userClient = userClient;
public async Task<IEnumerable<UserRepresentation>> GetUsers()
=> await _userClient.GetUsers("my-realm");
public async Task CreateUser(string username, string email)
=> await _userClient.CreateUser("my-realm", new UserRepresentation
{
Username = username,
Email = email,
Enabled = true,
});
}
Blazor Integration
Blazor Server
Works almost identically to standard MVC. Same OpenID Connect middleware. The key difference is how you access authentication state in components.
// Program.cs — same OpenID Connect setup as MVC
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://keycloak.example.com/realms/my-realm";
options.ClientId = "blazor-server-app";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.SaveTokens = true;
options.MapInboundClaims = false;
});
<AuthorizeView>
<Authorized>
<p>Hello, @context.User.Identity?.Name</p>
</Authorized>
<NotAuthorized>
<p>Please log in.</p>
</NotAuthorized>
</AuthorizeView>
@* Require a role *@
<AuthorizeView Roles="admin">
<Authorized>
<p>Admin content here.</p>
</Authorized>
</AuthorizeView>
Accessing tokens in Blazor Server
You cannot use HttpContext directly in Blazor components after the initial render. Capture tokens during the first HTTP request.
// 1. Token provider service
public class TokenProvider
{
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
}
// 2. Register as scoped
builder.Services.AddScoped<TokenProvider>();
// 3. Populate in _Host.cshtml (during initial HTTP request)
@inject TokenProvider TokenProvider
@{
TokenProvider.AccessToken = await HttpContext.GetTokenAsync("access_token") ?? "";
TokenProvider.RefreshToken = await HttpContext.GetTokenAsync("refresh_token") ?? "";
}
// 4. Inject in any component
@inject TokenProvider TokenProvider
// Use TokenProvider.AccessToken to call APIs
Blazor WebAssembly
WASM runs in the browser. It is a public client: no client_secret, uses PKCE. Different auth library.
dotnet add package Microsoft.AspNetCore.Components.WebAssembly.Authentication
// Program.cs (Blazor WASM)
builder.Services.AddOidcAuthentication(options =>
{
options.ProviderOptions.Authority =
"https://keycloak.example.com/realms/my-realm";
options.ProviderOptions.ClientId = "blazor-wasm-app";
options.ProviderOptions.ResponseType = "code";
options.ProviderOptions.DefaultScopes.Add("openid");
options.ProviderOptions.DefaultScopes.Add("profile");
options.ProviderOptions.DefaultScopes.Add("email");
options.ProviderOptions.PostLogoutRedirectUri = "https://myapp.com/";
options.ProviderOptions.RedirectUri =
"https://myapp.com/authentication/login-callback";
});
WASM OidcProviderOptions
| Parameter | Description |
|---|---|
| Authority | Keycloak realm URL. |
| ClientId | Public client ID. No secret. |
| ResponseType | "code" for Authorization Code + PKCE. |
| DefaultScopes | Scopes to request. |
| RedirectUri | Where Keycloak sends the user after login. |
| PostLogoutRedirectUri | Where to go after logout. |
| MetadataUrl | Override discovery URL if needed. |
WASM login/logout
<AuthorizeView>
<Authorized>
<span>Hello, @context.User.Identity?.Name</span>
<button @onclick="Logout">Log out</button>
</Authorized>
<NotAuthorized>
<a href="authentication/login">Log in</a>
</NotAuthorized>
</AuthorizeView>
@code {
private async Task Logout()
{
await SignOutManager.SetSignOutState();
Navigation.NavigateTo("authentication/logout");
}
}
Keycloak client settings for WASM
| Setting | Value |
|---|---|
| Client Protocol | openid-connect |
| Access Type | public |
| Standard Flow Enabled | ON |
| Valid Redirect URIs | https://myapp.com/authentication/* |
| Post Logout Redirect URIs | https://myapp.com/ |
| Web Origins | https://myapp.com |
Advanced Scenarios
1. Raw HTTP — no middleware
Skip all Microsoft middleware and handle token validation manually. Useful when you need full control.
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
public class ManualTokenValidator
{
private readonly HttpClient _httpClient = new();
private readonly string _authority;
public ManualTokenValidator(string authority) => _authority = authority;
public async Task<ClaimsPrincipal?> ValidateToken(string token)
{
// Fetch JWKS
var jwksJson = await _httpClient.GetStringAsync(
$"{_authority}/protocol/openid-connect/certs");
var jwks = new JsonWebKeySet(jwksJson);
var handler = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = _authority,
ValidateAudience = true,
ValidAudience = "my-client",
ValidateLifetime = true,
IssuerSigningKeys = jwks.GetSigningKeys(),
ClockSkew = TimeSpan.FromSeconds(30),
};
try
{
return handler.ValidateToken(token, parameters, out _);
}
catch (SecurityTokenException)
{
return null;
}
}
}
2. Multi-tenant (multiple realms)
When your app serves multiple organizations, each with their own Keycloak realm. You resolve the authority dynamically per request.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidAudience = "my-api",
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = async context =>
{
var tenant = context.Request.Headers["X-Tenant"].FirstOrDefault();
if (string.IsNullOrEmpty(tenant))
{
context.Fail("No tenant specified");
return;
}
var authority = $"https://keycloak.example.com/realms/{tenant}";
context.Options.TokenValidationParameters.ValidIssuer = authority;
// Fetch and cache JWKS per tenant
}
};
});
3. Logout flows
// Front-channel logout (redirect-based)
public IActionResult Logout()
{
return SignOut(
new AuthenticationProperties { RedirectUri = "/" },
CookieAuthenticationDefaults.AuthenticationScheme,
OpenIdConnectDefaults.AuthenticationScheme
);
}
// The middleware builds:
// GET /realms/{realm}/protocol/openid-connect/logout
// ?id_token_hint=ID_TOKEN
// &post_logout_redirect_uri=https://myapp.com/signout-callback-oidc
// Back-channel logout (server-to-server)
// Keycloak POSTs to your app when a session ends elsewhere
app.MapPost("/backchannel-logout", async (HttpContext context) =>
{
var form = await context.Request.ReadFormAsync();
var logoutToken = form["logout_token"].ToString();
// Validate the logout_token JWT
// Extract "sid" claim, invalidate matching local session
return Results.Ok();
});
// Register in Keycloak: Backchannel Logout URL = https://myapp.com/backchannel-logout
4. Token Exchange
Exchange one token for another. Service A calls Service B on behalf of the original user.
var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "urn:ietf:params:oauth:grant-type:token-exchange",
["subject_token"] = originalAccessToken,
["subject_token_type"] = "urn:ietf:params:oauth:token-type:access_token",
["requested_token_type"] = "urn:ietf:params:oauth:token-type:access_token",
["client_id"] = "service-a",
["client_secret"] = "service-a-secret",
["audience"] = "service-b",
});
var response = await httpClient.PostAsync(tokenEndpoint, content);
5. Custom claims transformation
public class AppClaimsTransformation : IClaimsTransformation
{
private readonly IUserRepository _users;
public AppClaimsTransformation(IUserRepository users) => _users = users;
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity!;
var keycloakId = principal.FindFirst("sub")?.Value;
if (keycloakId == null) return principal;
// Load app-specific data
var appUser = await _users.GetByExternalId(keycloakId);
if (appUser != null)
{
identity.AddClaim(new Claim("app_user_id", appUser.Id.ToString()));
identity.AddClaim(new Claim("subscription_tier", appUser.Tier));
foreach (var perm in appUser.Permissions)
identity.AddClaim(new Claim("permission", perm));
}
// Flatten nested realm roles
var realmAccess = principal.FindFirst("realm_access")?.Value;
if (realmAccess != null)
{
using var doc = JsonDocument.Parse(realmAccess);
if (doc.RootElement.TryGetProperty("roles", out var roles))
foreach (var role in roles.EnumerateArray())
identity.AddClaim(new Claim(ClaimTypes.Role, role.GetString()!));
}
return principal;
}
}
builder.Services.AddTransient<IClaimsTransformation, AppClaimsTransformation>();
6. Protecting SignalR hubs
// SignalR sends tokens via query string for WebSocket connections
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken)
&& path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
[Authorize]
public class ChatHub : Hub
{
public override Task OnConnectedAsync()
{
var user = Context.User?.FindFirst("preferred_username")?.Value;
return base.OnConnectedAsync();
}
}
7. Docker for development
# docker-compose.yml
version: '3.8'
services:
keycloak:
image: quay.io/keycloak/keycloak:latest
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- "8080:8080"
After starting, go to http://localhost:8080, log in as admin/admin, create a realm, create a client, and point your .NET app's Authority to http://localhost:8080/realms/your-realm. Set RequireHttpsMetadata = false in development.