Why Logout Is Surprisingly Difficult
Login is a request-response problem. The user shows up, proves something, and gets a credential. It either works or it visibly doesn't.
Login is a request-response problem. The user shows up, proves something, and gets a credential. It either works or it visibly doesn't.
Logout is a distributed systems problem, and it has the property that makes distributed systems problems miserable: the failure mode is silence. Nothing errors. Nothing gets logged. The confirmation page renders. And a session somewhere is still alive.
You already know that the IdP's authentication state and the application's session are different objects. This article is about what happens below that — the actual wire formats, the browser behavior that quietly broke one of these mechanisms in the last few years, and what a correct implementation has to do about it.
RP-initiated logout: four parameters, three ways to get it wrong
The starting point is the OIDC RP-Initiated Logout spec. The IdP publishes an end_session_endpoint in its discovery document, and the relying party redirects the browser there:
GET /connect/endsession
?id_token_hint=eyJhbGciOi...
&post_logout_redirect_uri=https://app.example.com/signed-out
&state=x7fq2
id_token_hint is the previously-issued ID token. It tells the IdP which session and which client this request is about, so it can skip the "are you sure you want to sign out?" interstitial and know where it's safe to send the user afterward.
Here's the practical problem nobody warns you about: the spec effectively expects you to still have the ID token. Plenty of RPs validate the ID token at login, extract the claims they care about, create their session, and throw the token away — which is otherwise good hygiene. Then logout arrives and there's nothing to put in id_token_hint. Some IdPs will proceed anyway and show a generic confirmation prompt; some will refuse to honor post_logout_redirect_uri without it. Either way you get a degraded flow. If you intend to do RP-initiated logout, store the raw ID token (or the newer logout_hint, where your IdP supports it) alongside the session at login time. Decide that on day one, because retrofitting it means touching session storage.
post_logout_redirect_uri must be pre-registered. This is not a formality. An end_session_endpoint that redirects to arbitrary URIs is an open redirect on your identity provider's domain — the single most credible-looking phishing origin you own. If you're building the IdP side, match registered values exactly; no prefix matching, no wildcard subdomains, no "starts with https://app.example.com". If you're building the RP side and your IdP accepts unregistered redirect URIs, that's a finding, not a convenience.
state works exactly as it does in the authorization request: opaque value, echoed back, lets you resume whatever the user was doing.
That's the easy part. The user is in the browser, moving through a redirect chain you control. Now consider the other four applications that also have live sessions.
Front-channel logout, and why it quietly stopped working
The OIDC Front-Channel Logout spec has an appealing simplicity. After ending its own authentication state, the IdP renders a page containing one hidden iframe per relying party, each pointing at that RP's registered frontchannel_logout_uri:
<iframe src="https://app-a.example.com/logout?iss=https://idp.example.com&sid=abc123"></iframe>
<iframe src="https://app-b.example.com/logout?iss=https://idp.example.com&sid=abc123"></iframe>
Each RP receives a GET, and — this is the load-bearing assumption — the browser attaches that RP's own session cookie, because the request goes to the RP's origin. The RP reads its cookie, finds the session, destroys it. No server-to-server connectivity needed, works through firewalls, trivial to implement.
That assumption is no longer true, and this is the most important mechanical fact in this article.
The iframe is embedded in a page served by the IdP. The request to app-a.example.com is therefore a cross-site request, and App A's session cookie is, in that context, a third-party cookie. Safari's ITP has blocked those by default since 2020. Firefox's Enhanced Tracking Protection with Total Cookie Protection partitions them. Chrome has spent years tightening the same behavior, and separately, SameSite=Lax — which is the browser default when a cookie omits the attribute — already prevents the cookie from being sent on cross-site iframe loads.
So the RP receives a request that looks correct in every way except that it carries no session cookie. There is no session to identify. The handler runs, finds nothing, and returns 200. The IdP's page loads all four iframes successfully. Everything reports success. Nothing was logged out.
This is why so many teams have front-channel logout that "used to work." It didn't break loudly. It stopped attaching a cookie.
There are partial mitigations. SameSite=None; Secure on the session cookie makes it eligible to be sent cross-site — which reintroduces the CSRF exposure the attribute exists to prevent, and is ignored entirely by browsers that block third-party cookies outright rather than merely honoring SameSite. frontchannel_logout_session_required tells the IdP to include sid and iss in the query string so the RP can identify the session without the cookie, looking it up by session index instead. That genuinely helps — but only if the RP maintains a sid-keyed lookup, which is exactly the work most front-channel implementations skipped because the cookie made it unnecessary.
And there's a structural problem no amount of cookie configuration fixes: the IdP gets no acknowledgement. An iframe load reports nothing back to the parent document across origins. The IdP cannot distinguish "session destroyed" from "RP returned 200 and did nothing" from "request blocked by an extension." The protocol has no success signal. Any UI built on top of it that says "you have been logged out" is asserting something it did not verify.
Back-channel logout: the Logout Token
Back-channel logout removes the browser from the loop entirely. The IdP makes a direct HTTP POST to each RP's registered backchannel_logout_uri:
POST /backchannel-logout HTTP/1.1
Content-Type: application/x-www-form-urlencoded
logout_token=eyJhbGciOiJSUzI1NiIsImtpZCI6...
The Logout Token is a signed JWT, and the validation rules are stricter than they look:
{
"iss": "https://idp.example.com",
"aud": "client-id-for-this-rp",
"iat": 1754200000,
"jti": "b7f1c9e2-...",
"sub": "248289761001",
"sid": "abc123",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}
Validate the signature against the IdP's JWKS. Check iss and aud as you would for an ID token. Check iat is recent. Require sub, sid, or both — and know which your IdP sends, because sub alone means "log out every session for this user," while sid means "log out this one session," and they are not interchangeable. Require the events claim to contain the http://schemas.openid.net/event/backchannel-logout member.
Then the rule people miss: a Logout Token MUST NOT contain a nonce claim, and you must reject it if it does.
This isn't stylistic. It's what prevents ID-token substitution. Without that rule, an attacker holding a valid ID token for a user could POST it to your backchannel_logout_uri — same issuer, same audience, valid signature, plausible iat, containing sub and sid. It would pass every other check. The nonce prohibition, combined with the mandatory events claim, makes the two token types structurally non-interchangeable in both directions. Skip that one line of validation and you've built a denial-of-service endpoint that anyone with a leaked ID token can drive.
Two implementation requirements follow. First, you need a sid → local session mapping, populated at login from the ID token's sid claim. Without it the Logout Token is unactionable — you're told a session ended and have no way to say which of yours it was.
Second, and this catches more teams than it should: that mapping must live in shared storage. Back-channel logout is a server-to-server POST that hits whichever instance your load balancer picks. If sessions live in process memory, the notification lands on instance 3 while the session sits on instance 1, and it's dropped. It works perfectly in local development, where there's one process. Redis, your session database, anything shared — but not memory.
sequenceDiagram
participant U as Browser
participant IdP as Identity Provider
participant A as RP A
participant B as RP B
Note over U,B: Front-channel — browser mediated, no acknowledgement
U->>IdP: GET /endsession
IdP-->>U: HTML page with hidden iframes
U->>A: GET /frontchannel_logout (cookie BLOCKED cross-site)
A-->>U: 200 (no session found, nothing destroyed)
U->>B: GET /frontchannel_logout (cookie BLOCKED cross-site)
B-->>U: 200 (no session found, nothing destroyed)
Note over IdP: IdP sees success. It has no idea.
Note over U,B: Back-channel — server to server, real responses
IdP->>A: POST logout_token (JWT)
A->>A: Validate, look up sid, destroy session
A-->>IdP: 200 OK
IdP->>B: POST logout_token (JWT)
B--xIdP: 503 — genuinely failed, and the IdP knows
SAML Single Logout, and its reputation
SAML SLO uses <LogoutRequest> and <LogoutResponse> messages, signed like any other SAML message, and comes in two bindings.
The front-channel bindings (HTTP-Redirect and HTTP-POST) chain the user's browser through every service provider sequentially. The IdP redirects to SP 1 with a LogoutRequest; SP 1 destroys its session and redirects back with a LogoutResponse; the IdP redirects to SP 2; and so on. The user's browser is the transport, the message bus, and the retry mechanism.
Every weakness follows from that shape. One slow SP stalls the entire chain — everyone after it in the ordering stays logged in while the user stares at a spinner. If the user closes the tab, or hits back, or a redirect fails, the chain terminates wherever it happens to be. The SPs after that point never even receive their LogoutRequest. And the same third-party cookie erosion that broke OIDC front-channel logout applies here too, though somewhat less acutely because these are top-level navigations rather than iframes.
The SessionIndex element is SAML's sid. It appears in the original assertion and must be echoed in the LogoutRequest so the SP knows which session to end — a user with two sessions at the same SP needs both distinguished. SPs that ignore SessionIndex and log out everything for that NameID are a common source of "logging out of one browser killed my other browser" reports.
SAML at least has honest vocabulary for the outcome. urn:oasis:names:tc:SAML:2.0:status:PartialLogout exists precisely because the spec authors knew the chain would break. Most implementations either never emit it or never surface it to anyone.
The SOAP back-channel binding solves most of this — direct server-to-server, real responses, no browser. It is also barely deployed, because it requires the IdP to reach every SP over the network, which fails immediately for SPs behind corporate firewalls. That's the honest reason SAML SLO has the reputation it has: the reliable binding is the one nobody can deploy.
Tell the truth about partial failure
Suppose three of four RPs acknowledged and the fourth timed out. What does the user see?
The overwhelmingly common answer is a green checkmark and "You have been signed out." That message is false, and its falseness is specifically dangerous on a shared machine — a library terminal, a hospital workstation, a warehouse floor tablet. The user's next action depends on it. Told the truth, they close the browser or find an admin. Told a comfortable lie, they walk away.
Signed out of 3 of 4 applications.
Support Console did not confirm sign-out. If you're on a shared computer, close all browser windows.
Nobody enjoys shipping that screen. It is still strictly better than the alternative, and it's only possible at all with back-channel logout, because that's the only one of these mechanisms that gives you a response to check. That is a design argument, not just an operational one.
"Logout doesn't work" is often about tokens
A large fraction of logout bug reports are not logout bugs.
Ending a session does not invalidate an access token. A JWT access token is self-contained and valid until exp — the resource server validates a signature and an expiry, and has no idea a session ended five minutes ago. So the user logs out, an SPA tab keeps polling with a token that has eleven minutes left, and the API keeps answering. Everything is behaving as specified.
Your options are the ones you'd expect and should choose deliberately: call the RFC 7009 revocation endpoint at logout (which for JWT access tokens usually only revokes the refresh token, since there's nothing to revoke on the access token itself), use reference tokens with introspection so revocation is immediate at the cost of a lookup per request, or keep access token lifetimes short enough that the exposure window is acceptable. Five minutes of residual API access is a policy decision. It should be a recorded one, not an accident.
The refresh token is the part that actually matters. An access token expires on its own; a refresh token left alive after logout can mint new ones indefinitely. Revoking refresh tokens at logout is not optional, and it's the step most implementations skip because logout appears to work without it.
What is genuinely unsolvable
Some of this doesn't have a fix, and pretending otherwise leads to worse designs than accepting it.
You cannot guarantee logout on a closed tab. Front-channel logout requires a live browser executing your redirect chain. If the user closes it mid-flow, the remaining RPs never hear anything. navigator.sendBeacon on pagehide is best-effort at best and doesn't survive a killed process, a lost network, or a laptop lid. Back-channel logout is the answer here — not because it's more reliable at delivery, but because it doesn't depend on the user's continued presence at all.
You cannot un-issue a token that has already been minted. If an RP cached an access token, or handed it to a downstream service, or a mobile client has it in memory, nothing you do at logout time reaches it. Introspection helps if the resource server actually introspects. Short lifetimes shrink the window. Neither closes it. There is always a period after logout during which some previously-issued credential still works, and the only real question is how long you decided that period is.
You cannot make a non-participating RP comply. If a relying party never registered a backchannel_logout_uri, no protocol mechanism reaches it. This is the common case with third-party SaaS in an enterprise deployment: the customer's IdP supports back-channel logout, four of the seven integrated applications do not, and single logout means "four of seven" forever. That's a vendor procurement problem wearing a protocol costume, and no amount of IdP configuration solves it.
Native and mobile apps are a separate problem. They hold refresh tokens in secure storage, often for weeks. There's no browser session to end and no cookie to clear. Logout there means explicitly revoking the refresh token server-side and clearing local state, and if the device is offline at logout time, it may hold a working credential until it reconnects. Any "log out everywhere" feature has to treat mobile as its own path, or it will silently exclude the clients with the longest-lived credentials.
What to actually do
Prefer back-channel logout. It's the only mechanism that survives third-party cookie blocking, works with a closed browser, and returns a real status code.
Store sid at login. From the ID token, alongside the session record. It's two lines, and every back-channel and session-index-aware front-channel implementation depends on it.
Put session state in shared storage. Back-channel logout hits an arbitrary instance. Process memory means dropped notifications that never reproduce in development.
Store the ID token too, if you plan to do RP-initiated logout. id_token_hint needs it.
Pre-register every post_logout_redirect_uri, and match exactly. Unregistered redirects at the end-session endpoint are an open redirect on your identity domain.
Revoke refresh tokens explicitly at logout. RFC 7009. Nothing else stops a client from quietly minting new access tokens after the session is gone.
Reject Logout Tokens containing nonce. One conditional. It's what makes an ID token unusable as a logout trigger.
Test the partial-failure path deliberately. Take one RP down, run logout, and look at what the user is told. If it says "signed out" with a checkmark, you have found the bug — and it's a bug in the copy, which is the layer nobody reviews.
The recurring theme is that logout's failures are quiet ones. Login failing is a support ticket within the minute. Logout failing looks exactly like logout succeeding, right up until someone reviews an access log after an incident and finds a session that outlived the person who ended it.