BFF.Auth.Keycloak 0.1.0-preview.7

This is a prerelease version of BFF.Auth.Keycloak.
dotnet add package BFF.Auth.Keycloak --version 0.1.0-preview.7
                    
NuGet\Install-Package BFF.Auth.Keycloak -Version 0.1.0-preview.7
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="BFF.Auth.Keycloak" Version="0.1.0-preview.7" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="BFF.Auth.Keycloak" Version="0.1.0-preview.7" />
                    
Directory.Packages.props
<PackageReference Include="BFF.Auth.Keycloak" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add BFF.Auth.Keycloak --version 0.1.0-preview.7
                    
#r "nuget: BFF.Auth.Keycloak, 0.1.0-preview.7"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package BFF.Auth.Keycloak@0.1.0-preview.7
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=BFF.Auth.Keycloak&version=0.1.0-preview.7&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=BFF.Auth.Keycloak&version=0.1.0-preview.7&prerelease
                    
Install as a Cake Tool

BFF.Auth.Keycloak

CI NuGet .NET Status License

ASP.NET Core library that wires a Backend-For-Frontend (BFF) authentication stack against a Keycloak realm. The browser only ever sees an HttpOnly cookie; OIDC tokens stay on the server, session is encrypted and stored in Redis, refresh is transparent — AddKeycloakBffAuth() + MapKeycloakBffEndpoints() is all the consuming app needs.

What you get out of the box:

  • OIDC Authorization Code + PKCE flow against a Keycloak realm.
  • HttpOnly cookie session with SameSite=Strict, Secure, __Host- prefix by default.
  • Server-side session store in Redis with tickets encrypted via ASP.NET Core Data Protection.
  • Transparent token refresh in OnValidatePrincipal — access tokens are refreshed 30 seconds before expiry; cookie lifetime tracks Keycloak's refresh_expires_in (8-hour fallback when Keycloak does not return it). Concurrent requests hitting expiry at the same time are de-duplicated (in-process single-flight), so refresh-token rotation does not spuriously sign users out.
  • Refresh resilient to Keycloak outages — only a definitive rejection from Keycloak (4xx, e.g. invalid_grant) ends the session. Transient failures (5xx, timeout, network error) keep the session alive: the request proceeds with the expired access token and the failure is negative-cached for 20 seconds, so a downed Keycloak is not hammered with one token request per incoming request. Token endpoint calls have an explicit 10-second timeout, and every failure is logged with the status code and response body.
  • OIDC Back-Channel Logout 1.0 with JWT signature/issuer/audience/events/iat validation and Redis-backed jti replay protection (atomic SET NX, marked only after the sessions were actually removed — a storage failure returns 503 temporarily_unavailable and leaves the token retryable). Logging a user out of Keycloak terminates every session of that user in this BFF (across devices).
  • Role mapping from Keycloak's resource_access[clientId].roles to ClaimsIdentity.RoleClaimType — [Authorize(Roles="admin")] and User.IsInRole(...) just work. Roles and user profile claims (email, name, …) are re-synchronized on every token refresh from the fresh ID token returned by Keycloak, so a role granted or revoked in Keycloak propagates to the BFF within the access-token lifetime (typically ~5 minutes) instead of requiring a re-login. Immediate revocation still goes through back-channel logout. The mapping is pluggable via mapRoles — a single delegate covers both sign-in and refresh, so the two paths cannot drift apart.
  • ClaimsPrincipal extensions (User.UserId(), User.Email(), User.KeycloakRoles(), …) — discoverable via IntelliSense, no need to remember claim names like sub or preferred_username.
  • 401/403 instead of redirects — fits SPA frontends that detect 401 and navigate to /auth/login themselves.
  • No global service hijacking — the library's Redis connection and cache are registered as keyed services private to the library, and the session store is wired directly into the cookie handler rather than registered as a global ITicketStore. Your own IDistributedCache / AddStackExchangeRedisCache / ITicketStore registrations (prefix, connection, everything) are left untouched.

Ready-to-use HTTP endpoints exposed under /auth:

Method Path Description
GET /auth/login Starts OIDC challenge or local-redirects when already authenticated.
POST /auth/logout RP-Initiated Logout (clears cookie + signs out of Keycloak).
GET /auth/users/me Returns the logged-in user as UserInfoDto (Cache-Control: no-store).
POST /auth/backchannel-logout Server-to-server endpoint invoked by Keycloak on SSO logout.

