3 Feb, 20256 min read
Setting Up Keycloak in .NET 10: The Clean Way

Setting Up Keycloak in .NET 10: The Clean Way

#.NET#Keycloak#Authentication#C##Web API

Building an authentication system is often the part of a project that everyone hates doing. It feels like plumbing, you need it for everything to work, but it's messy, and if you make one mistake, everything breaks.

That’s where Keycloak comes in. It’s a free, open-source tool that handles all the heavy lifting for you. It’s packed with everything you need for a production-ready app, which removes the hassle of building your own login screens, password resets, and security checks from scratch. Your .NET API just needs to check if the person trying to get in has a valid "badge" (access token) from Keycloak.

In this guide, I’ll walk you through hooking up a .NET 10 Web API to Keycloak. We’re going to do this the "clean" way—using the Options Pattern for configuration and setting up a proper authentication scheme without polluting your Program.cs with magic strings.

We’re keeping this strictly to Authentication (who are you?), skipping Authorization (what can you do / roles) for now to keep things focused.

The 10,000 Foot View

Before we code, let's clarify the moving parts because Keycloak has its own vocabulary.

  1. Realm: This is a "tenant" or a space. If you had two different companies using your Keycloak, they’d each have their own Realm. We usually create one called my-app-realm or similar.
  2. Client: This is your application. Your .NET API is a Client. Your React frontend is a Client. Keycloak needs to know who is asking for tokens.
  3. The Flow:
    1. User signs in (usually via a frontend).
    2. Keycloak gives them a JWT (JSON Web Token).
    3. User sends that token to your .NET API in the Authorization header.
    4. Your API validates the signature with Keycloak.

1. The Keycloak Setup (Briefly)

I'll assume you have Keycloak running. While you can run it directly, using Docker Compose is much cleaner for managing configuration and persistence.

Here is the service definition to add to your docker-compose.yml:

version: '3.8'
services:
  keycloak:
    image: quay.io/keycloak/keycloak:latest
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
      KC_DB: postgres
      KC_DB_URL: jdbc:postgresql://postgres/koshome
      KC_DB_USERNAME: postgres
      KC_DB_PASSWORD: postgres
    ports:
      - "9090:8080"
    command:
      - start-dev

Note: This config expects a Postgres service named postgres. If you're testing without a DB, remove the KC_DB environment variables.

  1. Go to the Admin Console (http://localhost:9090).
  2. Create a Realm (e.g., your-project-name-realm).
  3. Create a Client (e.g., your-project-api).
    • Client Authentication: On (confidential) or Off (public). For an API validating tokens, we just need to know the Client ID usually, but if your API calls Keycloak back, you might need a secret. For this demo, standard settings work.
  4. Users: Create a dummy user (e.g., jdoe) and set a password.

2. The .NET Setup

Fire up your favorite IDE and create a new Web API. We need one main package. This library does 99% of the heavy lifting.

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Clean Configuration

Don't hardcode URLs. We’ll use the Options Pattern. First, add your Keycloak settings to appsettings.json.

{
  "Keycloak": {
    "Realm": "your-project-name-realm",
    "AuthServerUrl": "http://localhost:9090/",
    "SslRequired": "none",
    "Resource": "your-project-api",
    "VerifyTokenAudience": true
  }
}

Note: AuthServerUrl is where your API will look for Keycloak.

Now, let's make a C# class to match this structure. This gives us type safety.

// Configuration/KeycloakOptions.cs
public class KeycloakOptions
{
    public string Realm { get; set; } = string.Empty;
    public string AuthServerUrl { get; set; } = string.Empty;
    public string Resource { get; set; } = string.Empty;
    // We construct the Metadata/Issuer URL dynamically
    public string MockTls { get; set; } // Optional if you need dev flags
}

The Wiring (Program.cs)

Here is where the magic happens. We need to tell .NET: "Hey, when you see a Bearer token, check with Keycloak if it's legit."

We'll configure the JwtBearer handler.

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
 
var builder = WebApplication.CreateBuilder(args);
 
// 1. Bind our configuration class
builder.Services.Configure<KeycloakOptions>(
    builder.Configuration.GetSection("Keycloak"));
 
// 2. Add Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // We get the settings via IOptions inside the delegate or just resolve it before.
        // For simplicity in Program.cs, we can read directly or use a setup class. 
        // Let's read directly for clarity here:
        var keycloakSettings = builder.Configuration
            .GetSection("Keycloak")
            .Get<KeycloakOptions>();
 
        // The "Authority" is the trusted token issuer. 
        // Format: {ServerUrl}/realms/{Realm}
        var issuer = $"{keycloakSettings.AuthServerUrl}realms/{keycloakSettings.Realm}";
 
        options.Authority = issuer;
        options.Audience = keycloakSettings.Resource;
 
        // For local docker dev, you might need to disable HTTPS metadata requirement
        options.RequireHttpsMetadata = false; 
 
        options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = issuer,
            ValidateAudience = true,
            ValidAudience = keycloakSettings.Resource,
            ValidateLifetime = true
        };
 
        // EVENT HANDLERS (Debugging help!)
        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = context =>
            {
                Console.WriteLine($"Auth failed: {context.Exception.Message}");
                return Task.CompletedTask;
            },
            OnTokenValidated = context =>
            {
                Console.WriteLine($"Token validated for: {context.Principal.Identity.Name}");
                return Task.CompletedTask;
            }
        };
    });
 
