RO EN

Integration with IdentityServer and Keycloak in ASP.NET Core

Integration with IdentityServer and Keycloak in ASP.NET Core
Doru Bulubașa
17 April 2026
349 views

In the previous article, we explained how OAuth2 and OpenID Connect work at the protocol level. Now we take the next step: we configure a real Authorization Server — either IdentityServer (.NET solution) or Keycloak (Java/open-source solution) — and integrate the ASP.NET Core application with it.

Regardless of the choice, the concepts are identical: a centralized server issues tokens, your applications validate them. Only the configuration tooling differs.


1. IdentityServer vs. Keycloak — when do you choose which?

Before any code, it’s worth understanding the context of each solution.

IdentityServer (Duende IdentityServer)

IdentityServer is an open-source framework for .NET, built exactly on ASP.NET Core. You host it, customize it in C#, and it integrates naturally into any .NET solution. Duende Software (the company behind it) offers commercial licensing for production — free for small and open-source projects.

When to choose it:

  • You want full control in C# code over the authentication flow
  • You need deep customization (custom UI, custom grant logic)
  • Your stack is 100% .NET
  • You want to distribute your own IdP as part of a SaaS product

Keycloak

Keycloak is an enterprise open-source solution developed by Red Hat. It comes with a complete admin interface, out-of-the-box support for LDAP/AD, social login, MFA, and is language-agnostic (can be used by any application).

When to choose it:

  • You want a ready-configured IdP, without custom code
  • You have a mixed team (Java, .NET, Node.js) sharing the same authentication system
  • You need integration with Active Directory or LDAP
  • You prefer a visual admin console over configuration in code

2. Setup IdentityServer — concrete steps

2.1 Installation

Create a new ASP.NET Core project and add the package:

dotnet add package Duende.IdentityServer

2.2 Minimal configuration — Program.cs

builder.Services.AddIdentityServer(options =>
{
    options.Events.RaiseErrorEvents       = true;
    options.Events.RaiseSuccessEvents     = true;
    options.Events.RaiseFailureEvents     = true;
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients)
.AddInMemoryApiResources(Config.ApiResources)
.AddTestUsers(Config.Users);  // only for dev/test

app.UseIdentityServer();

2.3 Defining resources and clients — Config.cs

public static class Config
{
    public static IEnumerable<IdentityResource> IdentityResources =>
    [
        new IdentityResources.OpenId(),
        new IdentityResources.Profile(),
        new IdentityResources.Email()
    ];

    public static IEnumerable<ApiScope> ApiScopes =>
    [
        new ApiScope("myapi", "My API"),
        new ApiScope("myapi.read"),
        new ApiScope("myapi.write")
    ];

    public static IEnumerable<ApiResource> ApiResources =>
    [
        new ApiResource("myapi", "My API")
        {
            Scopes = { "myapi", "myapi.read", "myapi.write" }
        }
    ];

    public static IEnumerable<Client> Clients =>
    [
        // Machine-to-Machine client (Client Credentials Flow)
        new Client
        {
            ClientId     = "m2m-client",
            ClientName   = "Machine to Machine Client",
            ClientSecrets = { new Secret("super-secret".Sha256()) },

            AllowedGrantTypes = GrantTypes.ClientCredentials,
            AllowedScopes     = { "myapi", "myapi.read" }
        },

        // SPA/Web client (Authorization Code + PKCE)
        new Client
        {
            ClientId     = "web-client",
            ClientName   = "Web Application",
            ClientSecrets = { new Secret("web-secret".Sha256()) },

            AllowedGrantTypes    = GrantTypes.Code,
            RequirePkce          = true,
            RequireClientSecret  = false, // public client

            RedirectUris         = { "https://localhost:5001/signin-oidc" },
            PostLogoutRedirectUris = { "https://localhost:5001/signout-callback-oidc" },
            FrontChannelLogoutUri  = "https://localhost:5001/signout-oidc",

            AllowedScopes =
            {
                IdentityServerConstants.StandardScopes.OpenId,
                IdentityServerConstants.StandardScopes.Profile,
                IdentityServerConstants.StandardScopes.Email,
                "myapi"
            },

            AllowOfflineAccess = true // refresh tokens
        }
    ];
}

2.4 Discovery endpoint

Once started, IdentityServer automatically exposes metadata at:

https://<your-identityserver>/.well-known/openid-configuration

Here you find all endpoints: authorization_endpoint, token_endpoint, jwks_uri, userinfo_endpoint etc. Client applications use this URL for auto-configuration.


3. Setup Keycloak — concrete steps

3.1 Start with Docker

The easiest way to run Keycloak locally:

docker run -p 8080:8080 \
  -e KEYCLOAK_ADMIN=admin \
  -e KEYCLOAK_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:latest start-dev

Access the admin console at http://localhost:8080/admin.

3.2 Configure Realm, Client, and Scopes

In Keycloak, organization is done by Realms — each realm is an isolated authentication space (similar to a tenant). The steps are:

  1. Create a new Realm (e.g., myapp) — do not use the master realm for applications
  2. Create a ClientClients → Create client:
    • Client ID: myapp-client
    • Client type: OpenID Connect
    • Client authentication: ON (confidential client)
    • Authorization: ON if you want fine-grained authorization
    • Valid redirect URIs: https://localhost:5001/*
    • Web origins: https://localhost:5001
  3. Create UsersUsers → Add user
  4. Create RolesRealm roles or Client roles
  5. Add custom ScopesClient scopes → Create client scope

3.3 Keycloak discovery endpoint

http://localhost:8080/realms/myapp/.well-known/openid-configuration

The structure is identical to any OIDC provider — that’s the beauty of the standard.


4. ASP.NET Core integration — client application (Web App)

This is the common part — the ASP.NET Core code is almost identical whether you use IdentityServer or Keycloak. Only the Authority differs.

4.1 Install packages

dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

4.2 Configuration in Program.cs

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme          = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
    {
        options.Cookie.Name     = ".myapp.session";
        options.Cookie.HttpOnly = true;
        options.Cookie.SameSite = SameSiteMode.Lax;
        options.ExpireTimeSpan  = TimeSpan.FromHours(8);
    })
    .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
    {
        // —— IdentityServer ——
        options.Authority = "https://localhost:5000"; // IdentityServer URL

        // —— OR Keycloak ——
        // options.Authority = "http://localhost:8080/realms/myapp";

        options.ClientId     = "web-client";
        options.ClientSecret = "web-secret";
        options.ResponseType = "code"; // Authorization Code Flow

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

        options.SaveTokens            = true; // store tokens in cookie
        options.GetClaimsFromUserInfoEndpoint = true;

        options.TokenValidationParameters = new TokenValidationParameters
        {
            NameClaimType = "name",
            RoleClaimType = "role"   // or "roles" for Keycloak
        };
    });

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

4.3 Protecting a controller

[Authorize]
public class DashboardController : Controller
{
    public async Task<IActionResult> Index()
    {
        // Access the access token stored in cookie
        var accessToken = await HttpContext.GetTokenAsync("access_token");
        // ... API call with token
        return View();
    }

    [Authorize(Roles = "Admin")]
    public IActionResult AdminPanel() => View();
}

5. ASP.NET Core integration — API application (Resource Server)

Your API does not do redirects — it only validates JWTs received in the Authorization: Bearer <token> header.

5.1 Install packages

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

5.2 Configuration in Program.cs

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        // —— IdentityServer ——
        options.Authority = "https://localhost:5000";

        // —— OR Keycloak ——
        // options.Authority = "http://localhost:8080/realms/myapp";
        // options.MetadataAddress = "http://localhost:8080/realms/myapp/.well-known/openid-configuration";
        // options.RequireHttpsMetadata = false; // only for local dev HTTP

        options.Audience = "myapi"; // ApiResource name from IdentityServer
                                    // or Client ID from Keycloak

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateLifetime         = true,
            ValidateIssuerSigningKey  = true,
            ClockSkew                = TimeSpan.FromSeconds(30)
        };
    });

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ReadAccess", policy =>
        policy.RequireClaim("scope", "myapi.read"));

    options.AddPolicy("WriteAccess", policy =>
        policy.RequireClaim("scope", "myapi.write"));
});

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

5.3 Protecting endpoints

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    [Authorize(Policy = "ReadAccess")]
    public IActionResult GetAll() => Ok(new[] { "Product A", "Product B" });

    [HttpPost]
    [Authorize(Policy = "WriteAccess")]
    public IActionResult Create([FromBody] ProductDto dto) => Created("/", dto);
}

6. Key differences IdentityServer vs. Keycloak in .NET integration

6.1 Audience validation

This is the most frequent source of confusion:

  • IdentityServer: the audience in the JWT is the name of the ApiResource defined in Config.cs. For example, if you have new ApiResource("myapi"), then aud = "myapi".
  • Keycloak: by default, the audience is account or the clientId. For separate APIs, you must explicitly add an Audience mapper in Client scopes → <scope> → Mappers → Add mapper → Audience.

6.2 Roles and claims

Keycloak puts roles in nested structures inside the token:

// Keycloak JWT token (decoded)
{
  "realm_access": {
    "roles": ["Admin", "User"]
  },
  "resource_access": {
    "myapp-client": {
      "roles": ["manager"]
    }
  }
}

ASP.NET Core does not know by default how to map these structures. You need a custom ClaimsTransformation:

public class KeycloakRolesClaimsTransformation : IClaimsTransformation
{
    public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
    {
        var identity = (ClaimsIdentity)principal.Identity!;

        // Extract roles from realm_access.roles
        var realmAccessClaim = identity.FindFirst("realm_access");
        if (realmAccessClaim != null)
        {
            var realmAccess = JsonDocument.Parse(realmAccessClaim.Value);
            if (realmAccess.RootElement.TryGetProperty("roles", out var roles))
            {
                foreach (var role in roles.EnumerateArray())
                {
                    identity.AddClaim(new Claim(ClaimTypes.Role, role.GetString()!));
                }
            }
        }

        return Task.FromResult(principal);
    }
}

// Registration in Program.cs
builder.Services.AddTransient<IClaimsTransformation, KeycloakRolesClaimsTransformation>();

6.3 Token introspection

If you issue reference tokens (opaque, not JWT), validation is no longer done locally. The API must call the introspection endpoint to verify if the token is valid:

// IdentityServer — native support
dotnet add package IdentityModel.AspNetCore.OAuth2Introspection

.AddOAuth2Introspection("introspection", options =>
{
    options.Authority    = "https://localhost:5000";
    options.ClientId     = "myapi";
    options.ClientSecret = "api-secret";
});

7. Refresh tokens — management in the web application

If you requested offline_access, you receive a refresh token. ASP.NET Core does not use it automatically — you must manage expiration manually or through middleware.

Recommended pattern with IdentityModel:

dotnet add package IdentityModel.AspNetCore
// Program.cs
builder.Services.AddAccessTokenManagement(options =>
{
    options.Client.DefaultClient = new ClientCredentialsTokenRequest
    {
        Address      = "https://localhost:5000/connect/token",
        ClientId     = "m2m-client",
        ClientSecret = "super-secret"
    };
});

// Automatic renewal when token expires — for user tokens:
builder.Services.AddUserAccessTokenHttpClient("ApiClient", configureClient: client =>
{
    client.BaseAddress = new Uri("https://api.myapp.com");
});

The IdentityModel.AspNetCore library automatically manages refresh when the token is about to expire, before making the HTTP call.


8. Proper logout — end_session_endpoint

A complete logout (SSO logout) means three steps:

  1. Delete the local application cookie
  2. Revoke tokens at the Authorization Server
  3. Redirect the browser to the server’s end_session_endpoint
public IActionResult Logout()
{
    return SignOut(
        new AuthenticationProperties
        {
            RedirectUri = "/"
        },
        CookieAuthenticationDefaults.AuthenticationScheme,
        OpenIdConnectDefaults.AuthenticationScheme // triggers redirect to end_session_endpoint
    );
}

If you omit OpenIdConnectDefaults.AuthenticationScheme from SignOut, you only delete the local cookie — the user can access again without login because the session at the IdP is still active.


9. Common problems and their solutions

Problem Likely cause Solution
401 Unauthorized on API Wrong audience in token or validation Check options.Audience matches aud in decoded JWT
IDX20803: Unable to obtain configuration Authority URL inaccessible Check that Authority is accessible from container/server; set RequireHttpsMetadata = false in dev
Roles not recognized Keycloak puts roles in realm_access.roles Add custom IClaimsTransformation (see section 6.2)
Correlation failed Correlation cookie lost between requests Check SameSite policy and that RedirectUri exactly matches client config
Token expired after refresh Refresh token not used correctly Use IdentityModel.AspNetCore for automatic token management

10. Production checklist

  • ✅ HTTPS mandatory on all components
  • RequireHttpsMetadata = true in production
  • ✅ Signing keys rotate periodically (IdentityServer has automatic key rotation; Keycloak as well)
  • ClockSkew set to minimum (30 seconds) for token validation
  • ✅ Secrets stored in Azure Key Vault or similar, not in appsettings.json
  • ✅ Refresh tokens with limited AbsoluteRefreshTokenLifetime
  • ✅ Keycloak admin endpoints (port 9000) isolated in private network
  • ✅ Logging enabled for IdentityServer Events and sent to Application Insights
  • ✅ Test complete logout (SSO logout), not just local cookie deletion

Conclusion

Integrating with an external Authorization Server is one of the architectural decisions with the greatest impact on a system’s security. Whether you choose IdentityServer for full control in C#, or Keycloak for a ready-configured enterprise solution, the ASP.NET Core code on the client and API side remains remarkably similar — proof that the OAuth2 and OpenID Connect standards have achieved their purpose.

The most common mistakes are not in code but in configuration: wrong audience, roles that don’t map, incomplete logout. Understanding what each piece of the puzzle does makes these problems easy to diagnose.

In the next article, we will go further with X509 certificate authentication — the case where you want to completely eliminate passwords and use client certificates for identity.