Skip to content
TDACorpIDENTITYSign in

Back-channel logout

How identity tells your application that a session has ended, what the route helper checks and answers, and how retries behave.

On this page

A person can end a session at identity, in another application, or from their account. Your own session cookie knows nothing about that. Back-channel logout closes the gap: identity calls your server, from its server, to say which session ended (OpenID Connect Back-Channel Logout 1.0).

What identity sends#

Identity sends a form POST to the back-channel logout address registered for your client, with one field, logout_token. The token is a signed JWT.

ClaimValue
header typlogout+jwt
issThe identity issuer.
audYour client id.
subThe person's account identifier. For a client registered with the pairwise subject type, the same per-client value as in its ID token, not the real identifier.
sidThe identity session that ended. It is the sid of the ID token you received at sign-in.
jtiA unique id for this token.
iat, expexp is at most 15 minutes after iat.
events{"http://schemas.openid.net/event/backchannel-logout": {}}

There is never a nonce. A logout token is sent for every session your client holds an authorization on, whether or not it asked for offline_access. A session that had already expired when it was ended is not announced. Neither is one for a pairwise client when the person's account is no longer active and the client never received a value for it.

Delivery#

  • The address must be https, and reachable from the public internet. Identity does not follow redirects.
  • Any 2xx answer is success. Answer quickly, after you have ended the session.
  • Anything else, or no answer, is retried a limited number of times over several minutes. The same token, with the same jti, is sent each time, so make your handler idempotent.
  • After the last attempt identity stops. A notification can therefore be missed, for example during an outage of yours, and persistent failure can lead identity to stop sending to the address until it is changed. Do not treat back-channel logout as your only check. Where it matters, such as before a sensitive action, confirm the session is still live.

Mount the route#

createAuthRoutes and createNextAuthHandlers give you a backchannelLogout handler. In the Next.js catch-all route it is POST /api/auth/backchannel-logout. It answers 404 until you pass a backchannelLogout option, and, when mounted on its own path, 405 to anything but POST. Through the catch-all route any other method is a 404.

The handler, in order:

  1. Requires Content-Type: application/x-www-form-urlencoded, a body of at most 8192 bytes and exactly one non-empty logout_token. Otherwise it answers 400.
  2. Verifies the token: signature against the issuer's keys, typ logout+jwt, iss, aud, the back-channel logout events member, no nonce, iat, exp, a kid, and at least one of sub and sid. A token that fails answers 400.
  3. Claims the jti. A jti that was already claimed is refused as a replay and answers 400.
  4. Calls your onLogout(token) with sub, sid and jti.
  5. Answers 200 with Cache-Control: no-store. If onLogout throws, it answers 500 so identity retries, and it releases the jti first so that the retry is not mistaken for a replay.

An unexpected error while verifying also answers 500.

What you implement#

Identity gives you sid and sub. Mapping them to your own sessions and ending those is yours.

  • Store the ID token's sid with your session when the person signs in, as in the Quickstart. Then onLogout ends every session of yours with token.sid. If sid is null, end every session of token.sub.
  • Make onLogout idempotent. Identity retries after any error and may deliver the same logout more than once.
  • Check for the ended session on every request that matters, since your session cookie is still in the browser.
import { createAuthRoutes } from '@tdacorp/identity-sdk/rp'import { rp } from './relying-party'// A claim store shared by every instance. A Map stands in for your database here.const claimed = new Map<string, number>()export const routes = createAuthRoutes({  rp,  appOrigin: 'https://app.example.com',  cookieSecret: process.env.APP_COOKIE_SECRET ?? '',  onLogin: (login) => new Response(null, { status: 303, headers: { location: login.redirectTo } }),  backchannelLogout: {    // true the first time a jti is seen, false for a replay.    claimJti: (jti, expiresAt) => {      if (claimed.has(jti)) return false      claimed.set(jti, expiresAt)      return true    },    // Required next to a custom claimJti: undo the claim when onLogout throws, so the retry is not taken for a replay.    releaseJti: (jti) => {      claimed.delete(jti)    },    onLogout: (token) => {      // End every session of yours that carries token.sid, or every session of token.sub when sid is null.      void token    },  },})

Replay protection across instances#

The default jti claim store holds up to 10,000 entries in memory, per instance. On a platform where retries can reach another instance, supply your own claimJti, backed by a table shared by every instance. A custom claimJti must come with a matching releaseJti. Without it, a retry after a failed onLogout is refused as a replay, and that logout is lost.

Register the address#

The back-channel logout address is a field of the client registration, set by the administrator who registers or updates the client: see the Quickstart. Identity refuses an address that is not https.

Verified against commit 5d85077