Implement OpenID Connect Back-Channel Logout using ASP.NET Core, Keycloak and .NET Aspire

When you sign a user out of one browser tab, the other tabs and the other server instances running that application usually have no idea the session just ended. Front-channel logout, where the browser is redirected to every relying party to clear its cookie, works fine for a single browser but breaks down the moment you scale an application across multiple instances or want the identity provider to be the one driving logout. OpenID Connect back-channel logout solves this differently: the identity provider calls a server-to-server webhook on your application, and your application is responsible for invalidating the session wherever it is tracked, without depending on the browser at all.

This article walks through a working implementation of back-channel logout using Keycloak as the identity provider, two ASP.NET Core applications as relying parties, and .NET Aspire to orchestrate the containers during local development. The sample also uses Redis as a distributed cache to share logout state across application instances, which is the part that actually makes this pattern useful in a real deployment rather than a single-instance demo.

Why back-channel logout matters

Front-channel logout relies on the browser visiting an iframe or redirect URL for every application that needs to sign out. If a user has five tabs open across three applications, or if an application runs behind a load balancer with several instances, front-channel logout cannot reliably reach every session. Back-channel logout removes the browser from the equation. Keycloak (or any OIDC provider that supports the spec) sends a signed logout token directly to a registered endpoint on each relying party whenever a session ends, and the relying party is responsible for propagating that to wherever it tracks sessions, cookies, cache entries and anything else.

The trade-off is that back-channel logout requires more plumbing on the application side. You need an endpoint that accepts and validates the logout token, a place to persist the fact that a session was revoked, and a check on every subsequent request that looks up whether the current session is still valid. This is exactly what the sample below builds.

Architecture overview

Two ASP.NET Core UI apps behind Keycloak, sharing logout state through a Redis cache, all orchestrated by .NET Aspire.
Two ASP.NET Core UI apps behind Keycloak, sharing logout state through a Redis cache, all orchestrated by .NET Aspire.

Two ASP.NET Core server-rendered applications authenticate against Keycloak using the OpenID Connect authorization code flow with PKCE, and additionally use Pushed Authorization Requests (PAR) as defined in RFC 9126. Both applications are confidential clients. When Keycloak receives a logout event for a session, it sends a back-channel logout call to both applications directly, server to server, and each application writes that logout event into a shared Redis cache. Any application instance that later serves a request for that same session checks the cache and forces re-authentication if the session was revoked.

Running Keycloak and Redis as containers instead of installing them locally keeps the setup reproducible and disposable. .NET Aspire is a good fit here because it wires up service discovery, connection strings and container lifecycle for you, so the ASP.NET Core projects do not need to hardcode ports or connection details.

Configuring the AppHost project

The .NET Aspire AppHost project is where the Keycloak container, the Redis container and the two ASP.NET Core UI projects are declared and wired together. Aspire needs the Keycloak and Redis hosting packages added to the AppHost project before this code compiles.

var keycloak = builder.AddKeycloakContainer("keycloak",
            userName: userName, password: password, port: 8080)
    .WithArgs("--features=preview")
    // for more details regarding disable-trust-manager see https://www.keycloak.org/server/outgoinghttp#_client_configuration_command
    // IMPORTANT: use this command ONLY in local development environment!
    .WithArgs("--spi-connections-http-client-default-disable-trust-manager=true")
    .WithDataVolume()
    .RunWithHttpsDevCertificate(port: 8081);
 
var cache = builder.AddRedis("cache", 6379)
    .WithDataVolume();
 
var mvcpar = builder.AddProject<Projects.MvcPar>("mvcpar")
    .WithExternalHttpEndpoints()
    .WithReference(keycloak)
    .WithReference(cache);
 
var mvcbackchanneltwo = builder.AddProject<Projects.MvcBackChannelTwo>("mvcbackchanneltwo")
    .WithExternalHttpEndpoints()
    .WithReference(keycloak)
    .WithReference(cache);

The Keycloak container runs with HTTPS using the ASP.NET Core development certificate, through the RunWithHttpsDevCertificate extension. Because the container needs to call back into the applications running on the docker host, the sample disables the container’s trust manager for outgoing HTTP calls. This is the kind of shortcut that is fine on your own machine but must never ship into a staging or production configuration. In a real deployment, Keycloak and your applications would trust a proper certificate chain and this flag would not exist at all.

Both UI projects reference the keycloak and cache resources so Aspire injects the correct connection information as environment variables and configuration at startup. This is one of the genuine time-savers Aspire brings to local development: you stop hand-editing appsettings.json every time a container port changes.

Registering the back-channel logout URL in Keycloak

Backchannel logout settings on the Keycloak client, with the logout webhook URL pointing at the ASP.NET Core application.
Backchannel logout settings on the Keycloak client, with the logout webhook URL pointing at the ASP.NET Core application.

On the Keycloak client configuration, front-channel logout is switched off and backchannel logout is enabled instead, with the logout URL pointing at an endpoint hosted by the ASP.NET Core application. Because Keycloak is running in a container and the ASP.NET Core application is running directly on the docker host, the host.docker.internal hostname is used so the container can reach back out to the host machine. This is a Docker Desktop convenience and would be replaced with a real internal DNS name or service address in a container-orchestrated production environment such as Kubernetes.

The two toggles worth noting alongside the logout URL are backchannel logout session required and backchannel logout revoke offline sessions. The session required flag tells Keycloak to include the session identifier (sid) claim in the logout token, which the application needs to know which session to revoke. Revoke offline sessions extends the logout to also kill any offline_access refresh tokens tied to that session, which matters if your application requested the offline_access scope for long-lived refresh tokens.

Handling the logout webhook in ASP.NET Core

