Keycloak Authentication in ASP.NET Core (.NET 8)

Guilliano Molaire Guilliano Molaire Updated June 10, 2026 11 min read

Last updated: May 2026

ASP.NET Core (.NET 8) integrates with Keycloak through the framework’s built-in OpenID Connect and JWT Bearer middleware — no Keycloak-specific adapter or SDK is required. You point Microsoft.AspNetCore.Authentication.OpenIdConnect or Microsoft.AspNetCore.Authentication.JwtBearer at Keycloak’s realm discovery document, and the middleware handles token validation, session management, and claims extraction automatically. The only Keycloak-specific work is mapping realm and resource roles into .NET’s ClaimsPrincipal, since Keycloak structures role claims differently from the RFC 7519 standard expected by the framework.

This tutorial covers two scenarios: a server-rendered web application that uses the OIDC Authorization Code flow with PKCE for interactive login, and a REST API that validates JWT Bearer tokens issued by Keycloak. Both use Keycloak 26.x with the /realms/{realm} path structure. The /auth/ prefix was removed in Keycloak 17 and should not appear in any current configuration.

Keycloak Client Configuration

Before writing any .NET code, you need at least one Keycloak client. The client type depends on which scenario you are building.

Web Application Client (Confidential, OIDC Code Flow)

In the Keycloak Admin Console, navigate to your realm and create a new client:

  • Client ID: dotnet-webapp
  • Client authentication: On (makes this a confidential client)
  • Authentication flow: Standard flow (Authorization Code) — check this, uncheck everything else
  • Valid redirect URIs: https://localhost:7001/signin-oidc
  • Valid post logout redirect URIs: https://localhost:7001/signout-callback-oidc
  • Web origins: https://localhost:7001

After saving, open the Credentials tab and copy the client secret. You will need it in appsettings.json.

PKCE is enabled by default for confidential clients in Keycloak 21 and later. No additional toggle is required.

API Client (Bearer-Only Style)

For a resource server that only validates tokens, you have two options. The simplest is to reuse the web app client and validate tokens issued to it. For a dedicated API client:

  • Client ID: dotnet-api
  • Client authentication: On
  • Authentication flow: Uncheck all flows (the API never initiates logins)

The API validates tokens using Keycloak’s JWKS endpoint, which is advertised in the discovery document. No client secret is needed on the API side for token validation — only the authority URL and the expected audience.

Setting Up the JWT Bearer API

NuGet Packages

The JWT Bearer middleware ships with the .NET 8 SDK. No third-party packages are required.

<!-- No additional NuGet packages needed beyond the standard ASP.NET Core web API template -->
<!-- Microsoft.AspNetCore.Authentication.JwtBearer is included in the framework -->

Program.cs for the API

using Microsoft.AspNetCore.Authentication.JwtBearer;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // Keycloak realm discovery document URL (no /auth/ prefix in Keycloak 17+)
        options.Authority = "https://keycloak.example.com/realms/myrealm";

        // Must match the 'aud' claim in tokens issued to this client.
        // For Keycloak, the audience is the client_id unless you add a custom audience mapper.
        options.Audience = "dotnet-api";

        options.RequireHttpsMetadata = true; // set false only in local dev

        options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = "https://keycloak.example.com/realms/myrealm",
            ValidateAudience = true,
            ValidAudience = "dotnet-api",
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30),
        };

        // Map Keycloak roles into .NET ClaimsPrincipal roles (see section below)
        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = ctx =>
            {
                ctx.Principal = KeycloakClaimsTransformer.Transform(ctx.Principal!);
                return Task.CompletedTask;
            }
        };
    });

builder.Services.AddAuthorization();
builder.Services.AddControllers();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

For local development without a trusted TLS certificate on Keycloak, set options.RequireHttpsMetadata = false. Never set this in production. See the Microsoft ASP.NET Core authentication documentation for the full list of JwtBearerOptions properties.

appsettings.json

{
  "Authentication": {
    "Authority": "https://keycloak.example.com/realms/myrealm",
    "Audience": "dotnet-api"
  }
}

You can bind these values into Program.cs instead of hard-coding them:

options.Authority = builder.Configuration["Authentication:Authority"];
options.Audience  = builder.Configuration["Authentication:Audience"];

Mapping Keycloak Roles into .NET Claims

This is the most common integration gap. Keycloak does not place roles in the http://schemas.microsoft.com/ws/2008/06/identity/claims/role claim that .NET’s [Authorize(Roles = "...")] attribute reads. Instead, Keycloak encodes roles in two places inside the JWT payload:

  • realm_access.roles — roles granted at the realm level
  • resource_access.{client_id}.roles — roles granted for a specific client