/auth/login and /auth/logout require top-level browser navigation — do not call them with fetch/XHR. Both reply with a 302 to Keycloak. Inside fetch the cross-origin redirect fails the CORS check, and even if it did not, fetch never changes the browser's location, so PostLogoutRedirectUri would never take effect. Navigate for login, submit a form for logout:

// login
window.location.href = '/auth/login?returnUrl=' + encodeURIComponent(location.pathname);

// logout — POST, so it needs a form rather than window.location
const form = document.createElement('form');
form.method = 'post';
form.action = '/auth/logout';
document.body.appendChild(form);
form.submit();

/auth/users/me is the only endpoint meant to be called with fetch.

Getting started locally

Prerequisites

  • .NET 10 SDK
  • A reachable Redis instance (local Docker container, managed service, or remote)
  • A configured Keycloak realm with an OIDC client (see Keycloak configuration)

Install

The package is published on NuGet as a prerelease:

dotnet add package BFF.Auth.Keycloak --prerelease

Configuration (appsettings.json)

Add a KeycloakAuth section:

{
  "KeycloakAuth": {
    "Authority": "https://keycloak.example.com/realms/my-realm",
    "ClientId": "my-bff",
    "ClientSecret": "REPLACE-ME",
    "RedisConnectionString": "redis:6379,password=...",
    "SessionKeyNamespace": "my-bff",
    "CookieName": "__Host-session",
    "PostLogoutRedirectUri": "/",
    "LoginFailureRedirectUri": "/"
  }
}
Key Required Notes
Authority yes Full realm URL: https://<host>/realms/<realm>. Must be https; http is accepted only for loopback hosts (localhost, 127.0.0.1) — a dev Keycloak on http://localhost:8080 works, http://keycloak:8080 from a compose network does not. Validated at startup.
ClientId yes OIDC client ID from Keycloak Admin Console.
ClientSecret yes Credentials → Secret in Keycloak Admin Console.
RedisConnectionString yes StackExchange.Redis connection string. The library forces AbortOnConnectFail=false regardless of the string: when Redis is down the multiplexer keeps reconnecting in the background instead of throwing, and commands issued meanwhile wait in the backlog up to AsyncTimeout (5 s by default).
SessionKeyNamespace yes Prefix for Redis keys. Lowercase, alphanumeric, hyphens allowed, max 32 characters. Changing it invalidates existing sessions.
CookieName no Defaults to __Host-session. The __Host- prefix mandates Secure and no Domain.
PostLogoutRedirectUri no Local path the user lands on after full logout. Must start with /. Defaults to /.
LoginFailureRedirectUri no Local path the user lands on when login does not complete — Keycloak unreachable during the code exchange, correlation cookie expired (login form left open longer than RemoteAuthenticationTimeout, 15 min by default), or an error returned on the callback. Must start with /. Defaults to /. Without it those cases surface as HTTP 500.

All required fields are validated at startup via AddOptionsWithValidateOnStart — missing values throw an exception instead of failing silently at runtime.

Minimal Program.cs

using System.Security.Claims;
using BFF.Auth.Keycloak;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddKeycloakBffAuth();
// Optional: if your app runs behind nginx/Traefik — see "Behind a reverse proxy" below.
builder.Services.AddReverseProxyForwardedHeaders();

var app = builder.Build();

app.UseForwardedHeaders();      // only if AddReverseProxyForwardedHeaders was called
app.UseAuthentication();
app.UseAuthorization();

app.MapKeycloakBffEndpoints();  // /auth/login, /auth/logout, /auth/users/me, /auth/backchannel-logout

// Your own endpoints — use ClaimsPrincipal and the library's extension methods:
app.MapGet("/profile", (ClaimsPrincipal user) =>
    Results.Ok(new { UserId = user.UserId(), Email = user.Email(), Roles = user.KeycloakRoles() }))
   .RequireAuthorization();

app.Run();

Behind a reverse proxy

When TLS terminates at a proxy (nginx, Traefik, Nginx Proxy Manager), the app sees plain http://<container>:8080 requests. The OIDC callback URLs are built from the request's scheme and host, so without forwarded headers Keycloak rejects them as unregistered redirect URIs.

AddReverseProxyForwardedHeaders() processes X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host from any address — you don't need to know or pin the proxy's network (Docker address pools, Kubernetes, cloud load balancers). You still have to call app.UseForwardedHeaders() before UseAuthentication().

