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:
- Create a new Realm (e.g.,
myapp) — do not use themasterrealm for applications - Create a Client → Clients → Create client:
Client ID:myapp-clientClient type:OpenID ConnectClient authentication: ON (confidential client)Authorization: ON if you want fine-grained authorizationValid redirect URIs:https://localhost:5001/*Web origins:https://localhost:5001
- Create Users → Users → Add user
- Create Roles → Realm roles or Client roles
- Add custom Scopes → Client 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
ApiResourcedefined inConfig.cs. For example, if you havenew ApiResource("myapi"), thenaud = "myapi". - Keycloak: by default, the audience is
accountor 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:
- Delete the local application cookie
- Revoke tokens at the Authorization Server
- 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 = truein production - ✅ Signing keys rotate periodically (IdentityServer has automatic key rotation; Keycloak as well)
- ✅
ClockSkewset 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 Eventsand 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.