builder.Services.AddControllers();
 
var app = builder.Build();
 
// 3. ENABLE MIDDLEWARE
app.UseAuthentication(); // <--- Don't forget this!
app.UseAuthorization();
 
app.MapControllers();
 
app.Run();

Custom Claims Mapping

By default, Keycloak puts the user's ID (UUID) in the token's sub (Subject) claim. But often you want the preferred_username or email to show up as the User.Identity.Name in .NET.

.NET tries to map these automatically, but Keycloak's claims don't always align with Microsoft's SOAP-era legacy claim names (http://schemas.xmlsoap.org/...).

Let's fix that validation logic. You can inhibit the legacy mapping:

// Add this ideally in Program.cs before AddAuthentication
System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();

Now User.Claims will contain exactly what Keycloak sent, like preferred_username instead of a long XML SOAP URL. Much cleaner.

The Controller

Now, let's protect an endpoint.

[ApiController]
[Route("[controller]")]
public class UsersController : ControllerBase
{
    // Anyone (even anonymous) can see this
    [HttpGet("public")]
    public IActionResult GetPublic()
    {
        return Ok(new { Message = "This is public data." });
    }
 
    // Only valid tokens allowed here
    [Authorize]
    [HttpGet("protected")]
    public IActionResult GetProtected()
    {
        // We can access user data from the parsed token
        var username = User.FindFirst("preferred_username")?.Value;
        var userId = User.FindFirst("sub")?.Value;
 
        return Ok(new 
        { 
            Message = "You are authenticated!", 
            User = username, 
            Id = userId 
        });
    }
}

How It Works Under the Hood

When you hit GET /products/protected with a header Authorization: Bearer <TOKEN>:

  1. ** interception**: The UseAuthentication middleware grabs the request.
  2. Discovery: It looks at options.Authority (.../realms/your-project-name-realm).
  3. Metadata: It calls a standard endpoint: .../realms/your-project-name-realm/.well-known/openid-configuration.
    • This is why your Keycloak URL must be reachable from the container/api!
  4. Keys: It downloads the Public Keys (JWKS) from Keycloak to verify the digital signature of your JWT.
  5. Validation: It checks:
    • Is the signature valid? (Did Keycloak sign this?)
    • Is it expired? (exp claim)
    • Is it for me? (aud claim matches your Client ID)
  6. Success: It creates a ClaimsPrincipal, assigns it to HttpContext.User, and passes control to your controller.

3. Production-Grade Architecture

The setup above is great for getting started, but in a real-world .NET app (like the one we use for KosHome), you don't want to call Keycloak directly from your controllers. You want Layered Architecture.

Here is the "Golden Path" we use to keep things clean, testable, and robust.

The Flow

Controller -> Mediator (Command) -> Handler -> Identity Service -> Wrapper -> Keycloak API

