Building your own authentication system means dealing with password hashing, token issuance, session handling, and a steady stream of security patches that never really stops. Most teams underestimate this until something goes wrong in production. A better approach for a lot of applications is to hand this responsibility to a dedicated identity provider and let your API focus on validating tokens rather than managing credentials.
Keycloak is one of the more mature open source options for this. It handles authentication, authorization, and identity brokering (social logins, enterprise SSO) out of the box, ships with a proper admin console, supports OAuth 2.0 and OpenID Connect natively, and runs anywhere Docker runs. This walkthrough sets up Keycloak as a container, wires Swagger UI into it using the OAuth 2.0 Authorization Code flow, and adds JWT validation on the ASP.NET Core side using .NET 10.
Running Keycloak as a container
Docker is the fastest way to get Keycloak running locally. The setup below runs it in development mode, which disables HTTPS and uses an embedded H2 database. That is fine for trying things out on your machine, but it is not something you want to carry into production, and I will come back to that later in the production considerations section.
services:
keycloak:
image: quay.io/keycloak/keycloak:26.5.2
container_name: keycloak
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
ports:
- '8080:8080'
command: start-dev
Start it with docker compose up -d. Once the container is up, open http://localhost:8080 and log in using admin / admin, the credentials set through the environment variables above. First boot takes a few seconds while Keycloak initializes its internal schema, so do not worry if the login page takes a moment to respond the first time.

A successful login drops you into the admin console, scoped to the master realm by default.

Setting up a realm and client
Keycloak organizes everything into realms. A realm is an isolated space for users, roles, and applications, and realms do not share data with each other. The master realm is reserved for administering Keycloak itself, so create a separate realm for your application instead of reusing it. Mixing your application’s users into the master realm is a common early mistake and it gets messy to unwind later.
Click Manage Realms in the top left corner, then Create realm, and give it a name such as keycloak-demo.

Next, register your application as a client. Since Swagger UI runs entirely in the browser and cannot keep a secret safe from the end user, this needs to be a public client, meaning no client secret. Go to Clients, then Create client, set the Client ID to demo-api, leave the client type as OpenID Connect, and continue.

On the next step, turn Client authentication off, since that is what makes this a public client. Check Standard flow, which is Keycloak’s name for the Authorization Code flow, and set the PKCE method to S256. Skip PKCE here and you are leaving the door open to authorization code interception, which defeats a large part of the reason to use this flow in a browser context in the first place.

Finally, configure the redirect URIs. Set valid redirect URIs to your API’s Swagger URL, something like https://localhost:5001/*, and set web origins to https://localhost:5001. Save the client once this is done.

You also need a user to actually log in with. Go to Users, then Add user, fill in the basic details, and leave Email Verified checked so Keycloak does not send a confirmation email you cannot receive in a local setup. After creating the user, open the Credentials tab, click Set password, and turn off Temporary so you are not forced to change the password on first login.

How the Authorization Code flow actually works
It helps to understand what happens end to end before wiring up any code. The Authorization Code flow is the recommended OAuth 2.0 flow for browser based applications, and Swagger UI uses it here exactly the way a real single page application would.
PKCE, short for Proof Key for Code Exchange, is a security addition on top of the base flow. The client generates a random secret called the code verifier, derives a hash of it called the code challenge, and sends the challenge in the initial authorization request. When it later exchanges the authorization code for tokens, it has to present the original code verifier. Anyone who intercepts just the authorization code cannot complete the exchange without that verifier, which is what closes the interception gap that public clients are otherwise exposed to.