On the application side, each instance needs a server implementation that accepts the logout token from Keycloak, validates it, and records the logout event somewhere every instance of the application can see. A distributed cache is the natural fit here rather than in-memory storage, because in-memory state would only be visible to the one instance that happened to receive the webhook call.

services.AddTransient<CookieEventHandler>();
services.AddSingleton<LogoutSessionManager>();
services.AddHttpClient();
 
services.Configure<AuthConfiguration>(configuration.GetSection("AuthConfiguration"));
 
var authConfiguration = configuration.GetSection("AuthConfiguration");
 
builder.AddRedisDistributedCache("cache");
 
services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
    options.ExpireTimeSpan = TimeSpan.FromMinutes(60);
    options.Cookie.Name = "MvcPar";
 
    options.EventsType = typeof(CookieEventHandler);
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.Authority = authConfiguration["StsServerIdentityUrl"];
    options.ClientSecret = authConfiguration["ClientSecret"];
    options.ClientId = authConfiguration["Audience"];
    options.ResponseType = OpenIdConnectResponseType.Code;
 
    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
    options.Scope.Add("offline_access");
 
    options.ClaimActions.Remove("amr");
    options.ClaimActions.MapJsonKey("website", "website");
 
    options.GetClaimsFromUserInfoEndpoint = true;
    options.SaveTokens = true;
 
    options.PushedAuthorizationBehavior = PushedAuthorizationBehavior.Require;
 
    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = JwtClaimTypes.Name,
        RoleClaimType = JwtClaimTypes.Role,
    };
});

Reading through the pieces here in order: a CookieEventHandler is registered to intercept the cookie authentication pipeline, and a LogoutSessionManager singleton is responsible for reading and writing logout events. AddRedisDistributedCache wires the ASP.NET Core distributed cache abstraction to the Redis container that Aspire started, using the connection reference set up in the AppHost project. The cookie is configured with a one hour sliding expiry and points its events at the CookieEventHandler, which is where the actual check against the cache happens, typically inside ValidatePrincipal, rejecting the cookie and forcing a challenge if the session shows up as logged out.

The OpenID Connect handler itself sets PushedAuthorizationBehavior.Require, which forces every authorization request through RFC 9126 pushed authorization requests instead of sending parameters directly in the browser redirect URL. PAR is worth combining with back-channel logout in any serious deployment, because it keeps sensitive authorization parameters off the browser URL bar and out of server logs, and Keycloak has supported it as a preview feature for a few releases now. The scope list explicitly requests offline_access, which is what makes the revoke offline sessions toggle on the Keycloak client relevant in the first place.

One practical note from the sample repository: the logout webhook implementation itself was adapted from the older IdentityServer4 samples, since the mechanics of validating a JWT-based logout token and looking up the session are the same regardless of which OpenID Connect provider is issuing the token. If you already have a back-channel logout endpoint written against IdentityServer4 or Duende IdentityServer, most of that code can be reused as-is against Keycloak, since the logout token format follows the same OpenID Connect Back-Channel Logout specification.

Verifying logout events land in Redis

Redis Insight showing a logout event persisted as a hash, keyed by session id, with the subject and session claims and a TTL.
Redis Insight showing a logout event persisted as a hash, keyed by session id, with the subject and session claims and a TTL.

Redis Insight is a convenient way to inspect what actually lands in the cache during testing. Each logout event is stored as a hash keyed by a generated identifier, with a data field holding the subject and session id extracted from the logout token, and a TTL that expires the entry automatically once it is no longer needed. Setting the TTL correctly matters more than it looks: if the expiry is shorter than your longest-lived offline session, a user could refresh an access token using a session that was already revoked, because the cache entry disappeared before the token’s own lifetime ran out.

When another instance of the application, or the same instance on a later request, needs to determine whether a given session is still valid, it looks up the cache using the session id from the current authentication cookie. If an entry exists, the request is treated as unauthenticated and the user is challenged again, triggering a fresh OpenID Connect flow.

Limitations you should plan around

The most significant limitation in this implementation is that back-channel logout only works per browser session, because Keycloak issues a distinct session for every browser the user signs in from. If a user is logged into the same account from Chrome and Firefox, logging out in Chrome sends a back-channel logout event scoped to the Chrome session only, and the Firefox session is unaffected. This is a Keycloak session model constraint rather than something you can fix purely in the ASP.NET Core code. If Keycloak is configured to use a single shared session across browsers for the same user, the back-channel logout would then propagate across all of them, but that is a broader session-management decision with its own trade-offs around convenience versus security.

It is also worth being upfront that this sample is built for local development and demonstration. Disabling the outgoing HTTP trust manager on the Keycloak container and relying on host.docker.internal are both things that need to be replaced with a proper certificate trust chain and real service addressing before anything resembling this setup goes near a production environment.

When to reach for this pattern

Back-channel logout is worth the extra implementation effort mainly when you run multiple instances of a relying party, when you need the identity provider to be able to force a logout independent of the browser (for example, an administrator revoking a compromised account), or when you are already using offline_access refresh tokens and need a reliable way to kill them centrally. If you have a single-instance application with only interactive browser sessions and no requirement for provider-initiated logout, front-channel logout is simpler to reason about and does not need a distributed cache or a webhook endpoint at all.

For teams already standardized on Entra ID or Duende IdentityServer, the same architecture applies with provider-specific differences in how the logout token is issued and which claims are required. A useful follow-up exercise, and one the original author flags as an open opportunity, is mapping out exactly how back-channel logout support differs across Entra ID, Keycloak and Duende IdentityServer, since the feature is part of the OpenID Connect specification but each provider’s implementation details and defaults do not line up perfectly.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading