Skip to content

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 | undefined

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

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:

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

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:

  1. iss must match expectedIssuer
  2. aud must include expectedAudience
  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)

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