Let's break it down from top to bottom.

1. The Controller (Thin Layer)

The controller should be dumb. It just accepts the request and passes it to MediatR.

[ApiController]
[Route("api/v{version:apiVersion}/auth")]
public class AuthController : ControllerBase
{
    private readonly IMediator _mediator;
 
    public AuthController(IMediator mediator) => _mediator = mediator;
 
    [HttpPost("signup")]
    public async Task<IActionResult> Register(RegisterRequest request, CancellationToken cancellationToken)
    {
        // Map DTO to Command
        var command = new RegisterUserCommand
        {
            IdentityUser = new IdentityUser
            {
                FirstName = request.FirstName,
                LastName = request.LastName,
                Email = request.Email,
                Password = request.Password,
                IsEnabled = true
            }
        };
 
        var result = await _mediator.Send(command, cancellationToken);
        return this.ToActionResult(result);
    }
}

2. The Handler (The Orchestrator)

The handler coordinates the "Transaction". It needs to create the user in Keycloak AND save the user profile in our local Postgres database. If one fails, we handle it.

public sealed class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand, Result<RegisterResponse>>
{
    private readonly IKeycloakIdentityService _keycloakIdentityService;
    private readonly IUserRepository _userRepository;
    private readonly IUnitOfWork _unitOfWork;
 
    // ... Constructor ...
 
    public async Task<Result<RegisterResponse>> Handle(RegisterUserCommand request, CancellationToken cancellationToken)
    {
        // 1. Create Domain Entity
        var user = User.Create(
            new FirstName(request.IdentityUser.FirstName),
            new LastName(request.IdentityUser.LastName),
            new Email(request.IdentityUser.Email)
        );
 
        // 2. Link them (we need the local ID to store in Keycloak attributes)
        request.IdentityUser.Attributes["app_user_id"] = [user.Id.ToString()];
        
        // 3. Call Keycloak Service
        var keycloakResult = await _keycloakIdentityService.CreateIdentityUserAndAssignRoleAsync(
            request.IdentityUser, 
            cancellationToken);
 
        if (keycloakResult.IsFailed) return Result.Fail(keycloakResult.Errors);
 
        // 4. Update local user with Keycloak ID (Foreign Key essentially)
        user.SetIdentityId(keycloakResult.Value.ToString());
 
        // 5. Save functionality (Unit of Work)
        await _userRepository.AddAsync(user, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);
        
        return Result.Ok(new RegisterResponse { UserId = user.Id });
    }
}

3. The Identity Service (Domain Logic)

This service speaks "Domain Language". It knows we want to "Create a User" but it hides the messy details of Keycloak's specific API calls and object mapping.

It uses two distinct clients:

  1. Wrapper: For Admin operations (Create User).
  2. AuthApi: For Login (getting tokens).
public sealed class KeycloakIdentityService : IKeycloakIdentityService
{
    // ... Dependencies injected ...
 
    public async Task<Result<Guid>> CreateIdentityUserAndAssignRoleAsync(IdentityUser identityUser, CancellationToken cancellationToken = default)
    {
        // ... (Validation logic)
 
        var keycloakUser = MapToKeycloakUser(identityUser);
 
        var createUserResult = await _keycloakWrapper.CreateAndRetrieveUserIdAsync(_realmName, keycloakUser, cancellationToken);
        if (createUserResult.IsFailed)
        {
            _logger.LogError("Failed to create identity user with Email: {Email}", identityUser.Email);
            return Result.Fail(UsersErrors.IdentityUserInvalid);
        }
 
        // ... (Role assignment logic shortened)
 
        var identityUserId = createUserResult.Value;
        _logger.LogInformation("Successfully created an identity user with Id: {IdentityUserId}", identityUserId);
        return Result.Ok(Guid.Parse(identityUserId));
    }
    
