Skip to content
Published on

The Hard Problem of Single Logout (SLO) — Designing Front-Channel and Back-Channel Logout

Authors

Introduction — Logout Is Harder Than Login

The issue discovered last and dragged out longest in SSO projects is, surprisingly, logout. Login is a one-directional flow — "authenticate once, everything opens" — so the design is crisp. Logout, however, is a distributed state propagation problem: "cut it in one place and everything must close." Any engineer who knows how hard state propagation is in distributed systems can guess why Single Logout (SLO) spawned so many standards (OIDC alone has three logout specs) and still is not clean.

As of 2026 the problem has become harder. With every mainstream browser blocking third-party cookies by default, the front-channel (iframe) approach that carried SLO for over a decade has effectively reached end of life. This post draws the full map of logout design: the three-layer SSO session model, the three OIDC logout specs, the reality of SAML SLO, Keycloak configuration, mobile, UX, and testing.

The Three-Layer SSO Session Model — What Exactly Must Be Cut

To design logout you must first know where, and how many, "logged-in states" exist. A typical OIDC SSO environment has at least three layers of session.

Layer 1: IdP (OP) SSO session
  - The IdP browser cookie, e.g. Keycloak KEYCLOAK_IDENTITY
  - While alive, any app can SSO-login without re-authentication

Layer 2: Each app (RP) local session
  - App server session cookie (e.g. JSESSIONID) or the app session store
  - After one OIDC authentication, the app remembers the user via its own session

Layer 3: Issued tokens
  - access token (valid for its own lifetime — works independently of sessions)
  - refresh token (can mint new access tokens until revoked)
  - token copies held by mobile apps / SPAs

In a diagram:

                +---------------------------+
                |  IdP (Keycloak)           |
                |  [L1] SSO session cookie  |
                +------+--------+-----------+
                       |        |
            OIDC auth  |        |  OIDC auth
                       v        v
        +--------------+--+  +--+---------------+
        | App A (RP)      |  | App B (RP)       |
        | [L2] app session|  | [L2] app session |
        | [L3] tokens     |  | [L3] tokens      |
        +-----------------+  +------------------+

For "I logged out" to mean anything, all three layers must be cleaned up. The common failure modes:

Failure modeSymptomCause
Only the app session endedLogging back in to app A right after logout requires no passwordIdP SSO session (L1) still alive
Only the IdP session endedAfter IdP logout, app B keeps workingApp B local session (L2) still alive
Sessions all cut, APIs still workMobile app keeps calling APIs successfully after logoutaccess/refresh tokens (L3) not revoked

The second row of this table is exactly the problem SLO tries to solve: the fact that the IdP session ended must be propagated to every RP.

OIDC RP-Initiated Logout — Where Logout Begins

When the user clicks "log out" in app A, app A does not just end its own session; it sends the user to the IdP logout endpoint. This is OIDC RP-Initiated Logout.

GET /realms/saas-prod/protocol/openid-connect/logout?id_token_hint=eyJhbGciOiJSUzI1NiIs...&post_logout_redirect_uri=https%3A%2F%2Fapp-a.example.com%2Fbye&state=af0ifjsldkj HTTP/1.1
Host: auth.example.com

The parameters:

  • id_token_hint — A previously issued ID token; the IdP's evidence of whose session is being ended. Without it, the IdP typically shows a "do you really want to log out?" confirmation screen (logout CSRF protection).
  • post_logout_redirect_uri — Where to return after logout completes. Only pre-registered URIs may be allowed (open redirect prevention).
  • state — An opaque value to prevent CSRF across the redirect round trip.

So far this only cleans up "app A and the IdP." Telling apps B, C, and D is the real problem — and the mechanisms for that are front-channel and back-channel.

Front-Channel Logout — The Rise and Fall of the iframe

OIDC Front-Channel Logout uses the browser as the propagation medium. The IdP logout page renders each RP logout URL in a hidden iframe; each iframe request carries the RP session cookie, allowing the RP to end its own session.

[Browser loads the IdP logout page]
  Inside the IdP response HTML:
    iframe 1 -> https://app-a.example.com/oidc/front-logout?iss=...&sid=abc123
    iframe 2 -> https://app-b.example.com/oidc/front-logout?iss=...&sid=abc123
    iframe 3 -> https://app-c.example.com/oidc/front-logout?iss=...&sid=abc123
  Each RP finds and ends its session via "its own domain cookie" on the request

The decisive weaknesses of this approach became fatal wounds in 2026.

  1. Third-party cookie blocking: the app-a.example.com request inside the iframe is a third-party request from the perspective of the IdP page (auth.example.com). Safari (ITP) has blocked these for years, Chrome went through phased blocking, and as of 2026, under mainstream browser defaults, the RP session cookie is not attached to these iframe requests. Without the cookie, the RP cannot know which session to end. Front-channel logout fails silently.
  2. No delivery confirmation: the IdP cannot tell whether the iframe load succeeded. No retry on failure.
  3. Timing: if the user navigates away from the logout page immediately, iframe loads can be cut short.

Within the same domain family (IdP and apps all under subdomains of example.com) requests count as first-party and it still works — but the moment domains diverge, as in multi-tenant SaaS, front-channel cannot be trusted. The 2026 verdict: do not choose front-channel logout in new designs.

Back-Channel Logout — Direct Server-to-Server Notification

OIDC Back-Channel Logout bypasses the browser. The IdP directly POSTs a signed JWT called a logout token to each RP backend endpoint.

[IdP session termination occurs]
   |
   |  HTTP POST (server to server, browser not involved)
   +---> https://app-a.example.com/oidc/back-logout   (logout_token=...)
   +---> https://app-b.example.com/oidc/back-logout   (logout_token=...)
   +---> https://app-c.example.com/oidc/back-logout   (logout_token=...)

Structure of the logout token

The logout token is a JWT similar to an ID token, but with several unique rules.

{
  "iss": "https://auth.example.com/realms/saas-prod",
  "aud": "app-a-client",
  "iat": 1781234567,
  "exp": 1781234687,
  "jti": "bWJq-09cd-4a4f-a3f9",
  "sub": "f3a8c2e1-9b47-4d6a-8c21-0e5f7a9b3d44",
  "sid": "abc123-session-id",
  "events": {
    "http://schemas.openid.net/event/backchannel-logout": {}
  }
}

The validation rules matter.

  • events claim required: the backchannel-logout event URI must be present as a key.
  • nonce forbidden: to prevent confusion with ID tokens (token substitution attacks), reject any token containing a nonce.
  • At least one of sub or sid: with sid, end "that session only"; with only sub, end "all sessions of that user."
  • Signature verification: verify the signature against the IdP JWKS and validate iss/aud/exp.
  • jti replay detection: remember processed jti values for a short window to prevent replay.

Implementing the RP receiving endpoint

@PostMapping(value = "/oidc/back-logout",
             consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<Void> backChannelLogout(@RequestParam("logout_token") String token) {
    LogoutToken lt = logoutTokenValidator.validate(token); // verify signature, iss, aud, events, absence of nonce

    if (lt.sid() != null) {
        sessionRegistry.terminateBySid(lt.sid());          // end app sessions tied to that OIDC session
    } else {
        sessionRegistry.terminateAllForUser(lt.sub());     // end all app sessions of the user
    }
    tokenStore.revokeRefreshTokens(lt.sub(), lt.sid());    // clean up layer 3
    return ResponseEntity.ok().build();                    // 200 OK — return 5xx on failure so the IdP can retry
}

The hidden difficulty is mapping sid to app sessions. At login time you must store the sid claim from the ID token alongside the app session, so that you can look it up in reverse from the logout token sid later. If your app uses only stateless JWT sessions, there is no "server session to end" at all — you must introduce state such as a denylist for back-channel logout to mean anything. Logout is inherently a stateful operation.

Limits of back-channel

It is not a silver bullet.

  • The RP backend must be at a network location reachable from the IdP (apps behind firewalls are problematic).
  • The IdP must send as many POSTs as there are RPs; with many RPs, slow ones add logout latency. IdP timeout/parallelism settings matter.
  • Retry policy on delivery failure differs per IdP implementation, and the guarantee is closer to at-most-once. Always pair it with backup measures (short sessions + periodic token revalidation).

Comparing the three OIDC logout specs

Summarizing everything so far in one table:

CriterionSession Management (iframe polling)Front-Channel LogoutBack-Channel Logout
Propagation mediumBrowser (polling OP iframe state)Browser (hidden iframe loads)Server-to-server HTTP POST
Third-party cookie dependencyYes — fatalYes — fatalNone
Delivery confirmation/retryImpossibleImpossiblePossible (implementation dependent)
RP implementation burdenFrontend polling logicOne logout URLSignature verification + session mapping
Robust to browser close/navigationNoNoYes
RPs behind firewallsWorksWorksProblematic
2026 recommendationDeprecatedDeprecatedThe standard choice

SAML SLO — A Standard Exists, Reality Is Rough

SAML 2.0 also has a Single Logout Profile: LogoutRequest/LogoutResponse messages exchanged via Redirect (through the browser) or SOAP (server to server) bindings.

<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                     xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                     ID="_8f2b3c4d" Version="2.0"
                     IssueInstant="2026-06-12T09:30:00Z"
                     Destination="https://app-a.example.com/saml/slo">
  <saml:Issuer>https://auth.example.com/realms/saas-prod</saml:Issuer>
  <saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">
    f3a8c2e1-9b47-4d6a-8c21-0e5f7a9b3d44
  </saml:NameID>
  <samlp:SessionIndex>abc123-session-index</samlp:SessionIndex>
</samlp:LogoutRequest>

The practical problems:

  • The Redirect binding is a relay race: the browser visits every participant in sequence — SP1 to IdP to SP2 to IdP to SP3. If one SP in the middle fails to respond or the user closes the window, the chain breaks there and the remaining SPs never learn about the logout.
  • The SOAP binding lacks support: conceptually similar to back-channel as server-to-server notification, but SPs that actually implement SOAP SLO are rare. Many commercial SaaS products support SAML login yet do not support SLO, or support it only nominally.
  • The industry's tacit agreement: as a result, many enterprise environments leave SAML SLO off and settle for "IdP session timeout + per-app logout." If SLO behavior is a contractual requirement in a SAML integration, always verify the actual support level of both products with a PoC.

Logout Configuration in Keycloak

Keycloak (as of 26.6) supports all of the mechanisms above, configured per client.

OIDC client configuration points

In the admin console client settings:

  • Front channel logout: on/off plus the front-channel logout URL. For the reasons above, turning it off is recommended.
  • Backchannel logout URL: the RP back-channel receiving endpoint.
  • Backchannel logout session required: whether to include sid in the logout token. Recommended on (precise, session-scoped logout).
  • Backchannel logout revoke offline sessions: whether to also revoke offline sessions (long-lived refresh).

Expressed via the kcadm CLI:

# Configure back-channel logout for a client
kcadm.sh update clients/CLIENT-UUID -r saas-prod \
  -s 'attributes."backchannel.logout.url"=https://app-a.example.com/oidc/back-logout' \
  -s 'attributes."backchannel.logout.session.required"=true' \
  -s 'attributes."backchannel.logout.revoke.offline.tokens"=false' \
  -s 'frontchannelLogout=false'

Combining with session and token lifetimes

Logout propagation can fail, so session/token lifetimes are the safety net. In the realm settings:

kcadm.sh update realms/saas-prod \
  -s ssoSessionIdleTimeout=1800 \
  -s ssoSessionMaxLifespan=28800 \
  -s accessTokenLifespan=300 \
  -s offlineSessionIdleTimeout=2592000
  • Keep accessTokenLifespan short (around 5 minutes): even if back-channel logout kills the refresh token, the access token lives until expiry. The only ways to shrink that window are a shorter lifetime or per-request introspection at the resource server (a performance trade-off).
  • Admin-forced logout: the Admin API can immediately terminate all sessions of a given user. Clients configured for back-channel will receive logout tokens.
# Force-terminate all sessions of a user
kcadm.sh create users/USER-UUID/logout -r saas-prod

Session Fixation and Lingering Tokens — The Security Risks of Logout

Incomplete logout opens the following attack/risk surfaces.

  • The shared PC scenario: the user believes they "logged out" and walks away, but if the IdP SSO session is alive, the next person at the same browser is auto-logged-in the moment they open any app. This is the most frequently reported real incident type in SSO environments.
  • Lingering tokens: an access token that survives logout grants API access for its remaining lifetime. An implementation that skips refresh-token revocation effectively has no logout at all. Include a call to the RFC 7009 (Token Revocation) endpoint in the logout procedure.
  • Logout CSRF: forcing a victim to log out looks trivial, but it can be the setup step for steering them to a phishing login screen. The defenses are id_token_hint validation, confirmation screens, and a post_logout_redirect_uri allowlist.
  • The inverse lesson of session fixation: just as you re-issue session IDs at login, at logout you must destroy all derived state tied to the session identifier (CSRF tokens, cached permissions) along with it.

Logout in Mobile Apps

Mobile is a different world where the browser session model does not apply.

  • A mobile app's "logged-in state" is usually a refresh token stored in the Keychain/Keystore. Logout must do both: (1) delete local tokens, and (2) call server-side token revocation. Surprisingly many apps do only the local deletion — meaning a stolen token remains valid on the server.
  • If login used the system browser (ASWebAuthenticationSession, Custom Tabs), the IdP SSO cookie stays in the system browser. For a complete logout, open the RP-Initiated Logout URL once through the same mechanism to also end the IdP session.
  • A mobile app cannot receive back-channel logout tokens directly (it is not a server), so design for detecting "forcibly logged out" via push notification or 401 handling on the next API call.
Mobile logout checklist
  [ ] Delete local refresh/access tokens (Keychain/Keystore)
  [ ] POST /revoke for server-side token revocation (RFC 7009)
  [ ] If needed, RP-Initiated Logout to clear IdP cookies
  [ ] Clear cached user data / encryption keys in the app
  [ ] A receive path for server-initiated forced logout (push or 401 handling)

Global Logout vs Single-App Logout — A UX Decision

Even with the technology in place, "what should the logout button do" is a product decision.

OptionBehaviorSuitable context
Single-app logoutEnd only this app's session; keep the IdP sessionWorkplace portals — other work apps must keep working
Global logoutEnd the IdP session + all RP sessionsShared PCs, security-sensitive environments, "log out everywhere"
User choiceAsk the user for scope at logoutMixed environments — at the cost of UX friction

Recommended pattern:

  • Wire the ordinary "log out" button to global logout (RP-Initiated, then IdP session termination, then back-channel propagation). Matching the user's mental model ("if I logged out, it is over") reduces incidents.
  • Where single-app logout is needed, separate it into a distinct flow such as "switch account."
  • Offering "log out of all devices" (full session revocation via the Admin API) on the security settings page completes the account-takeover response UX.

Test Matrix

Logout is a regression-prone area, so matrix-based automation is mandatory.

#ScenarioExpected result
1Re-access app A after logging out of app ARe-authentication required
2Access app B after logging out of app A (global policy)Re-authentication required (verify back-channel propagation)
3API call with the old access token within 5 minutes of logout401 or allowed-until-expiry per policy — as documented
4Refresh attempt with the refresh token after logout401/400 invalid_grant
5IdP admin force-terminates the sessionLogout tokens sent to all RPs, app sessions ended
6Logout while the RP back-logout endpoint is downIdP logout succeeds; after recovery, that RP session dies via idle timeout
7Logout token replay (resending the same jti)Rejected
8Forged logout token containing a nonceRejected
9Unregistered URL in post_logout_redirect_uriRejected or ignored
10Full flow in a third-party-cookie-blocking browserWorks correctly via the back-channel path
11Mobile: API call with a presumed-stolen token after logout401 due to revocation taking effect
12Back button to a personalized page right after logoutNo cached exposure (verify no-store)

A skeleton for E2E automation:

#!/usr/bin/env bash
set -euo pipefail
# Scenario 4: verify refresh is rejected after logout

TOKENS=$(curl -sf -X POST "$IDP/realms/saas-prod/protocol/openid-connect/token" \
  -d "grant_type=password&client_id=e2e&username=tester&password=$E2E_PW")
RT=$(echo "$TOKENS" | jq -r .refresh_token)
IDT=$(echo "$TOKENS" | jq -r .id_token)

# RP-Initiated Logout
curl -sf "$IDP/realms/saas-prod/protocol/openid-connect/logout?id_token_hint=$IDT" > /dev/null

# The refresh attempt must fail
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
  "$IDP/realms/saas-prod/protocol/openid-connect/token" \
  -d "grant_type=refresh_token&client_id=e2e&refresh_token=$RT")
test "$STATUS" = "400" && echo "PASS: refresh rejected after logout"

Anti-Patterns — Doing It This Way Causes Incidents

Logout anti-patterns found over and over in code reviews and penetration tests.

Anti-pattern 1 — Frontend-only logout

// This is not logout
function logout() {
  localStorage.removeItem('access_token');
  localStorage.removeItem('refresh_token');
  window.location.href = '/login';
}

Only the local token copies were deleted; on the server the tokens remain valid and the IdP session stays alive. An attacker who captured tokens via browser history or XSS is unaffected. A revocation endpoint call and an RP-Initiated Logout redirect must accompany this.

Anti-pattern 2 — Exposing logout as a GET link

A GET /logout link without confirmation lets a single img tag force-logout a victim (logout CSRF). Use POST + CSRF token, or the standard flow with id_token_hint validation.

Anti-pattern 3 — Stateless JWT sessions plus the assumption of "logged out"

An implementation that manages auth state solely with JWTs, no server sessions, while the logout API returns 200. Nothing was actually invalidated. You need at least one of: a denylist (keyed by jti), a token version claim, or short lifetimes plus refresh revocation.

Anti-pattern 4 — Trusting the back-channel endpoint without authentication

An implementation that reads sub and kills sessions without verifying the logout_token signature becomes a DoS channel for logging out arbitrary users with forged tokens. Full validation — signature, iss, aud, events, and absence of nonce — is mandatory.

Anti-pattern 5 — Cache leftovers after logout

Personalized pages remaining in CDN or browser caches get exposed via the back button after logout. Apply Cache-Control private/no-store consistently to authenticated responses.

What the 2026 Browser Privacy Shift Leaves Behind

In summary, browser privacy hardening (third-party cookies blocked by default, partitioned storage, bounce tracking mitigations) leaves these marks on SSO logout design:

  1. Exclude front-channel logout and the OIDC Session Management (iframe polling) family from new designs — cross-domain behavior cannot be guaranteed.
  2. Back-channel logout is the only trustworthy propagation mechanism — but accept that delivery is at-most-once and combine it with short token lifetimes.
  3. Browser-mediated identity APIs such as FedCM are evolving to partially replace post-third-party-cookie session signals. The future of login-state sharing and logout signals is moving toward cooperation with browser APIs, so abstracting your auth layer makes the transition easier.
  4. The ultimate safety net is lifetime design — no propagation mechanism is 100%, so an access token lifetime and session idle timeout guaranteeing "even if every propagation fails, it all expires within N minutes anyway" is the last line of defense.

Closing Thoughts

Logout is a distributed state invalidation problem; there is no perfect solution, only good trade-offs. The design principles in summary:

  1. Model the three session layers (IdP session, app session, tokens) explicitly, and document what logout does to each layer.
  2. New designs default to RP-Initiated Logout + Back-Channel Logout. Front-channel cannot be trusted in the 2026 browser environment.
  3. Never promise SAML SLO before verifying the counterpart product's actual support level.
  4. Assume propagation fails; keep short access token lifetimes + refresh revocation + idle timeouts as the safety net.
  5. Logout UX (global vs single-app) is a security decision. Align it with the user's mental model and provide "log out of all devices."
  6. Put the test matrix into CI. Logout regressions happen silently and are discovered only after an incident.

References