Skip to content

Back-Channel Logout

OIDC Back-Channel Logout 1.0 allows an Authorization Server to notify your Resource Server directly when a user logs out. Instead of relying on the user’s browser to propagate logout, the Authorization Server sends a signed Logout Token (JWT) to your backend endpoint.

@authrim/server provides the BackChannelLogoutValidator class to parse and validate these tokens.

sequenceDiagram
    participant User
    participant AS as Authorization Server
    participant RS as Your Resource Server
    participant DB as Session Store

    User->>AS: Logout request
    AS->>RS: POST /backchannel-logout<br>(logout_token JWT)
    RS->>RS: Verify JWT signature<br>(your responsibility)
    RS->>RS: BackChannelLogoutValidator<br>.validate(logoutToken)
    RS->>RS: Check jti replay<br>(your responsibility)
    RS->>DB: Invalidate session(s)
    RS-->>AS: 200 OK
    AS-->>User: Logout complete
import { BackChannelLogoutValidator } from '@authrim/server';
const validator = new BackChannelLogoutValidator();

Validates a logout token and returns a structured result:

import { BackChannelLogoutValidator } from '@authrim/server';
import type {
BackChannelLogoutValidationOptions,
BackChannelLogoutValidationResult,
} from '@authrim/server';
const validator = new BackChannelLogoutValidator();
const options: BackChannelLogoutValidationOptions = {
issuer: 'https://auth.example.com',
audience: 'my-resource-server',
maxAge: 60,
clockSkew: 30,
};
const result: BackChannelLogoutValidationResult = validator.validate(
logoutToken,
options,
);
if (result.valid) {
// result.claims contains LogoutTokenClaims
console.log('Subject:', result.claims.sub);
console.log('Session ID:', result.claims.sid);
} else {
console.error('Validation failed:', result.error);
}

Extracts the JWT payload without full validation. Useful for logging or debugging:

const claims = validator.extractClaims(logoutToken);
// claims: LogoutTokenClaims | undefined

Extracts the JWT header to determine the signing algorithm and key ID:

const header = validator.extractHeader(logoutToken);
// header: { alg: string; kid?: string; typ?: string }
interface BackChannelLogoutValidationOptions {
/** Expected issuer (iss claim). Must match exactly. */
issuer: string;
/** Expected audience (aud claim). Token must include this value. */
audience: string;
/** Maximum token age in seconds based on iat. Default: 60 */
maxAge?: number;
/** Clock skew tolerance in seconds for iat/exp checks. Default: 30 */
clockSkew?: number;
/** Expected subject, when binding logout to a known subject. */
expectedSub?: string;
/** Expected session id, when binding logout to a known session. */
expectedSid?: string;
}

A Back-Channel Logout Token is a JWT containing these claims:

ClaimTypeRequiredDescription
issstringYesIssuer identifier
substringConditionalSubject (user) identifier. Required if sid is absent
audstring | string[]YesAudience — your resource server
iatnumberYesIssued-at timestamp
jtistringYesUnique token identifier (for replay protection)
expnumberYesExpiration timestamp
eventsobjectYesMust contain the back-channel logout event URI
sidstringConditionalSession ID. Required if sub is absent

The events claim must contain the OIDC Back-Channel Logout event:

import { BACKCHANNEL_LOGOUT_EVENT } from '@authrim/server';
// BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout'
// Example events claim in a logout token:
// { "http://schemas.openid.net/event/backchannel-logout": {} }

The validator enforces the following rules per the OIDC Back-Channel Logout spec:

  1. iss must match issuer
  2. aud must include audience
  3. events must contain BACKCHANNEL_LOGOUT_EVENT
  4. sub or sid — at least one must be present
  5. jti must be present
  6. exp must be present and not expired
  7. nonce must NOT be present (rejects tokens with a nonce claim)

When the logout token includes a sid claim, invalidate only that specific session:

async function invalidateBySessionId(
sessionStore: SessionStore,
sid: string,
): Promise<void> {
await sessionStore.deleteSession(sid);
}

When the logout token includes a sub claim (and no sid), invalidate all sessions for that user:

async function invalidateBySubject(
sessionStore: SessionStore,
sub: string,
): Promise<void> {
const sessions = await sessionStore.findSessionsByUser(sub);
await Promise.all(
sessions.map((session) => sessionStore.deleteSession(session.id)),
);
}

Handle both cases for maximum compatibility:

import type { LogoutTokenClaims } from '@authrim/server';
async function invalidateSessions(
sessionStore: SessionStore,
claims: LogoutTokenClaims,
): Promise<void> {
if (claims.sid) {
// Prefer session-specific logout
await sessionStore.deleteSession(claims.sid);
} else if (claims.sub) {
// Fall back to user-wide logout
const sessions = await sessionStore.findSessionsByUser(claims.sub);
await Promise.all(
sessions.map((s) => sessionStore.deleteSession(s.id)),
);
}
}
import express from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { BackChannelLogoutValidator, BACKCHANNEL_LOGOUT_EVENT } from '@authrim/server';
const app = express();
app.use(express.urlencoded({ extended: false }));
const validator = new BackChannelLogoutValidator();
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
);
app.post('/backchannel-logout', async (req, res) => {
const logoutToken = req.body.logout_token;
if (!logoutToken) {
return res.status(400).json({ error: 'missing logout_token' });
}
// Step 1: Verify JWT signature
try {
await jwtVerify(logoutToken, JWKS, {
issuer: 'https://auth.example.com',
audience: 'my-resource-server',
});
} catch {
return res.status(400).json({ error: 'invalid signature' });
}
// Step 2: Validate token structure and claims
const result = validator.validate(logoutToken, {
issuer: 'https://auth.example.com',
audience: 'my-resource-server',
maxAge: 60,
clockSkew: 30,
});
if (!result.valid) {
return res.status(400).json({ error: result.error });
}
// Step 3: Check jti replay
const isNew = await checkAndStoreJti(redis, result.claims.jti);
if (!isNew) {
return res.status(400).json({ error: 'replayed token' });
}
// Step 4: Invalidate sessions
await invalidateSessions(sessionStore, result.claims);
return res.status(200).send();
});
import { Hono } from 'hono';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { BackChannelLogoutValidator } from '@authrim/server';
const app = new Hono();
const validator = new BackChannelLogoutValidator();
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
);
app.post('/backchannel-logout', async (c) => {
const body = await c.req.parseBody();
const logoutToken = body['logout_token'] as string;
if (!logoutToken) {
return c.json({ error: 'missing logout_token' }, 400);
}
// Verify JWT signature
try {
await jwtVerify(logoutToken, JWKS, {
issuer: 'https://auth.example.com',
audience: 'my-resource-server',
});
} catch {
return c.json({ error: 'invalid signature' }, 400);
}
// Validate token structure and claims
const result = validator.validate(logoutToken, {
issuer: 'https://auth.example.com',
audience: 'my-resource-server',
maxAge: 60,
clockSkew: 30,
});
if (!result.valid) {
return c.json({ error: result.error }, 400);
}
// Check jti replay
const isNew = await checkAndStoreJti(redis, result.claims.jti);
if (!isNew) {
return c.json({ error: 'replayed token' }, 400);
}
// Invalidate sessions
await invalidateSessions(sessionStore, result.claims);
return c.body(null, 200);
});
export default app;