Security relies on network isolation. The app's port must be reachable only through the proxy — in Docker, don't publish it (no ports: in compose; the proxy reaches the container over a shared network). If clients can reach the app directly, they can spoof their IP, scheme and host.

To trust specific proxies only, skip the helper and configure ForwardedHeadersOptions yourself:

using Microsoft.AspNetCore.HttpOverrides;

builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
    options.ForwardedHeaders =
        ForwardedHeaders.XForwardedFor |
        ForwardedHeaders.XForwardedProto |
        ForwardedHeaders.XForwardedHost;
    options.KnownIPNetworks.Clear();
    options.KnownProxies.Clear();
    options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("172.18.0.0/16")); // your proxy network(s)
});

Data Protection key ring (persisting sessions across restarts and replicas)

The session cookie, the Redis ticket, and (as of the Redis-backed refresh lock — see Deployment caveats) the shared token-refresh result are all encrypted with ASP.NET Core Data Protection. By default the encryption keys are generated inside the container and lost whenever the container is recreated — i.e. on every redeploy — which silently signs out every user. Worse, each replica generates its own keys, so with more than one instance a request that lands on a different replica than the one that issued the cookie cannot decrypt it either.

Persist the key ring to Redis — the same server this library already requires — so every replica shares one key ring regardless of how many there are or which node(s) they run on:

// Requires the Microsoft.AspNetCore.DataProtection.StackExchangeRedis package.
var dataProtectionRedis = ConnectionMultiplexer.Connect("<same connection string as KeycloakAuth:RedisConnectionString>");

builder.Services.AddDataProtection()
    .PersistKeysToStackExchangeRedis(dataProtectionRedis, "my-bff-dp-keys")
    .SetApplicationName("my-bff");   // keep stable across deploys

dataProtectionRedis is a plain connection your own code owns — it is not the library's internal Redis connection (that one is registered as a private keyed service and deliberately not exposed to your container), so create it yourself, pointed at the same Redis server. The string passed to PersistKeysToStackExchangeRedis is the Redis key the key ring is stored under — pick something that will not collide with anything else in that Redis instance, and, like SetApplicationName, keep it identical across every replica.

Skipping this does not break anything on a single instance — the app behaves exactly as without this config: keys land in the container's ephemeral filesystem and users are signed out the next time the container is recreated. With more than one replica it is no longer optional: without a shared key ring, requests bounce between replicas that cannot read each other's cookies, sessions, or refresh locks, and users are signed out at random regardless of load balancing.

Prefer PersistKeysToFileSystem with a durable (not necessarily shared) path instead if you run a single instance and would rather not put the key ring in Redis — a mounted volume works fine there and keeps the keys out of the same store as the encrypted tickets. It stops being a valid option the moment you scale to more than one replica in Swarm/Kubernetes, since a plain named volume is local to one node and is not synchronized to the others.

Customizing OIDC options

AddKeycloakBffAuth accepts an optional Action<OpenIdConnectOptions> callback that runs after the library's own defaults, so anything you set in it wins. Typical use case is requesting extra scopes:

builder.Services.AddKeycloakBffAuth(oidc =>
{
    oidc.Scope.Add("phone");
    oidc.Scope.Add("address");
});

offline_access is not supported. Offline tokens come back with refresh_expires_in = 0, which the library reads as "no lifetime given" — the cookie falls back to 8 hours at sign-in and refreshes never extend it, so the session dies 8 hours after login regardless of how long the refresh token stays valid.

Scopes already requested by the library: openid, profile, email, roles. The first three back the ClaimsPrincipal extensions (Email(), GivenName(), …); roles ensures resource_access reaches the token regardless of whether the scope is Default or Optional in the realm.

Custom role mapping

By default the library reads resource_access[<ClientId>].roles from the ID token into ClaimsIdentity.RoleClaimType ("roles"). Pass mapRoles to replace that mapping — for example to also honour realm roles:

public delegate IEnumerable<string> KeycloakRoleMapper(
    IReadOnlyCollection<Claim> tokenClaims,   // every claim of the freshly issued ID token
    string clientId);                         // the configured OIDC ClientId

The returned names replace every existing role claim, so an empty result revokes all roles — same contract as the default mapping.

