Back-Channel Logout
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
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
(logout_token JWT)
RS->>RS: BackChannelLogoutValidator
.validate(logoutToken)
RS->>RS: Verify JWT signature
(your responsibility)
RS->>RS: Check jti replay
(your responsibility)
RS->>DB: Invalidate session(s)
RS-->>AS: 200 OK
AS-->>User: Logout complete
BackChannelLogoutValidator
Initialization
import { BackChannelLogoutValidator } from '@authrim/server';
const validator = new BackChannelLogoutValidator();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 = { expectedIssuer: 'https://auth.example.com', expectedAudience: 'my-resource-server', clockToleranceSeconds: 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()
Extracts the JWT payload without full validation. Useful for logging or debugging:
const claims = validator.extractClaims(logoutToken);// claims: LogoutTokenClaims | undefinedextractHeader()
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
interface BackChannelLogoutValidationOptions { /** Expected issuer (iss claim). Must match exactly. */ expectedIssuer: string;
/** Expected audience (aud claim). Token must include this value. */ expectedAudience: string;
/** Clock tolerance in seconds for iat/exp checks. Default: 0 */ clockToleranceSeconds?: number;}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
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
The validator enforces the following rules per the OIDC Back-Channel Logout spec:
issmust matchexpectedIssueraudmust includeexpectedAudienceeventsmust 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
Session Invalidation Patterns
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)
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
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
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, { expectedIssuer: 'https://auth.example.com', expectedAudience: 'my-resource-server', clockToleranceSeconds: 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
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, { expectedIssuer: 'https://auth.example.com', expectedAudience: 'my-resource-server', clockToleranceSeconds: 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
- Error Handling — Handle validation errors
- Configuration Reference — Provider system and runtime options
- Express & Fastify Adapters — Framework integration
- Hono, Koa & NestJS Adapters — Additional framework adapters