In this article
September 8, 2026
September 8, 2026

How to implement OIDC back-channel logout, and why almost nobody has

It is the one standard that can end a session your app already issued, it has been final since 2022, and almost nobody implements it. Here is the whole mechanism, the validation your endpoint owes, and the two limitations the spec admits to itself.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Your app makes one access decision per session. A user authenticates through SSO, you mint a session, and from that point you trust it until it expires. If IT disables the account an hour later, your app has no idea. We covered why that gap exists and where the industry is heading in continuous access evaluation and B2B SaaS.

CAEP is the answer for the next few years. Back-channel logout is the answer that already exists. It has been a final OpenID specification since 2022, the current revision incorporating errata set 1 is dated December 2023, and it does one thing well: it lets an identity provider reach into your app and end a session it did not create.

Hardly anyone implements it. That is not because it is obscure. It is because the spec asks for two things that are genuinely hard, and it says so itself.

The mechanism, end to end

Four moving parts.

The provider advertises it. In its discovery document, an OP sets backchannel_logout_supported to true. It should also publish backchannel_logout_session_supported, which says whether it can include a sid claim identifying a specific session. If it can, that sid also shows up in the ID tokens it issues, which is what lets you correlate later.

You register an endpoint. As part of client registration you provide a backchannel_logout_uri. It must be an absolute URI, it must not have a fragment, and it should be HTTPS. You can also set backchannel_logout_session_required to declare that you need a sid in every logout token, rather than being told to log out every session a user has.

The provider POSTs to it. When the user logs out at the OP, or IT kills the account, the OP sends a form-encoded POST carrying one parameter:

  
POST /backchannel_logout HTTP/1.1
Host: rp.example.org
Content-Type: application/x-www-form-urlencoded

logout_token=eyJhbGci ... .eyJpc3Mi ... .T3BlbklE ...
  

No browser involved. No cookie. Just your server and theirs.

You validate the token and end the session. That is where the work is.

The logout token

It is a JWT that looks like an ID token and is deliberately not one:

  
{
  "iss": "https://server.example.com",
  "sub": "248289761001",
  "aud": "s6BhdRkqt3",
  "iat": 1471566154,
  "exp": 1471569754,
  "jti": "bWJq",
  "sid": "08a5019c-17e1-4977-8f42-65a12843ea02",
  "events": {
    "http://schemas.openid.net/event/backchannel-logout": {}
  }
}
  

iss, aud, iat, exp, jti and events are required. sub and sid are each optional, but the token must carry at least one of them, and may carry both.

That last rule is the one with teeth. If there is no sid, the instruction is to log out every session at your app for that user, not just one. A provider that cannot track sessions individually can still tell you to kill them all, and your handler has to be prepared to do that.

Two details protect the token from being reused as something else. nonce is prohibited outright, specifically so that a logout token cannot be passed off as an ID token in a forged authentication response. And the spec recommends explicitly typing the token with a typ header of logout+jwt, while conceding that requiring it "will break most existing deployments, as existing OPs and RPs are already commonly using untyped Logout Tokens." So recommend it, do not require it.

The token is signed with the same keys the OP uses for ID tokens, and providers are encouraged to keep the expiry short, "preferably at most two minutes in the future," so a captured token is not replayable for long.

Validating it

The spec lists eleven validation steps, seven of which you must do and four of which are optional correlation checks. Most of the first group is standard JWT verification, and a library will do it if you configure it properly:

  
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`));
const LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout';

export async function POST(req: Request) {
  const form = await req.formData();
  const logoutToken = form.get('logout_token');

  if (typeof logoutToken !== 'string') {
    return bad('invalid_request', 'missing logout_token');
  }

  let claims;
  try {
    ({ payload: claims } = await jwtVerify(logoutToken, JWKS, {
      issuer: ISSUER,
      audience: CLIENT_ID,
      algorithms: ['RS256'],        // never 'none'
      clockTolerance: 30,
    }));
  } catch {
    return bad('invalid_request', 'signature or claims failed validation');
  }

  if (!claims.sub && !claims.sid) return bad('invalid_request', 'need sub or sid');
  if (!claims.events?.[LOGOUT_EVENT]) return bad('invalid_request', 'not a logout token');
  if ('nonce' in claims) return bad('invalid_request', 'nonce is prohibited');
  if (await seenRecently(claims.jti)) return ok();   // idempotent replay

  await endSessions(claims);
  await rememberJti(claims.jti, claims.exp);

  return ok();
}

const ok = () =>
  new Response(null, { status: 200, headers: { 'Cache-Control': 'no-store' } });

const bad = (error: string, error_description: string) =>
  new Response(JSON.stringify({ error, error_description }), {
    status: 400,
    headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' },
  });
  

Three things in there are easy to get wrong.

  • Pin the algorithm. The spec says an alg of none must never be accepted on a logout token. Passing an explicit algorithm list is how you guarantee that.
  • Reject nonce explicitly. It feels redundant until you remember what it defends against: this is the check that stops a logout token being replayed as an ID token.
  • Return the right status. Success is 200, and the spec notes that frameworks often substitute 204 for an empty body, so providers should tolerate both. Any failure is 400, optionally with an OAuth-style error and error_description body to help whoever is debugging. Add Cache-Control: no-store so nothing between you caches a response and interferes with a later logout.

One more thing worth building even though the spec files it under optional: the jti replay check. Providers are told not to retransmit except when they suspect a recoverable failure, which means retries do happen, and a handler that is not idempotent will do the work twice or error the second time.

The part the spec calls implementation specific

Everything above is mechanical. This is where implementations actually stall, because the specification hands the problem straight back to you. Section 2.7, on what to do once a valid token arrives:

"the RP locates the session(s) identified by the iss and sub Claims and/or the sid Claim. The RP then clears any state associated with the identified session(s). The mechanism by which the RP achieves this is implementation specific."

Which means: for this to work, you need to have stored, at login, a mapping from the OP's sid to your own session record. If you never captured the sid from the ID token, there is nothing to look up when the logout token arrives, and your only option is the blunt one of ending every session for that user.

And ending a session has to mean more than deleting a cookie. The logout token arrives server to server, so there is no browser to clear. The spec is explicit that front-channel logout gets to clear cookies and local storage, and back-channel logout does not, so "all needed state must be explicitly communicated between the parties."

How the sid ties login to logout. At login, an ID token arrives carrying a sub and a sid. You store that sid on your own session row alongside your session id and user id. Days later a logout token arrives carrying the same sid, you look the row up by it, and that one session ends. Below, two ways you end up killing every session instead: the token carries no sid, in which case the spec says end every session for that sub, or you never stored the sid at login, which produces the same outcome even though the token told you which session to end.

Concretely, endSessions should:

  • Delete the server-side session records matched by sid, or by sub when no sid is present
  • Revoke refresh tokens issued to that session, which the spec makes explicit: refresh tokens without the offline_access property should be revoked, and those with it normally should not
  • Handle unexpired access tokens, through a denylist or short enough lifetimes that you accept the gap
  • Cascade, if your app is itself an identity provider to something downstream

If the user is already logged out when the request arrives, that counts as success. Return 200.

The two limitations, in the spec's own words

The specification is unusually candid about why this is hard. Both reasons sit in Section 1, the introduction, rather than buried in a considerations section at the end.

Your endpoint has to be reachable by every provider you support. Section 1 of the specification:

"Another significant limitation of back-channel logout is that the RP's back-channel logout URI must be reachable from all the OPs used. This means, for instance, that the RP cannot be behind a firewall or NAT when used with public OPs."

There is no polling mode. This is worth saying clearly because the Shared Signals Framework does have one, and receivers behind a firewall can poll for events rather than being pushed to. Back-channel logout has no equivalent. If your deployment cannot accept an inbound POST from a customer's identity provider, this mechanism is not available to you, and that is a real reason some teams never adopt it.

Terminating the session is your problem. Also Section 1:

"RPs must implement an application-specific method of terminating RP sessions with the OP upon receiving back-channel logout requests; this can be more complicated than simply clearing cookies and HTML5 local storage state, which is often all that has to happen to implement logout in response to front-channel logout requests."

That is the sentence explaining the adoption numbers. Front-channel logout is a redirect and a cookie clear. Back-channel logout requires a session store you can invalidate on demand, keyed by something the provider knows about. Plenty of apps do not have one, and discovering that is a refactor rather than a feature.

So is it worth wiring up

Yes, with a clear head about what it is.

Compared to CAEP, back-channel logout is far more widely implemented, which is a low bar: Okta is currently the only identity provider with production-ready Shared Signals transmitter and receiver support, Google Workspace's receiver is in closed beta, and Microsoft Entra exposes no public SSF endpoints. Compared to the number of applications that could support back-channel logout, adoption is poor. Both things are true, and they are not in tension.

The practical read is that back-channel logout handles the single most common case, an account being disabled or a user logging out centrally, using a standard that shipped years ago and that your customers' identity providers already speak. It does not give you role changes mid-session, device posture, or credential events. Those are what CAEP adds, when you can get it.

If you are choosing where to spend a sprint, the order that makes sense is: shorten your session lifetimes first, because that shrinks the window with no protocol work at all; capture and store the sid at login, because everything else depends on it and it costs nothing; then stand up the logout endpoint above. That gets you a session store that can be invalidated on demand, keyed to the provider's identifier, which is also exactly what a CAEP receiver needs later.

The endpoint is an afternoon. The session store is the project. That has always been the real reason this never caught on.