You need a claims transformer that reads these nested JSON structures and adds standard role claims to the principal.

using System.Security.Claims;
using System.Text.Json;

public static class KeycloakClaimsTransformer
{
    private const string RealmAccessClaim    = "realm_access";
    private const string ResourceAccessClaim = "resource_access";
    private const string RolesClaim          = "roles";

    public static ClaimsPrincipal Transform(ClaimsPrincipal principal)
    {
        var identity = principal.Identity as ClaimsIdentity;
        if (identity is null) return principal;

        // Map realm-level roles
        var realmAccessClaim = identity.FindFirst(RealmAccessClaim);
        if (realmAccessClaim is not null)
        {
            MapRoles(identity, realmAccessClaim.Value);
        }

        // Map client-level roles (resource_access is a JSON object keyed by client_id)
        var resourceAccessClaim = identity.FindFirst(ResourceAccessClaim);
        if (resourceAccessClaim is not null)
        {
            using var doc = JsonDocument.Parse(resourceAccessClaim.Value);
            foreach (var client in doc.RootElement.EnumerateObject())
            {
                if (client.Value.TryGetProperty(RolesClaim, out var roles))
                {
                    foreach (var role in roles.EnumerateArray())
                    {
                        var roleValue = role.GetString();
                        if (!string.IsNullOrWhiteSpace(roleValue))
                        {
                            identity.AddClaim(new Claim(ClaimTypes.Role, roleValue));
                        }
                    }
                }
            }
        }

        return principal;
    }

    private static void MapRoles(ClaimsIdentity identity, string json)
    {
        using var doc = JsonDocument.Parse(json);
        if (!doc.RootElement.TryGetProperty(RolesClaim, out var roles)) return;

        foreach (var role in roles.EnumerateArray())
        {
            var roleValue = role.GetString();
            if (!string.IsNullOrWhiteSpace(roleValue))
            {
                identity.AddClaim(new Claim(ClaimTypes.Role, roleValue));
            }
        }
    }
}

Use the JWT Token Analyzer to paste a Keycloak access token and inspect the exact shape of realm_access and resource_access before writing the transformer. The structure varies based on how your realm and client mappers are configured.

For a deeper look at what Keycloak tokens contain and how validation works, see Keycloak token validation for APIs.

Role-Based Authorization

Once roles are mapped into ClaimTypes.Role, standard ASP.NET Core authorization works without modification.

Attribute-Based Role Checks

[ApiController]
[Route("api/[controller]")]
[Authorize] // Any authenticated user
public class OrdersController : ControllerBase
{
    [HttpGet]
    public IActionResult GetOrders() => Ok("Your orders");

    [HttpDelete("{id}")]
    [Authorize(Roles = "admin")] // realm-level 'admin' role required
    public IActionResult DeleteOrder(int id) => Ok($"Deleted {id}");
}

Policy-Based Authorization

Policy-based authorization is preferable for complex rules because it keeps authorization logic out of controllers and makes it testable.

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequireAdminRole", policy =>
        policy.RequireRole("admin"));

    options.AddPolicy("RequirePremiumOrAdmin", policy =>
        policy.RequireAssertion(ctx =>
            ctx.User.IsInRole("premium") || ctx.User.IsInRole("admin")));

    options.AddPolicy("VerifiedEmailOnly", policy =>
        policy.RequireClaim("email_verified", "true"));
});

Apply policies with the Policy parameter:

[Authorize(Policy = "RequirePremiumOrAdmin")]
[HttpGet("reports")]
public IActionResult GetReports() => Ok("Premium reports");

Understanding how scopes and roles interplay is covered in the OAuth 2.0 visual guide for developers.

Setting Up the OIDC Web Application

For a server-rendered Razor Pages or MVC app that needs interactive login, use AddOpenIdConnect alongside AddCookie.

Program.cs for the Web App

