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

Realm: my-app Users alice@mail.com bob@mail.com service-acct Realm Roles admin user manager Groups engineering sales support Clients my-web-app confidential / code flow my-spa public / PKCE Client Scopes openid profile email Identity Providers Google Azure AD SAML IdP
A realm contains all identity objects. Each is isolated from other realms.

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).

Browser .NET App ASP.NET Core Keycloak /realms/my-realm Your API JwtBearer JWKS (certs) 1. redirect to login 2. auth code 3. exchange code 4. receives tokens (id, access, refresh) 5. API call with Bearer access_token 6. verify public keys
Authorization Code flow: browser to Keycloak to .NET, then API with Bearer token.

Keycloak issues three token types:

TokenPurposeDefault lifetime
ID TokenContains user identity claims (name, email, roles). Used by your frontend or MVC app.5 minutes
Access TokenSent as Bearer token to your API. Contains roles and scopes. This is what your API validates.5 minutes
Refresh TokenUsed 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:

Access Token Payload (decoded JWT) "iss":"https://keycloak.example.com/realms/my-realm" -- issuer (= Authority) "sub":"a1b2c3d4-e5f6-..." -- user ID in Keycloak "aud":"my-dotnet-app" -- audience (= ClientId) "exp":1700000000 -- expiration (Unix timestamp) "preferred_username":"alice" -- maps to NameClaimType "email":"alice@example.com" "scope":"openid profile email" "realm_access":{ "roles":["admin", "user"] } -- nested! Needs manual extraction in .NET "resource_access":{ "my-dotnet-app":{ "roles": ["editor"] } } -- client-specific roles, also nested
Keycloak nests roles inside realm_access and resource_access. .NET does not read these automatically.

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.

url
https://{host}/realms/{realm}/.well-known/openid-configuration

All endpoints

EndpointURL pathPurpose
Authorization/realms/{realm}/protocol/openid-connect/authRedirects user to login page. Starts the Authorization Code flow.
Token/realms/{realm}/protocol/openid-connect/tokenExchange code for tokens. Also handles client_credentials and refresh_token grants.
UserInfo/realms/{realm}/protocol/openid-connect/userinfoReturns claims about the authenticated user. Requires a valid access token.
Introspect/realms/{realm}/protocol/openid-connect/token/introspectServer-side token validation. Returns active/inactive and metadata.
End Session/realms/{realm}/protocol/openid-connect/logoutLogs the user out. Supports front-channel and back-channel logout.
JWKS (Certs)/realms/{realm}/protocol/openid-connect/certsPublic keys for verifying token signatures. Your JWT middleware fetches this automatically.
Revoke/realms/{realm}/protocol/openid-connect/revokeRevokes a refresh token or access token.
Device Auth/realms/{realm}/protocol/openid-connect/auth/deviceDevice authorization flow for TVs, CLI tools, IoT.
Registration/realms/{realm}/clients-registrations/openid-connectDynamic client registration (if enabled).
Admin API/admin/realms/{realm}REST API for managing users, roles, clients, groups.
Account/realms/{realm}/accountSelf-service UI for users to manage profile, sessions, credentials.

Authorization endpoint parameters

ParameterRequiredDescription
client_idYesYour registered client ID.
redirect_uriYesWhere to send the user after login. Must exactly match Keycloak client config.
response_typeYesUsually "code" for Authorization Code flow.
scopeYesSpace-separated scopes. Minimum: "openid".
stateYesRandom string for CSRF protection. Your middleware generates this.
nonceRecommendedRandom string to prevent replay attacks. Included in ID token.
code_challengeRecommendedPKCE challenge. base64url(SHA256(code_verifier)). Required for public clients.
code_challenge_methodRecommendedUsually "S256" for PKCE.
promptNo"login" forces re-auth, "consent" forces consent screen, "none" for silent auth.
login_hintNoPre-fills the username field on the login page.
kc_idp_hintNoKeycloak-specific. Skips login page and redirects to a specific identity provider.
acr_valuesNoRequest specific authentication levels (step-up authentication).
ui_localesNoPreferred language for the login page.

Token endpoint parameters

ParameterUsed withDescription
grant_typeAlways"authorization_code", "client_credentials", "refresh_token", "password" (deprecated), "urn:ietf:params:oauth:grant-type:token-exchange"
codeauthorization_codeThe authorization code received from the auth endpoint.
redirect_uriauthorization_codeMust match the one used in the auth request.
client_idAlwaysYour client ID.
client_secretConfidential clientsYour client secret. Not needed for public clients with PKCE.
refresh_tokenrefresh_tokenThe refresh token to exchange for new tokens.
scopeOptionalRequested scopes. Can narrow down from original request.
code_verifierPKCEThe original random string used to generate the code_challenge.
subject_tokentoken-exchangeThe token to exchange.
requested_token_typetoken-exchangeType of token you want back.
audiencetoken-exchangeTarget client for the exchanged token.

Token response fields

FieldDescription
access_tokenJWT string. Send as Bearer token to APIs.
id_tokenJWT with user identity claims.
refresh_tokenUsed to get new tokens without re-login.
expires_inSeconds until access_token expires.
refresh_expires_inSeconds until refresh_token expires.
token_typeAlways "Bearer".
session_stateKeycloak session identifier.
scopeScopes 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

shell
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

How the middleware works

.NET middleware pipeline (request flows left to right) Request incoming Authentication Cookie + OIDC Authorization [Authorize] checks Your Controller User.Identity available If not authenticated: Redirect to Keycloak
UseAuthentication() and UseAuthorization() in your pipeline. Order matters.

Minimal setup

csharp
// 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

ParameterTypeDefaultDescription
AuthoritystringnullBase URL of your Keycloak realm. Required. The middleware appends /.well-known/openid-configuration.
ClientIdstringnullThe client_id registered in Keycloak. Required.
ClientSecretstringnullThe client_secret from Keycloak. Required for confidential clients.
ResponseTypestring"id_token"Set to "code" for Authorization Code flow. This is what you want.
UsePkcebooltrue (.NET 7+)Enables PKCE. Adds code_challenge to the auth request. Always keep this on.
SaveTokensboolfalseStores access_token, refresh_token, and id_token in the auth properties. Set to true.
GetClaimsFromUserInfoEndpointboolfalseCalls the UserInfo endpoint after login and merges claims into the identity.
ScopeICollectionopenid, profileScopes to request. Clear defaults first, then add what you need.
CallbackPathstring"/signin-oidc"Path Keycloak redirects to after login. Must match redirect_uri in Keycloak config.
SignedOutCallbackPathstring"/signout-callback-oidc"Path Keycloak redirects to after logout.
RemoteSignOutPathstring"/signout-oidc"Path for front-channel logout notifications from Keycloak.
RequireHttpsMetadatabooltrueSet to false only in development when Keycloak runs on HTTP.
MapInboundClaimsbooltrueWhen false, preserves original JWT claim names. Set to false for cleaner claims.
TokenValidationParametersobjectdefaultsFine-grained control: ValidateIssuer, ValidateAudience, ClockSkew, NameClaimType, RoleClaimType.
MetadataAddressstringnullOverride the well-known config URL. Use if Keycloak is behind a reverse proxy.
ResponseModestring"form_post"How the auth response is returned. "form_post" or "query".
SignInSchemestringCookiesAuth scheme used to persist identity after login.
BackchannelTimeoutTimeSpan60sTimeout for backchannel HTTP calls to Keycloak.
MaxAgeTimeSpan?nullForces re-auth if the Keycloak session is older than this.
Promptstring?null"login" forces re-auth, "consent" forces consent screen, "none" for silent auth.
AccessDeniedPathstringnullWhere to redirect if access is denied.
RefreshIntervalTimeSpan12 hoursHow often to refresh cached discovery metadata.
EventsobjectnullHooks: OnTokenValidated, OnAuthorizationCodeReceived, OnRedirectToIdentityProvider, etc.

Recommended full configuration

csharp
.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)

SettingValue
Client Protocolopenid-connect
Access Typeconfidential
Standard Flow EnabledON
Valid Redirect URIshttps://yourapp.com/signin-oidc
Post Logout Redirect URIshttps://yourapp.com/signout-callback-oidc
Web Originshttps://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:

csharp
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

csharp
// 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.

Client Your API AddJwtBearer() middleware Keycloak JWKS endpoint Authorization: Bearer ... fetch keys (cached) signature valid? 200 OK + response 401 Unauthorized yes: no:
JwtBearer validates the token locally using cached JWKS keys. No call to Keycloak per request.

Install

shell
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Minimal setup

csharp
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

ParameterTypeDefaultDescription
AuthoritystringnullKeycloak realm URL. Middleware fetches JWKS and validates issuer from here.
AudiencestringnullExpected audience claim. Keycloak sets this to the client_id by default.
RequireHttpsMetadatabooltrueRequire HTTPS for metadata. Set false only in dev.
MapInboundClaimsbooltrueSet false to keep original JWT claim names.
SaveTokenboolfalseStore the raw token string, accessible via HttpContext.GetTokenAsync("access_token").
IncludeErrorDetailsbooltrueInclude error details in WWW-Authenticate header on 401.
MetadataAddressstringnullOverride the full discovery URL. Useful behind a proxy.
RefreshOnIssuerKeyNotFoundbooltrueAuto-refresh JWKS if a token has an unknown kid.
BackchannelTimeoutTimeSpan60sTimeout for HTTP calls to Keycloak.
EventsobjectnullHooks: OnTokenValidated, OnAuthenticationFailed, OnChallenge, OnMessageReceived.

TokenValidationParameters

ParameterTypeDefaultDescription
ValidateIssuerbooltrueValidate that the token issuer matches the Authority.
ValidIssuerstringfrom AuthorityExplicit issuer value. Auto-populated from discovery if not set.
ValidateAudiencebooltrueValidate the audience claim.
ValidAudiencestringfrom AudienceExpected audience value.
ValidateLifetimebooltrueReject expired tokens.
ClockSkewTimeSpan5 minutesTolerance for clock differences. Reduce for tighter security.
ValidateIssuerSigningKeybooltrueValidate signature using JWKS keys.
NameClaimTypestringClaimTypes.NameClaim for User.Identity.Name. Set to "preferred_username".
RoleClaimTypestringClaimTypes.RoleClaim for role checks.
RequireExpirationTimebooltrueReject tokens without an exp claim.
RequireSignedTokensbooltrueReject unsigned tokens.
ValidAlgorithmsIEnumerablenullRestrict accepted signing algorithms. Keycloak uses RS256 by default.

Recommended full configuration

csharp
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

csharp
// 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.

http
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

csharp
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.

Service A .NET backend Keycloak /token endpoint Service B protected API 1. client_credentials 2. access_token 3. Bearer token
No user, no browser. Service authenticates with client_id and client_secret directly.
http
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
csharp
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.

t=0 (login) t=5min t=10min t=30min access_token issued access_token expires POST /token grant_type=refresh_token new access_token new refresh_token refresh_token expires user must log in again
Access tokens are short-lived (5 min). Use refresh tokens to get new ones silently. When the refresh token expires, the user must re-authenticate.
http
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).

http
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

http
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

csharp
// 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}

OperationMethodPath
List usersGET/admin/realms/{realm}/users?first=0&max=20
Search usersGET/admin/realms/{realm}/users?search=john
Get user by IDGET/admin/realms/{realm}/users/{user-id}
Create userPOST/admin/realms/{realm}/users
Update userPUT/admin/realms/{realm}/users/{user-id}
Delete userDELETE/admin/realms/{realm}/users/{user-id}
Reset passwordPUT/admin/realms/{realm}/users/{user-id}/reset-password
List realm rolesGET/admin/realms/{realm}/roles
Assign realm rolesPOST/admin/realms/{realm}/users/{user-id}/role-mappings/realm
List groupsGET/admin/realms/{realm}/groups
Add user to groupPUT/admin/realms/{realm}/users/{user-id}/groups/{group-id}
List clientsGET/admin/realms/{realm}/clients
User sessionsGET/admin/realms/{realm}/users/{user-id}/sessions
Logout userPOST/admin/realms/{realm}/users/{user-id}/logout

Create a user

csharp
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

shell
dotnet add package Keycloak.AuthServices.Sdk
csharp
// 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 Browser SignalR ASP.NET Server OIDC + Cookie Keycloak confidential client has client_secret Blazor WebAssembly Browser (WASM) runs .NET in browser handles auth directly PKCE (no secret) Keycloak public client no client_secret
Blazor Server: auth happens on the server (same as MVC). WASM: auth happens in the browser (public client).

Blazor Server

Works almost identically to standard MVC. Same OpenID Connect middleware. The key difference is how you access authentication state in components.

csharp
// 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;
});
razor
<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.

csharp
// 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.

shell
dotnet add package Microsoft.AspNetCore.Components.WebAssembly.Authentication
csharp
// 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

ParameterDescription
AuthorityKeycloak realm URL.
ClientIdPublic client ID. No secret.
ResponseType"code" for Authorization Code + PKCE.
DefaultScopesScopes to request.
RedirectUriWhere Keycloak sends the user after login.
PostLogoutRedirectUriWhere to go after logout.
MetadataUrlOverride discovery URL if needed.

WASM login/logout

razor
<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

SettingValue
Client Protocolopenid-connect
Access Typepublic
Standard Flow EnabledON
Valid Redirect URIshttps://myapp.com/authentication/*
Post Logout Redirect URIshttps://myapp.com/
Web Originshttps://myapp.com

Advanced Scenarios

1. Raw HTTP — no middleware

Skip all Microsoft middleware and handle token validation manually. Useful when you need full control.

csharp
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.

csharp
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

csharp
// 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.

User Service A has user's token Keycloak Service B different audience token A exchange token A new token B (audience=service-b) call with token B
Token exchange lets Service A get a new token scoped for Service B, preserving the user context.
csharp
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

csharp
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

csharp
// 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

yaml
# 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.