Logout in OIDC is significantly more complex than login, and it is frequently implemented incompletely. Clicking "Sign out" in your app clears the local session — but the user may still have an active session at the OpenID Provider and at every other Relying Party that shared that SSO session. Complete logout means terminating the session everywhere. The three logout mechanisms in the OIDC specification handle different parts of this problem, and each has distinct behavioral characteristics.
RP-initiated logout
RP-initiated logout is the simplest form. The Relying Party (your application) redirects the user's browser to the OpenID Provider's end_session_endpoint, which terminates the session at the OP. The OP then redirects the user back to your application.
// Build the logout URL and redirect the user
function buildLogoutUrl(idToken: string, postLogoutRedirectUri: string): string {
const opConfig = await getOpenIDConfiguration();
const url = new URL(opConfig.end_session_endpoint);
// id_token_hint helps the OP identify which session to terminate
// and confirms to the OP that the RP calling logout was the one that received this token
url.searchParams.set('id_token_hint', idToken);
// Where to redirect after logout (must be pre-registered with the OP)
url.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
// Optional: the OP may verify this matches the sub in id_token_hint
url.searchParams.set('logout_hint', userEmail);
// State parameter to verify the return redirect is from the expected OP
const state = crypto.randomUUID();
sessionStorage.setItem('logout_state', state);
url.searchParams.set('state', state);
return url.toString();
}
// In the logout handler
app.post('/auth/logout', async (req, res) => {
const idToken = req.session.idToken;
// Clear local session immediately
await invalidateSession(req.session.id);
req.session.destroy();
if (idToken) {
const logoutUrl = buildLogoutUrl(idToken, 'https://app.example.com/logged-out');
return res.redirect(logoutUrl);
}
res.redirect('/logged-out');
});
The behavioral difference from other mechanisms: RP-initiated logout requires a browser redirect. If the user has JavaScript disabled, is in a background tab, or if the request comes from an API client rather than a browser, this mechanism does not work. It only clears the session at the OP — it does not notify other RPs.
Front-channel logout
When a user logs out from one RP, the OP may want to notify other RPs that share the same SSO session to also clear their local sessions. Front-channel logout achieves this by loading logout URLs from each registered RP in hidden iframes within the OP's browser context. Each iframe sends a request to the RP's frontchannel_logout_uri, which clears the local session.
// Register your frontchannel_logout_uri during client registration
// or in your OP's admin console:
// frontchannel_logout_uri: https://app.example.com/auth/frontchannel-logout
// frontchannel_logout_session_required: true
// Handle front-channel logout request
// This endpoint is called by the OP loading your URL in an iframe
app.get('/auth/frontchannel-logout', async (req, res) => {
const { iss, sid } = req.query;
// Validate issuer
if (iss !== EXPECTED_ISSUER) {
return res.status(400).send('Invalid issuer');
}
if (sid) {
// Find all sessions associated with this SSO session ID
const sessions = await db.sessions.findMany({ ssoSessionId: sid });
for (const session of sessions) {
await invalidateSession(session.id);
}
}
// Return 200 with no content — the OP checks that the iframe loaded successfully
res.status(200).send('');
});
Back-channel logout
Back-channel logout solves the iframe reliability problem by making a direct server-to-server call from the OP to the RP's backchannel_logout_uri. No browser is involved. The OP sends a signed Logout Token (a JWT with specific claims including the session ID and the user's sub) to each registered RP.
// Register: backchannel_logout_uri: https://app.example.com/auth/backchannel-logout
// Handle back-channel logout token from the OP
app.post('/auth/backchannel-logout', async (req, res) => {
const { logout_token } = req.body;
if (!logout_token) {
return res.status(400).json({ error: 'missing_logout_token' });
}
let tokenClaims;
try {
// Verify the logout token signature using the OP's JWKS
const { payload } = await jwtVerify(logout_token, JWKS, {
issuer: EXPECTED_ISSUER,
audience: CLIENT_ID,
});
tokenClaims = payload;
} catch (err) {
return res.status(400).json({ error: 'invalid_logout_token' });
}
// Validate required logout token claims (per OIDC Back-Channel Logout spec)
if (!tokenClaims.events?.['http://schemas.openid.net/event/backchannel-logout']) {
return res.status(400).json({ error: 'not_a_logout_token' });
}
if (tokenClaims.nonce) {
// Logout tokens MUST NOT have a nonce claim
return res.status(400).json({ error: 'nonce_in_logout_token' });
}
// Find and invalidate sessions by sub and/or sid
if (tokenClaims.sid) {
await db.sessions.deleteMany({ ssoSessionId: tokenClaims.sid });
} else if (tokenClaims.sub) {
// No sid — log out all sessions for this user at this OP
await db.sessions.deleteMany({ userId: tokenClaims.sub, opIssuer: tokenClaims.iss });
}
// Respond 200 — OP may retry if it receives a non-200
res.status(200).send('');
});
Choosing the right mechanism
The three mechanisms are not mutually exclusive. A complete implementation uses all three:
- RP-initiated logout: the primary mechanism when the user clicks "Sign out" in your app. Clears the OP session and redirects the user back.
- Front-channel logout: a best-effort mechanism for clearing other RPs. Implement it because it works in modern browsers without third-party cookie blocking, and it is zero-cost to support.
- Back-channel logout: the reliable mechanism for propagating logouts initiated elsewhere — if the user signs out from Okta's dashboard, the back-channel call ensures your app also clears the session even though you never initiated the logout.
If you implement only RP-initiated logout, logging out from Okta's admin portal will not clear sessions in your app. If you implement only back-channel logout, clicking "Sign out" in your app clears the local session but leaves the OP session active (the user can log back in without re-entering credentials). The correct answer is to implement all three and understand what each one covers.