using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme          = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
    options.Cookie.HttpOnly = true;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.SameSite = SameSiteMode.Lax;
    options.ExpireTimeSpan = TimeSpan.FromHours(8);
    options.SlidingExpiration = true;
})
.AddOpenIdConnect(options =>
{
    options.Authority    = "https://keycloak.example.com/realms/myrealm";
    options.ClientId     = "dotnet-webapp";
    options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"];

    options.ResponseType = OpenIdConnectResponseType.Code; // Authorization Code flow
    options.UsePkce      = true;                           // PKCE is recommended

    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.Scope.Add("offline_access"); // request refresh token

    options.SaveTokens = true; // stores access/refresh tokens in the auth cookie

    options.GetClaimsFromUserInfoEndpoint = true;

    options.TokenValidationParameters = new()
    {
        NameClaimType = "preferred_username",
        RoleClaimType = ClaimTypes.Role,
    };

    // Map Keycloak roles after OIDC token validation
    options.Events = new OpenIdConnectEvents
    {
        OnTokenValidated = ctx =>
        {
            ctx.Principal = KeycloakClaimsTransformer.Transform(ctx.Principal!);
            return Task.CompletedTask;
        }
    };
});

builder.Services.AddRazorPages();
builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();

app.Run();

The client secret should come from environment variables or .NET’s Secret Manager, not from appsettings.json in source control.

Storing the Client Secret Safely

# Development: use Secret Manager
dotnet user-secrets set "Keycloak:ClientSecret" "your-client-secret-here"

In production on a container platform or cloud host, inject it as an environment variable (Keycloak__ClientSecret) or read it from a secrets vault.

Token Refresh for the Web App Flow

When SaveTokens = true and offline_access scope is requested, the OIDC middleware stores the refresh token in the encrypted auth cookie. Refreshing is not automatic — you must implement it.

A common pattern is a middleware or background service that checks token expiry and calls the token endpoint before forwarding requests to downstream APIs.

public static class TokenRefreshExtensions
{
    public static async Task<string?> GetFreshAccessTokenAsync(
        this HttpContext context,
        IHttpClientFactory httpClientFactory)
    {
        var expiresAt = await context.GetTokenAsync("expires_at");

        if (DateTimeOffset.TryParse(expiresAt, out var expiry)
            && expiry > DateTimeOffset.UtcNow.AddSeconds(60))
        {
            // Token still valid; return it directly
            return await context.GetTokenAsync("access_token");
        }

        // Token is expired or close to expiring; refresh it
        var refreshToken = await context.GetTokenAsync("refresh_token");
        if (string.IsNullOrWhiteSpace(refreshToken)) return null;

        var authority   = "https://keycloak.example.com/realms/myrealm";
        var tokenEndpoint = $"{authority}/protocol/openid-connect/token";

        var httpClient = httpClientFactory.CreateClient();
        var response = await httpClient.PostAsync(tokenEndpoint, new FormUrlEncodedContent(
            new Dictionary<string, string>
            {
                ["grant_type"]    = "refresh_token",
                ["client_id"]     = "dotnet-webapp",
                ["client_secret"] = "your-client-secret", // inject via DI in practice
                ["refresh_token"] = refreshToken,
            }));

        if (!response.IsSuccessStatusCode) return null;

        var json = await response.Content.ReadFromJsonAsync<JsonElement>();
        var newAccessToken  = json.GetProperty("access_token").GetString();
        var newRefreshToken = json.GetProperty("refresh_token").GetString();
        var expiresIn       = json.GetProperty("expires_in").GetInt32();

        // Update the tokens stored in the auth cookie
        var authResult = await context.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        if (authResult.Properties is null) return newAccessToken;

        authResult.Properties.UpdateTokenValue("access_token",  newAccessToken!);
        authResult.Properties.UpdateTokenValue("refresh_token", newRefreshToken!);
        authResult.Properties.UpdateTokenValue("expires_at",
            DateTimeOffset.UtcNow.AddSeconds(expiresIn).ToString("o"));

        await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme,
            authResult.Principal!, authResult.Properties);

        return newAccessToken;
    }
}

For architectures where the web app acts as a proxy to backend APIs, the Backend for Frontend pattern keeps tokens server-side and avoids exposing them to the browser. See the Keycloak BFF pattern guide for a worked example.

Common Pitfalls

Audience Validation Failures

The most frequent production error is IDX10214: Audience validation failed. By default, Keycloak does not add the API client ID to the aud claim unless you configure an audience mapper.

In the Keycloak Admin Console, go to your client, open Client Scopes, then the dedicated scope for your client (dotnet-api-dedicated). Add a mapper of type Audience and set the Included Client Audience to dotnet-api. After saving, tokens issued via this client will include "aud": ["dotnet-api", "account"].

Alternatively, if you want to accept tokens issued to the web app client in the API, add the web app client ID as an audience in the API client’s scope, or set ValidateAudience = false (not recommended for production).

