·
Front-Channel and Back-Channel Logout: Why Your Logout Is Broken
Logging out of one app does not log a user out of every app in an SSO session. How front-channel and back-channel OIDC logout actually work, real HTTP examples, and where each one silently fails.
Logging a user out of one application does not log them out of every application in a single sign-on session. Clearing that app’s own cookie only ends the session with the app that issued it. Front-channel logout notifies the other applications through the browser, in hidden iframes; back-channel logout notifies them directly, server-to-server, with no browser involved at all. Most “logout doesn’t work” bugs come from implementing only one of the two, or neither, and treating a redirect to a login page as proof the user is signed out everywhere.
The scenario
A user has three apps open, all authenticated through the same identity provider (OP):
- App A: a billing dashboard, open in the current tab.
- App B: an admin console, open in a background tab.
- App C: a mobile app whose backend holds a session tied to the same login, no browser tab at all.
The user clicks Log out in App A. What has to happen for that to actually mean “logged out everywhere”:
- The OP’s own SSO session dies, so it can’t silently re-issue tokens for A, B or C.
- App B, sitting in a background tab, has to find out. Nothing the user did in App A touched App B’s cookie.
- App C’s backend has to find out too, and it can’t be reached by anything the browser does on the user’s behalf.
Step 2 is what front-channel logout solves. Step 3 is what back-channel logout solves. A logout implementation that only clears App A’s own cookie handles none of the three: the user is “logged out” of the app they clicked in, and logged into everything else for as long as those sessions happen to live.
Front-channel logout: notify the browser
The OP renders a page containing one hidden <iframe> per relying party (RP) that participated in the session, each pointing at that RP’s registered front-channel logout URI. The browser loads every iframe in the background; each RP drops the session being logged out and returns 200 OK with an empty body. The user never sees any of this; it happens while the browser is rendering the OP’s own post-logout page.
How the RP knows which session to drop depends on registration. If the client is registered with frontchannel_logout_session_required, the OP appends iss (its issuer) and sid (the session ID) to the logout URI, and the RP can identify the session from the query string alone. That flag defaults to false, and both parameters are optional in the spec, so an RP that doesn’t ask for them has only its own cookie to go on. That’s precisely the case browser cookie policy breaks (see failure modes below).
sequenceDiagram
autonumber
participant B as Browser
participant OP as auth (OP)
participant RA as RP A (frontchannel)
participant RB as RP B (frontchannel)
note over B: User clicks "Log out" in RP A
B->>OP: GET /logout?post_logout_redirect_uri=…&id_token_hint=…
OP->>OP: Invalidate SSO session, list session participants
OP-->>B: 200 HTML — one hidden iframe per participant
par Browser loads each iframe in the background
B->>RA: GET /logout/frontchannel?iss=…&sid=…
RA->>RA: Clear session for sid
RA-->>B: 200 OK
and
B->>RB: GET /logout/frontchannel?iss=…&sid=…
RB->>RB: Clear session for sid
RB-->>B: 200 OK
end
B->>B: Redirect to post_logout_redirect_uri
A concrete exchange, starting with the browser hitting the OP’s logout endpoint:
GET /logout?post_logout_redirect_uri=https%3A%2F%2Fapp-a.example.com%2F&id_token_hint=eyJhbGciOiJSUzI1NiJ9... HTTP/1.1
Host: id.versola.kz
The OP invalidates its own session and, for each RP that has a frontchannel_logout_uri registered, includes a hidden iframe pointing at it. The browser loads:
GET /logout/frontchannel?iss=https%3A%2F%2Fid.versola.kz&sid=8f14e45f-ceea-4a2b-9d61-1e2f3a4b5c6d HTTP/1.1
Host: app-b.example.com
HTTP/1.1 200 OK
Cache-Control: no-store
When they are sent, iss and sid are the only signal the RP gets. There’s no authentication on this request beyond “does this session ID belong to a session I’m tracking, and does iss match the OP I trust.” That’s deliberate: it’s a GET request loaded in an iframe, so it can’t carry anything more sensitive.
Back-channel logout: notify the server directly
Front-channel logout only happens if a browser is there to run it. That’s a real constraint, and it isn’t only about whether a tab is open: the iframes fire from whichever user agent the logout was initiated in, so nothing reaches App C’s backend when the user signs out from the mobile app itself, when an administrator terminates the session, or when the session is revoked from another device. Even in the browser case, the user can navigate away before the iframes finish loading.
Back-channel logout removes the browser from the equation entirely: the OP calls the RP’s backchannel_logout_uri directly, server-to-server, with a signed logout_token, a JWT built specifically for this purpose and not the RP’s id_token replayed.
sequenceDiagram
autonumber
participant B as Browser
participant OP as auth (OP)
participant RB as RP B backend
note over RB: Holds a session for this user, reachable only server-to-server
note over B: User clicks "Log out" in RP A
B->>OP: GET /logout (SSO session cookie)
OP->>OP: Invalidate SSO session, list session participants
par Dispatched in the background — the logout response never waits
OP->>RB: POST /logout/backchannel (logout_token=…)
RB->>RB: Verify signature, iss, aud, events, no nonce
RB->>RB: Delete session by sid
RB-->>OP: 200 OK (nothing is blocked on this)
and
OP-->>B: Redirect to post_logout_redirect_uri
end
POST /logout/backchannel HTTP/1.1
Host: app-c-backend.example.com
Content-Type: application/x-www-form-urlencoded
logout_token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImtpZC0xIn0.eyJpc3MiOiJodHRwczovL2lkLnZlcnNvbGEua3oiLCJzdWIiOiJ1c3JfNDQxMSIsImF1ZCI6WyJhcHAtYyJdLCJqdGkiOiJiN2YyYzlkZS0uLi4iLCJpYXQiOjE3NTU3NzYwMDAsImV4cCI6MTc1NTc3NjEyMCwic2lkIjoiOGYxNGU0NWYtLi4uIiwiZXZlbnRzIjp7Imh0dHA6Ly9zY2hlbWFzLm9wZW5pZC5uZXQvZXZlbnQvYmFja2NoYW5uZWwtbG9nb3V0Ijp7fX19...
Decoded, that token’s claims are:
{
"iss": "https://id.versola.kz",
"sub": "usr_4411",
"aud": ["app-c"],
"jti": "b7f2c9de-...",
"iat": 1755776000,
"exp": 1755776120,
"sid": "8f14e45f-ceea-4a2b-9d61-1e2f3a4b5c6d",
"events": {
"http://schemas.openid.net/event/backchannel-logout": {}
}
}
The events claim is what positively identifies this as a logout token, and it’s required. That’s the check an RP relies on to know what it’s holding. The spec additionally forbids a nonce claim, which is a complementary defensive rule rather than an identifying one: an id_token only carries a nonce if the authorization request supplied one, so its absence proves nothing on its own. Rejecting any token that does carry one is what stops a captured id_token from being replayed as a logout signal, which matters because a service that accepted one in place of a logout token would let anyone holding an id_token force other users’ sessions closed.
On success:
HTTP/1.1 200 OK
Cache-Control: no-store
On a token that fails validation:
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"error":"invalid_request","error_description":"logout token must not carry a nonce"}
The RP never has to be reachable from a browser, never sees the user’s session cookie, and the browser that triggered the logout is never told whether App C’s backend actually cleared anything.
Failure modes
Third-party cookie blocking silently defeats front-channel-only logout
The front-channel iframe is loaded from the RP’s origin, in a browser context that increasingly treats third-party iframes as unable to read the cookies they’d need to identify which session to clear. If an implementation relies on the browser’s ambient session cookie inside that iframe rather than the sid query parameter to identify the session, Safari’s ITP and Chrome’s third-party cookie phase-out make the iframe load, return 200 OK, and clear nothing. This is precisely why back-channel logout exists as a second, independent channel. It’s not redundant with front-channel; it’s the fallback for browsers that partition or block the first one.
Back-channel delivery is fire-and-forget
Notifying every RP from the OP’s /logout handler, synchronously, means one slow or unreachable RP makes every user’s logout hang or fail. So back-channel deliveries are typically dispatched in the background, each bounded by a short timeout (Versola: 5 seconds, no retries), without blocking the response the browser is waiting on. That’s the right tradeoff for the user in front of the browser, but it means a back-channel logout can silently fail: if the RP’s endpoint is down, the OP’s session is gone but the RP’s copy lives until it expires on its own. Retries don’t fix this, since a logout token is only valid for a couple of minutes, so a delayed retry mostly doesn’t land in time either. The real mitigation is keeping RP-side session/token lifetimes short enough that this window doesn’t matter.
Notifying clients that were never in the session
A different failure mode than the first two: implementations that fan a logout out to every client registered under the tenant, instead of only the clients that actually participated in this specific SSO session. That’s both wasteful (calling RPs the user never touched) and a correctness bug (a sid those RPs never saw). It also means an OP has to actually track, per session, which clients that session was used with, populated when tokens are issued to a client and updated on silent re-authorization, rather than treating “logout” as “call everyone we know about.”
post_logout_redirect_uri treated as trusted input
post_logout_redirect_uri is attacker-controlled. It arrives on the query string of a GET /logout request that anyone can construct and send a victim to. An OP that redirects to it unconditionally after logout has an open redirect: https://id.example.com/logout?post_logout_redirect_uri=https://evil.example.com/ sends the user to an attacker’s page immediately after they’ve just been told “you’re now logged out,” which is exactly when they’re least suspicious of what comes next. The fix is the same one applied to OAuth’s redirect_uri: check it against a registered allow-list before honoring it, and drop it silently (redirect nowhere, or to a default page) if it doesn’t match.
The OP’s own session outliving the RP’s
The inverse of the first three: an application’s own “Log out” button that only clears its local cookie and never calls the OP’s end_session_endpoint at all. The RP looks logged out, but the OP’s SSO session cookie is still alive. The next silent re-authentication attempt (a hidden-iframe prompt=none check, or simply the user clicking “log in” again) signs them straight back in with no prompt, because as far as the OP is concerned nothing happened. This is the failure mode behind “I logged out but it logged me back in” bug reports: the RP did its half of the job and stopped there.
A checklist for evaluating an OIDC provider’s logout
- Does it implement both front-channel and back-channel logout, not just one?
- Does it track which clients actually participated in a session, rather than notifying every registered client?
- Does it validate
post_logout_redirect_uriagainst a registered list instead of redirecting to it unconditionally? - Does back-channel delivery run in the background with a bounded timeout, so one unreachable RP can’t hang every user’s logout?
- Does it reject a
logout_tokenthat carries anonce, and require theeventsclaim? Those are the two checks that stop anid_tokenfrom being replayed as a logout signal.
How Versola implements it
Versola’s auth service exposes RP-initiated logout at GET/POST /logout (id_token_hint, post_logout_redirect_uri, state). If the id_token_hint already proves which session is being closed, it skips the confirmation page entirely, since there’s nothing left to confirm. On logout, auth:
- resolves the actual session participants from the session record: clients registered against that session at token issuance or silent re-auth, not every client under the tenant;
- builds a front-channel logout URI for each one with a registered
frontchannel_logout_uri; - dispatches back-channel
logout_tokens in parallel, on their own fibers, each bounded by a 5-second timeout with no retries, so a dead RP never delays anyone else’s logout response; - checks
post_logout_redirect_uriagainst the tenant’s registered redirect URIs before honoring it.
On the receiving side, edge (the component that sits in front of an integrated application) implements both endpoints: GET /logout/frontchannel (validates iss against the configured OP, clears the EDGE_SESSION cookie for that session) and POST /logout/backchannel (validates the logout_token’s signature, issuer, audience, events claim, and rejects it outright if it carries a nonce, per the checklist above).
Client-side configuration for both URIs lives on the client entity. See front-channel and back-channel logout configuration in the docs.