The sequence runs like this: the user clicks Authorize in Swagger UI, the browser redirects to Keycloak’s authorization endpoint, the user logs in at Keycloak, Keycloak redirects back with an authorization code, Swagger UI exchanges that code for an access token, refresh token, and ID token, Swagger UI attaches the access token to subsequent API requests, and the API validates the token’s signature and claims on every call.
The part worth noticing here is that the user’s credentials never touch your application at any point. The user authenticates directly against Keycloak, and your API only ever sees signed tokens. That separation is most of the value of outsourcing authentication in the first place.
Configuring Swagger UI with OAuth 2.0
With Keycloak configured, the next step is wiring the .NET API so Swagger UI can act as an OAuth 2.0 client. Install Swashbuckle first.
dotnet add package Swashbuckle.AspNetCore
Then configure the OAuth2 security scheme in Program.cs.
var keycloakAuthority = builder.Configuration["Keycloak:Authority"]!;
var keycloakClientId = builder.Configuration["Keycloak:ClientId"]!;
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Demo API",
Version = "v1"
});
// Define the OAuth 2.0 security scheme
options.AddSecurityDefinition(nameof(SecuritySchemeType.OAuth2), new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
AuthorizationCode = new OpenApiOAuthFlow
{
AuthorizationUrl = new Uri($"{keycloakAuthority}/protocol/openid-connect/auth"),
TokenUrl = new Uri($"{keycloakAuthority}/protocol/openid-connect/token"),
Scopes = new Dictionary<string, string>
{
{ "openid", "OpenID Connect scope" },
{ "profile", "User profile" }
}
}
}
});
// Apply security to all operations
options.AddSecurityRequirement(doc => new OpenApiSecurityRequirement
{
{
new OpenApiSecuritySchemeReference(nameof(SecuritySchemeType.OAuth2), doc),
[]
}
});
});
The AuthorizationUrl and TokenUrl point at Keycloak’s OpenID Connect endpoints for the realm you created. The scopes listed here, openid and profile, are the minimum needed for an authenticated identity; add more scopes if your application needs role or group information available at the token level.
Next, enable the Swagger UI middleware and tell it which client ID to use and that it should use PKCE.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.OAuthClientId(keycloakClientId); // Default Client ID
options.OAuthUsePkce(); // Proof Key for Code Exchange (security enhancement)
});
}
And the corresponding configuration in appsettings.Development.json.
{
"Keycloak": {
"Authority": "http://localhost:8080/realms/keycloak-demo",
"ClientId": "demo-api",
"Audience": "account",
"Issuer": "http://localhost:8080/realms/keycloak-demo",
"MetadataAddress": "http://keycloak:8080/realms/keycloak-demo/.well-known/openid-configuration"
}
}
Notice the MetadataAddress uses the Docker service name keycloak instead of localhost. That matters once your API itself runs inside a container on the same Docker network as Keycloak, since localhost from inside a container refers to the container itself, not the host machine. This is a common source of confusing connection failures when people move this setup from local debugging into a docker compose based dev environment.
With this in place, Swagger UI shows an Authorize button. Clicking it starts the OAuth flow and redirects to Keycloak’s login page.

Adding JWT validation
At this point Swagger UI can obtain tokens from Keycloak, but the API is not checking them yet. Anyone could call an endpoint with no token, or an invalid one, and nothing would stop them. Install the JWT bearer authentication package to close that gap.
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MetadataAddress = builder.Configuration["Keycloak:MetadataAddress"]!;
options.Audience = builder.Configuration["Keycloak:Audience"];
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuer = builder.Configuration["Keycloak:Issuer"]
};
// Required for HTTP in development (Keycloak uses HTTP by default in dev mode)
options.RequireHttpsMetadata = !builder.Environment.IsDevelopment();
});
builder.Services.AddAuthorization();
The default TokenValidationParameters already checks the token’s signature, expiration, issuer, and audience, so you get sensible defaults without configuring each check manually. Add the authentication and authorization middleware in the pipeline, in that order.
app.UseAuthentication();
app.UseAuthorization();
Then protect an endpoint and read claims off the authenticated user.
app.MapGet("users/me", (ClaimsPrincipal user) =>
{
return Results.Ok(new
{
UserId = user.FindFirstValue(ClaimTypes.NameIdentifier),
Email = user.FindFirstValue(ClaimTypes.Email),
Name = user.FindFirstValue("preferred_username"),
Claims = user.Claims.Select(c => new { c.Type, c.Value })
});
})
.RequireAuthorization();
Call this endpoint with a valid access token from Swagger UI and you get back the user’s ID, email, preferred username, and the full claim set from the token. Call it without a token, or with an expired one, and you get a 401 instead of any data, which is exactly the behavior RequireAuthorization is there to enforce.
What happens under the hood during validation
It is worth knowing what the JWT bearer handler is actually doing on each request, mainly because it explains why this approach scales well.