For a full walkthrough of audience and scope configuration, see configuring CORS with your Keycloak OIDC client, which covers the client scope setup in detail.

HTTPS Metadata Fetch in Development

The middleware fetches the OpenID Connect discovery document from {Authority}/.well-known/openid-configuration at startup. If Keycloak is running on HTTP locally (e.g., http://localhost:8080), set options.RequireHttpsMetadata = false. This error surfaces as a startup exception, not a 401, so it can be confusing at first.

Clock Skew Between Server and Keycloak

JWT exp validation uses wall-clock time. If your application server’s clock drifts more than a few seconds from Keycloak’s clock, tokens will fail validation with IDX10223: Lifetime validation failed. The default ClockSkew in the middleware is 5 minutes, which is deliberately generous. Set it to 30-60 seconds once your environments have NTP synchronized.

Role Claims Not Appearing

If [Authorize(Roles = "admin")] always returns 403 even for users who have the role in Keycloak, the transformer is not running or the role name does not match exactly. Use the JWT Token Analyzer to decode a live access token and confirm the exact role string under realm_access.roles or resource_access.dotnet-api.roles. Role names in Keycloak are case-sensitive.

Using preferred_username as the User Name

By default, .NET maps the sub claim to ClaimTypes.NameIdentifier and name to ClaimTypes.Name. Keycloak’s name claim may be the display name, not the username. To use preferred_username as the name displayed in the UI, set NameClaimType = "preferred_username" in TokenValidationParameters.

JWT Best Practices

Regardless of which flow you use, follow secure token handling practices: short access token lifetimes (5-15 minutes), rotate refresh tokens, store tokens only in HttpOnly cookies for browser clients, and never log raw token values. The JWT best practices for developers guide covers these patterns in depth.

For API-only token validation without a browser client, the OAuth 2.0 visual guide for developers explains the full token flow from authorization request to introspection.

Frequently Asked Questions

Do I need the Keycloak .NET adapter?

No. The Keycloak .NET adapter was deprecated years ago and is not maintained. The recommended approach is to use ASP.NET Core’s built-in Microsoft.AspNetCore.Authentication.JwtBearer for APIs and Microsoft.AspNetCore.Authentication.OpenIdConnect for web applications. Both ship with the .NET 8 SDK and do not require any Keycloak-specific package.

How do I validate a Keycloak token in a .NET minimal API?

The setup is identical. After calling app.UseAuthentication() and app.UseAuthorization(), apply the [Authorize] attribute or .RequireAuthorization() to route groups:

app.MapGet("/secure", () => "Hello, authenticated user!")
   .RequireAuthorization();

app.MapGet("/admin-only", () => "Hello, admin!")
   .RequireAuthorization(new AuthorizeAttribute { Roles = "admin" });

The middleware and transformer work the same way in minimal API projects.

Why is realm_access not appearing in my token?

Keycloak only includes realm_access when the user has at least one realm-level role. If the claim is missing, either the user has no realm roles assigned, or the client’s scope configuration has suppressed it. Check the client’s Evaluate tab in the Admin Console to preview what a token for a given user would look like, without issuing a real token.

How do I call a downstream API from the web app using the user’s access token?

After authenticating with AddOpenIdConnect and SaveTokens = true, retrieve the stored access token with HttpContext.GetTokenAsync("access_token") and attach it to outgoing requests:

var accessToken = await HttpContext.GetTokenAsync("access_token");
httpClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", accessToken);

If the token is expired, run the refresh logic before attaching it. The Keycloak BFF pattern guide shows how to encapsulate this in a delegating handler for HttpClientFactory.

Can I use Keycloak groups instead of roles for authorization in .NET?

Yes, but you need a custom protocol mapper in Keycloak to include group membership in the token. Add a Group Membership mapper to the client scope and set the token claim name (e.g., groups). Then update the KeycloakClaimsTransformer to read the groups array and add each value as a ClaimTypes.Role claim. The rest of the authorization code ([Authorize(Roles = ...)], policies) works without changes.

Run Keycloak Without the Operational Overhead

Configuring Keycloak authentication in ASP.NET Core is straightforward once the client and audience mapper are set up correctly. The harder part is operating Keycloak reliably in production: upgrades, high availability, SSL termination, monitoring, and database management.

Skycloak provides fully managed Keycloak hosting so your team can focus on building features rather than maintaining identity infrastructure. All major version upgrades, backups, and uptime monitoring are handled for you.

See the pricing page to compare plans, or learn more about the managed Keycloak hosting offering.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman