LabHub

Blog

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

한국어English日本語

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:

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.

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.

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:

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:

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
# 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.

Logout in Mobile Apps

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

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:

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

Comments

No comments yet.

Sign in to leave a comment