    public async Task<Result<string>> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
    {
        try
        {
            var parameters = new Dictionary<string, string>
            {
                { "grant_type", "password" },
                { "client_id", _authOptions.Value.Keycloak.ClientId },
                { "client_secret", _authOptions.Value.Keycloak.ClientSecret },
                { "username", email },
                { "password", password }
            };
 
            var tokenResponse = await _keycloakAuthApi.LoginAsync(_realmName, parameters);
            return Result.Ok(tokenResponse.AccessToken);
        }
        catch (ApiException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.BadRequest)
        {
            _logger.LogWarning("Invalid login credentials for User with Email: {Email}", email);
            return Result.Fail(UsersErrors.InvalidCredentials());
        }
        catch(Exception ex)
        {
            _logger.LogError(ex, "Failed to login user {email} with Exception Message: {ExMessage}", email, ex.Message);
            return Result.Fail(UsersErrors.UnexpectedError());
        }
    }
 
    private static KeycloakUser MapToKeycloakUser(IdentityUser identityUser)
    {
        return new KeycloakUser
        {
            FirstName = identityUser.FirstName,
            LastName = identityUser.LastName,
            UserName = identityUser.Email,
            Email = identityUser.Email,
            Enabled = identityUser.IsEnabled,
            EmailVerified = identityUser.IsEmailVerified,
            Credentials = new List<Keycloak.Net.Models.Users.Credentials>
            {
                new()
                {
                    Type = "password",
                    Value = identityUser.Password,
                    Temporary = false
                }
            },
            Attributes = identityUser.Attributes?.ToDictionary(
                kvp => kvp.Key,
                kvp => kvp.Value?.AsEnumerable() ?? []) ?? new Dictionary<string, IEnumerable<string>>()
        };
    }
}

4. The Wrapper (The Adapter)

Why do we have a wrapper? The library Keycloak.Net is great, but creating a user and getting their ID immediately is a two-step process in Keycloak's API. Also, we want to mock this in unit tests.

The wrapper encapsulates the 3rd party library.

public sealed class KeycloakClientWrapper : IKeycloakClientWrapper
{
    private readonly KeycloakClient _keycloakClient; // The 3rd party class
 
    public KeycloakClientWrapper(KeycloakClient keycloakClient)
    {
        _keycloakClient = keycloakClient;
    }
    
    public async Task<Result<string>> CreateAndRetrieveUserIdAsync(string realm, KeycloakUser user, CancellationToken ct)
    {
        // Encapsulate the logic of calling the 3rd party lib
        return await _keycloakClient.CreateAndRetrieveUserIdAsync(
            realm, 
            user, 
            ct);
    }
}

Wait, CreateAndRetrieveUserIdAsync? Yes, we use an extension method (or a specific library helper) because standard Keycloak API returns 201 Created with a Location header, not the ID. Our wrapper/client handles that complexity so the Service doesn't have to care.

5. Login Fast (Refit)

For Login, we don't need the heavy Admin Client. We just need to hit the token endpoint. We use Refit for a lightweight, typed HTTP client.

public interface IKeycloakAuthApi
{
    [Post("/realms/{realm}/protocol/openid-connect/token")]
    [Headers("Content-Type: application/x-www-form-urlencoded")]
    Task<KeycloakTokenResponse> LoginAsync(string realm, [Body(BodySerializationMethod.UrlEncoded)] Dictionary<string, string> loginRequest);
}

Testing It

You can generate a token quickly using Postman or curl if you know the endpoint, but let's just grab one from the Keycloak Admin UI (under the user's Sessions or using a quick test client).

Or use curl to "login":

curl -X POST http://localhost:9090/realms/your-project-name-realm/protocol/openid-connect/token \
  -d "client_id=your-project-api" \
  -d "username=jdoe" \
  -d "password=password" \
  -d "grant_type=password"

Copy the access_token blob.

Send it to your API:

curl -H "Authorization: Bearer <PASTE_TOKEN_HERE>" http://localhost:5000/users/protected

If you get a 200 OK and your JSON response, congratulations. You’ve successfully delegated trust to Keycloak.

Wrap Up

That’s the core of it. You now have a decoupled auth system. Your API doesn’t handle passwords, it doesn’t store user tables, and it doesn't care if you add 2FA later in Keycloak—it just validates the badge.

In the future, you can extend this to handle Roles (mapping resource_access to .NET Roles) to control what users can do, but getting authentication working cleanly is the most important first step.