Back-Channel Logout
Overview
Section titled “Overview”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.
Logout Flow
Section titled “Logout Flow”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
BackChannelLogoutValidator
Section titled “BackChannelLogoutValidator”Initialization
Section titled “Initialization”import { BackChannelLogoutValidator } from '@authrim/server';
const validator = new BackChannelLogoutValidator();validate()
Section titled “validate()”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);}extractClaims()
Section titled “extractClaims()”Extracts the JWT payload without full validation. Useful for logging or debugging:
const claims = validator.extractClaims(logoutToken);// claims: LogoutTokenClaims | undefinedextractHeader()
Section titled “extractHeader()”Extracts the JWT header to determine the signing algorithm and key ID:
const header = validator.extractHeader(logoutToken);// header: { alg: string; kid?: string; typ?: string }Validation Options
Section titled “Validation Options”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;}Logout Token Structure
Section titled “Logout Token Structure”A Back-Channel Logout Token is a JWT containing these claims:
| Claim | Type | Required | Description |
|---|---|---|---|
iss | string | Yes | Issuer identifier |
sub | string | Conditional | Subject (user) identifier. Required if sid is absent |
aud | string | string[] | Yes | Audience — your resource server |
iat | number | Yes | Issued-at timestamp |
jti | string | Yes | Unique token identifier (for replay protection) |
exp | number | Yes | Expiration timestamp |
events | object | Yes | Must contain the back-channel logout event URI |
sid | string | Conditional | Session ID. Required if sub is absent |
The events Claim
Section titled “The events Claim”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": {} }Validation Rules
Section titled “Validation Rules”The validator enforces the following rules per the OIDC Back-Channel Logout spec:
issmust matchissueraudmust includeaudienceeventsmust containBACKCHANNEL_LOGOUT_EVENTsuborsid— at least one must be presentjtimust be presentexpmust be present and not expirednoncemust NOT be present (rejects tokens with a nonce claim)
Application Responsibilities
Section titled “Application Responsibilities”Session Invalidation Patterns
Section titled “Session Invalidation Patterns”By Session ID (sid)
Section titled “By Session ID (sid)”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);}By Subject (sub)
Section titled “By Subject (sub)”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)), );}Combined Strategy
Section titled “Combined Strategy”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)), ); }}Complete Example: Express
Section titled “Complete Example: Express”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();});Complete Example: Hono
Section titled “Complete Example: Hono”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;Next Steps
Section titled “Next Steps”- Error Handling — Handle validation errors
- Configuration Reference — Provider system and runtime options
- Express & Fastify Adapters — Framework integration
- Hono, Koa & NestJS Adapters — Additional framework adapters