The middleware extracts the Authorization: Bearer token header, and the JWT handler fetches Keycloak’s public signing keys from its JWKS endpoint, caching them after the first call. Signature validation confirms the token has not been tampered with, the claims are extracted into a ClaimsPrincipal, the authorization middleware checks whether the request meets the endpoint’s requirements, and only then does the endpoint execute with access to HttpContext.User.
The key detail is that your API never calls back to Keycloak to validate an individual token. It fetches the signing keys once, caches them, and validates every subsequent token locally using standard JWT signature verification. This is why JWT based authentication barely adds any latency once the keys are cached, unlike opaque token schemes that require a round trip to the identity provider on every request.
Watching the flow with Aspire Dashboard
If your project uses .NET Aspire, the Aspire Dashboard gives you distributed tracing across the whole authentication flow, which is genuinely useful the first time something does not validate correctly and you are not sure whether the problem is your API, Keycloak, or the token itself.

A successful trace shows the initial request to users/me with the bearer token attached, the outbound call to Keycloak’s well-known openid-configuration endpoint, the outbound call to the JWKS endpoint to fetch signing keys, and the final response back to the client. On any request after the first, the JWKS call disappears from the trace entirely because the keys are already cached, which is a quick visual confirmation that caching is working as expected.
Production considerations
Everything above is fine for local development, but a few things need to change before this setup is production ready.
Run Keycloak behind HTTPS. Development mode disables TLS for convenience, which is not acceptable once real credentials and tokens are flowing through it. Set KC_HOSTNAME and configure proper TLS certificates for the deployment.
Replace the embedded H2 database with a real one. H2 is only meant for quick local testing and does not hold up under concurrent load or restarts. Point Keycloak at PostgreSQL or MySQL instead.
environment:
- KC_DB=postgres
- KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak
- KC_DB_USERNAME=keycloak
- KC_DB_PASSWORD=secret
And remove the RequireHttpsMetadata override on the API side. Leaving RequireHttpsMetadata set to false in production means your API will happily fetch signing keys over plain HTTP, which defeats a chunk of the security guarantee JWT validation is supposed to give you.
Where this leaves you
In roughly the time it takes to read this, you can have a containerized Keycloak instance running, a realm with a public OAuth 2.0 client, Swagger UI acting as a proper OAuth client using Authorization Code plus PKCE, JWT validation wired into ASP.NET Core, and full visibility into the flow through OpenTelemetry if you are using Aspire.
What makes Keycloak worth the initial setup effort is how easy it is to extend later without touching your API code. Need Google login for end users? Configure it inside Keycloak. Need enterprise SSO through SAML? Add the provider in Keycloak. Your API keeps validating the same JWTs regardless of which identity source actually authenticated the user, since none of that complexity leaks into your application code.
One thing worth flagging if you are evaluating this against a managed alternative: Keycloak gives you full control over realms, clients, and user data, but you take on the operational cost of running and patching it yourself. If you are already inside the Microsoft ecosystem and do not need that level of control, Microsoft Entra ID is worth comparing against this setup, since it follows largely the same OAuth 2.0 and OpenID Connect patterns shown here but removes the self-hosting overhead entirely.
Leave a Reply