builder.Services.AddKeycloakBffAuth(mapRoles: (tokenClaims, clientId) =>
{
    string? ClaimValue(string type) => tokenClaims.FirstOrDefault(c => c.Type == type)?.Value;

    var roles = new List<string>();

    // client roles — {"resource_access":{"<clientId>":{"roles":["admin"]}}}
    if (ClaimValue("resource_access") is { } resourceAccess)
    {
        using var doc = JsonDocument.Parse(resourceAccess);
        if (doc.RootElement.TryGetProperty(clientId, out var client) &&
            client.TryGetProperty("roles", out var clientRoles))
        {
            roles.AddRange(clientRoles.EnumerateArray().Select(r => r.GetString()!));
        }
    }

    // realm roles — {"realm_access":{"roles":["admin"]}}, prefixed so a realm role cannot
    // silently satisfy an [Authorize] written for a client role of the same name
    if (ClaimValue("realm_access") is { } realmAccess)
    {
        using var doc = JsonDocument.Parse(realmAccess);
        if (doc.RootElement.TryGetProperty("roles", out var realmRoles))
        {
            roles.AddRange(realmRoles.EnumerateArray()
                .Select(r => r.GetString())
                .Where(r => r is not null && !r.StartsWith("default-roles-"))
                .Select(r => $"realm:{r}"));
        }
    }

    return roles;
});

Realm roles reach the ID token only if you enable Add to ID token on the realm's realm roles mapper (Client scopes → roles → Mappers) — the same switch the client roles mapper needs. Keycloak also puts offline_access, uma_authorization and default-roles-<realm> in there; filter what you do not want, as above.

Do not let the mapper throw. The built-in mapping treats a malformed resource_access claim as zero roles; a custom mapper has no such net. On sign-in a throw surfaces as a redirect to LoginFailureRedirectUri, which is survivable. On the refresh path it is not caught: the request fails with a 500 and the rotated tokens are never persisted, so the next request retries the refresh with the already-spent refresh token — with Keycloak's refresh-token rotation enabled that returns invalid_grant and signs the user out. Parse defensively.

Three things worth knowing:

  • The delegate runs at exactly two points — sign-in and every successful token refresh, i.e. whenever Keycloak issues a new ID token. It is deliberately not an IClaimsTransformation and does not run per request: the principal comes from the session and its claims only change together with a new ID token, so per-request mapping would compute the same answer at the cost of parsing a token on every request.
  • The same delegate serves both paths. Anything it cannot reconstruct from the ID token alone (an external lookup, the access token, request state) will survive sign-in and then vanish at the first refresh — within minutes, in production, long after the code looked correct in testing.
  • The "no resource_access claim" warning is suppressed once a custom mapper is registered, since resource_access is then no longer necessarily where roles come from.

Reading user info

The library exposes KeycloakClaimsExtensions on ClaimsPrincipal so you do not have to remember OIDC claim names. The same accessors work in MVC controllers (via User), minimal API handlers (ClaimsPrincipal parameter), and application services (pass ClaimsPrincipal as a method parameter — keeps the service testable without mocking HttpContext):

app.MapGet("/profile", (ClaimsPrincipal user) => Results.Ok(new
{
    UserId    = user.UserId(),
    Username  = user.Username(),
    Email     = user.Email(),
    GivenName = user.GivenName(),
    Roles     = user.KeycloakRoles(),
})).RequireAuthorization();

Available accessors: UserId, Username, Email, IsEmailVerified, DisplayName, GivenName, FamilyName, KeycloakRoles.

Custom shape. If UserInfoDto (returned by /auth/users/me) does not fit, register your own endpoint with whatever claims you need:

app.MapGet("/auth/me", (ClaimsPrincipal user) => Results.Ok(new
{
    UserId    = user.UserId(),
    Email     = user.Email(),
    BirthDate = DateOnly.TryParse(user.FindFirstValue("birth_date"), out var b) ? b : (DateOnly?)null,
})).RequireAuthorization();

Keycloak configuration

In Keycloak Admin Console → Clients → <your client> → Settings:

  • Valid Redirect URIs: https://<bff-host>/signin-oidc
  • Valid Post Logout Redirect URIs: https://<bff-host>/signout-callback-oidc — this is the post_logout_redirect_uri the OIDC handler sends to Keycloak's end-session endpoint (always the absolute URL of the sign-out callback, regardless of the PostLogoutRedirectUri setting, which is only used for the final in-app redirect). If it is not registered here, Keycloak shows an "Invalid redirect uri" error page after logout.
  • Backchannel Logout URL: https://<bff-host>/auth/backchannel-logout
  • Backchannel Logout Session Required: ON
  • Backchannel Logout Revoke Offline Sessions: optional, depending on your offline-token policy
  • Client roles must be enabled in the ID token mapper if you rely on IsInRole/[Authorize(Roles=...)].

