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

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — Logout Is Harder Than Login
- The Three-Layer SSO Session Model — What Exactly Must Be Cut
- OIDC RP-Initiated Logout — Where Logout Begins
- Front-Channel Logout — The Rise and Fall of the iframe
- Back-Channel Logout — Direct Server-to-Server Notification
- SAML SLO — A Standard Exists, Reality Is Rough
- Logout Configuration in Keycloak
- Session Fixation and Lingering Tokens — The Security Risks of Logout
- Logout in Mobile Apps
- Global Logout vs Single-App Logout — A UX Decision
- Test Matrix
- Anti-Patterns — Doing It This Way Causes Incidents
- What the 2026 Browser Privacy Shift Leaves Behind
- Closing Thoughts
- References
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 mode | Symptom | Cause |
|---|---|---|
| Only the app session ended | Logging back in to app A right after logout requires no password | IdP SSO session (L1) still alive |
| Only the IdP session ended | After IdP logout, app B keeps working | App B local session (L2) still alive |
| Sessions all cut, APIs still work | Mobile app keeps calling APIs successfully after logout | access/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.
- 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.
- No delivery confirmation: the IdP cannot tell whether the iframe load succeeded. No retry on failure.
- 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:
| Criterion | Session Management (iframe polling) | Front-Channel Logout | Back-Channel Logout |
|---|---|---|---|
| Propagation medium | Browser (polling OP iframe state) | Browser (hidden iframe loads) | Server-to-server HTTP POST |
| Third-party cookie dependency | Yes — fatal | Yes — fatal | None |
| Delivery confirmation/retry | Impossible | Impossible | Possible (implementation dependent) |
| RP implementation burden | Frontend polling logic | One logout URL | Signature verification + session mapping |
| Robust to browser close/navigation | No | No | Yes |
| RPs behind firewalls | Works | Works | Problematic |
| 2026 recommendation | Deprecated | Deprecated | The 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.
| Option | Behavior | Suitable context |
|---|---|---|
| Single-app logout | End only this app's session; keep the IdP session | Workplace portals — other work apps must keep working |
| Global logout | End the IdP session + all RP sessions | Shared PCs, security-sensitive environments, "log out everywhere" |
| User choice | Ask the user for scope at logout | Mixed 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.
| # | Scenario | Expected result |
|---|---|---|
| 1 | Re-access app A after logging out of app A | Re-authentication required |
| 2 | Access app B after logging out of app A (global policy) | Re-authentication required (verify back-channel propagation) |
| 3 | API call with the old access token within 5 minutes of logout | 401 or allowed-until-expiry per policy — as documented |
| 4 | Refresh attempt with the refresh token after logout | 401/400 invalid_grant |
| 5 | IdP admin force-terminates the session | Logout tokens sent to all RPs, app sessions ended |
| 6 | Logout while the RP back-logout endpoint is down | IdP logout succeeds; after recovery, that RP session dies via idle timeout |
| 7 | Logout token replay (resending the same jti) | Rejected |
| 8 | Forged logout token containing a nonce | Rejected |
| 9 | Unregistered URL in post_logout_redirect_uri | Rejected or ignored |
| 10 | Full flow in a third-party-cookie-blocking browser | Works correctly via the back-channel path |
| 11 | Mobile: API call with a presumed-stolen token after logout | 401 due to revocation taking effect |
| 12 | Back button to a personalized page right after logout | No 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:
- Exclude front-channel logout and the OIDC Session Management (iframe polling) family from new designs — cross-domain behavior cannot be guaranteed.
- Back-channel logout is the only trustworthy propagation mechanism — but accept that delivery is at-most-once and combine it with short token lifetimes.
- 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.
- 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:
- Model the three session layers (IdP session, app session, tokens) explicitly, and document what logout does to each layer.
- New designs default to RP-Initiated Logout + Back-Channel Logout. Front-channel cannot be trusted in the 2026 browser environment.
- Never promise SAML SLO before verifying the counterpart product's actual support level.
- Assume propagation fails; keep short access token lifetimes + refresh revocation + idle timeouts as the safety net.
- 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."
- Put the test matrix into CI. Logout regressions happen silently and are discovered only after an incident.
References
- OpenID Connect RP-Initiated Logout 1.0
- OpenID Connect Front-Channel Logout 1.0
- OpenID Connect Back-Channel Logout 1.0
- OpenID Connect Core 1.0
- SAML 2.0 Profiles (including the Single Logout Profile, OASIS)
- SAML 2.0 Core Specification (OASIS)
- RFC 7009 — OAuth 2.0 Token Revocation
- RFC 7519 — JSON Web Token (JWT)
- RFC 9700 — Best Current Practice for OAuth 2.0 Security
- Keycloak Documentation
- Keycloak Release Notes
- FedCM (Federated Credential Management) — W3C