OIDC logout: RP-initiated, front-channel, and back-channel — and why they all behave differently

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('');
});
Front-channel logout is unreliable. Browsers increasingly block third-party cookies in iframes (Safari ITP, Chrome Privacy Sandbox), which means the logout request from the iframe cannot access the user's session cookie for your app's domain. The OP's iframe request will succeed (200 OK) but the local session will not actually be cleared because the browser does not send the cookie. Do not rely on front-channel logout as your sole logout propagation mechanism.

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.

← Back to blog Try Bastionary free →