Behind a reverse proxy: the sign-in/sign-out callback URLs above are built from the incoming request's scheme and host. Without correctly configured forwarded headers (AddReverseProxyForwardedHeaders + app.UseForwardedHeaders()) the handler builds http://<internal-host>/..., which will not match the URIs registered in Keycloak. See Behind a reverse proxy.

Deployment caveats

  • Multiple replicas — no sticky sessions required, but the key ring must be shared. Token refreshes are de-duplicated across replicas with a Redis lock (the same Redis the library already requires): only one replica ever calls Keycloak for a given refresh token, so Docker Swarm's routing mesh (which load-balances round-robin, with no built-in sticky sessions) is safe. The losing replica reuses the winner's result instead of racing it — but only if it can decrypt that result, which requires every replica to share the same Data Protection key ring (see above). Without a shared key ring the lock still prevents two replicas calling Keycloak concurrently, but each ends up performing its own sequential refresh once the lock is released — which still hits invalid_grant under Keycloak's refresh-token rotation ("Revoke Refresh Token"). In short: a shared key ring is not optional once you run more than one replica, on Swarm or otherwise.
  • Persist the Data Protection key ring (see above) — otherwise every redeploy signs out all users, and (per the previous point) scaling out to multiple replicas signs users out at random regardless of load balancing.

Out of scope

Things this library deliberately leaves to the consumer:

  • CSRF/antiforgery for POST endpoints. Cross-site CSRF is already covered by the session cookie's SameSite=Strict — a form posted from evil.com does not carry the cookie. (The __Host- prefix does not contribute here; it only enforces Secure, Path=/ and no Domain, which prevents a subdomain from overwriting the cookie.) The gap SameSite leaves is that it is scoped to the site (eTLD+1), not the origin: if the BFF runs on app.example.com, a request from any *.example.com — an XSS on a sibling app, a taken-over subdomain — is same-site and the cookie is sent. Wire AddAntiforgery on your own mutating endpoints if the BFF shares a domain with other applications. The library's own surface needs nothing: /auth/backchannel-logout authenticates with Keycloak's JWT rather than the cookie, and a forged POST /auth/logout only signs the user out.
  • Forwarding the access token to downstream APIs — the token is stored in the session ticket and kept fresh by the transparent refresh, so read it with the standard framework API wherever an authenticated HttpContext is available (await ctx.GetTokenAsync("access_token") from Microsoft.AspNetCore.Authentication) and attach it as a Bearer header yourself — e.g. in a small DelegatingHandler on your HttpClient. Two rules: the token is only available inside a user's request (background/hosted services have no HttpContext — machine-to-machine calls should use the client credentials flow instead), and it must never be returned to the browser — that would defeat the point of the BFF pattern.
  • Data Protection key ring persistence — the library does not configure it; see Data Protection key ring for the recommended volume setup.
  • Redis health checks — add AspNetCore.HealthChecks.Redis in the consumer if needed.
  • Front-channel logout — only back-channel is supported.
  • Pushed Authorization Requests (PAR) — PKCE is considered sufficient for the BFF flow.
  • A Keycloak Admin REST client — out of scope for an auth library.
  • HTTPS redirection / forwarded headers pipeline middleware — controlled by the consumer (the library only offers the optional AddReverseProxyForwardedHeaders() options helper).

Project status

Early development. Published on NuGet as 0.1.0-preview.x — the API may still change between previews. The library compiles cleanly on net10.0 (0 errors, 0 warnings) and ships with an automated test suite (101 tests: token-refresh single-flight (in-process and cross-replica via Redis lock), races and failure classification, back-channel logout token validation, replay protection and storage-failure handling, RedisTicketStore round-trips and per-user session index, cookie-event role and profile-claim re-sync, role extraction and custom role mapping, URL safety, forwarded headers). CI runs build and tests on every push and pull request; a version tag (v*) additionally packs and publishes to NuGet.

Changelog

Breaking changes and notable fixes per release are documented in CHANGELOG.md. While the version stays 0.x, breaking changes may land in any preview release.

License

MIT — see LICENSE.txt.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.1.0-preview.7 44 9/29/2026
0.1.0-preview.6 47 9/28/2026
0.1.0-preview.5 70 7/31/2026
0.1.0-preview.4 69 7/31/2026
0.1.0-preview.3 73 7/25/2026
0.1.0-preview.2 66 7/23/2026
0.1.0-preview.1 64 7/21/2026