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.
| Claim | Value |
|---|---|
header typ | logout+jwt |
iss | The identity issuer. |
aud | Your client id. |
sub | The 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. |
sid | The identity session that ended. It is the sid of the ID token you received at sign-in. |
jti | A unique id for this token. |
iat, exp | exp 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
2xxanswer 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:
- Requires
Content-Type: application/x-www-form-urlencoded, a body of at most 8192 bytes and exactly one non-emptylogout_token. Otherwise it answers 400. - Verifies the token: signature against the issuer's keys,
typlogout+jwt,iss,aud, the back-channel logouteventsmember, nononce,iat,exp, akid, and at least one ofsubandsid. A token that fails answers 400. - Claims the
jti. Ajtithat was already claimed is refused as a replay and answers 400. - Calls your
onLogout(token)withsub,sidandjti. - Answers 200 with
Cache-Control: no-store. IfonLogoutthrows, it answers 500 so identity retries, and it releases thejtifirst 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
sidwith your session when the person signs in, as in the Quickstart. ThenonLogoutends every session of yours withtoken.sid. Ifsidisnull, end every session oftoken.sub. - Make
onLogoutidempotent. 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.