Skip to content

Federated Logout

Log users out across multiple domains and OAuth providers using ideal-auth.

Updated View as Markdown

When your application authenticates through a central identity provider (OAuth, OIDC, or a custom login service), logging out requires more than just clearing the local session cookie. You need to clear sessions on every domain that has one and, optionally, revoke the session at the identity provider itself.


The problem

auth().logout() clears the session cookie on the current domain only. It cannot:

  • Clear cookies on other domains (browsers don’t allow cross-domain cookie deletion)
  • Revoke the session at an OAuth/OIDC provider
  • Notify other applications that the user logged out

Without federated logout, a user who logs out of tenant-a.com is still logged into tenant-b.com and the identity provider. If they visit tenant-b.com, they appear authenticated. If tenant-b.com redirects them to the identity provider for re-auth, the provider still has a valid session and silently redirects them back — the user can never actually log out.


Logout flow

The solution is a redirect chain that visits each domain and clears its session:

User clicks "Sign out" on tenant-a.com
        │
        ▼
  1. auth().logout()        ← clears tenant-a.com session cookie
  2. Redirect to central    ← /logout?callbackUrl=https://tenant-a.com
        │
        ▼
  Central app
  3. auth().logout()        ← clears central app session cookie
  4. Redirect to provider   ← OIDC end_session_endpoint (if applicable)
        │
        ▼
  Identity provider
  5. Clears provider session
  6. Redirect to post_logout_redirect_uri
        │
        ▼
  7. Redirect back to tenant-a.com (or landing page)

Not every step is required. If you don’t use an external OIDC provider, skip steps 4-6. If you only have one domain, auth().logout() is sufficient.


Implementation

Step 1: Tenant logout action

Clear the local session cookie, then redirect to the central app’s logout endpoint.

app/actions/logout.tsts
'use server';

import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';

export async function logoutAction() {
  const session = auth();

  // If using sessionFields with accessToken, grab it before logout
  const user = await session.user();
  const idToken = user?.idToken;

  await session.logout();

  // Redirect to central logout with callback to return here after
  const logoutUrl = new URL(`${process.env.CENTRAL_LOGIN_URL}/logout`);
  logoutUrl.searchParams.set('callbackUrl', process.env.NEXT_PUBLIC_APP_URL!);

  // Pass the id_token_hint if available (needed for OIDC provider logout)
  if (idToken) {
    logoutUrl.searchParams.set('id_token_hint', idToken);
  }

  redirect(logoutUrl.toString());
}
src/routes/logout.tsts
import { Router } from 'express';
import { auth } from '../lib/auth';

const router = Router();

router.post('/logout', async (req, res) => {
  const session = auth(req, res);
  const user = await session.user();
  const idToken = user?.idToken;

  await session.logout();

  const logoutUrl = new URL(`${process.env.CENTRAL_LOGIN_URL}/logout`);
  logoutUrl.searchParams.set('callbackUrl', `${req.protocol}://${req.get('host')}`);

  if (idToken) {
    logoutUrl.searchParams.set('id_token_hint', idToken);
  }

  res.redirect(logoutUrl.toString());
});

export default router;
src/routes/logout/+page.server.tsts
import { redirect } from '@sveltejs/kit';
import { auth } from '$lib/server/auth';
import { CENTRAL_LOGIN_URL } from '$env/static/private';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ cookies, url }) => {
    const session = auth(cookies);
    const user = await session.user();
    const idToken = user?.idToken;

    await session.logout();

    const logoutUrl = new URL(`${CENTRAL_LOGIN_URL}/logout`);
    logoutUrl.searchParams.set('callbackUrl', url.origin);

    if (idToken) {
      logoutUrl.searchParams.set('id_token_hint', idToken);
    }

    redirect(303, logoutUrl.toString());
  },
};

Step 2: Central app logout endpoint

The central app clears its own session and optionally initiates OIDC provider logout.

Central app: app/api/logout/route.tsts
import { auth } from '@/lib/auth';
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const callbackUrl = request.nextUrl.searchParams.get('callbackUrl');
  const idTokenHint = request.nextUrl.searchParams.get('id_token_hint');

  // Clear the central app's session
  const session = auth();
  await session.logout();

  // Store the final callback URL for after the provider redirects back
  const cookieStore = await cookies();
  if (callbackUrl) {
    cookieStore.set('logout_callback', callbackUrl, {
      httpOnly: true,
      maxAge: 300, // 5 minutes
      path: '/',
    });
  }

  // Redirect to the OIDC provider's logout endpoint
  const params = new URLSearchParams({
    post_logout_redirect_uri: `${process.env.AUTH_URL}/api/logout/callback`,
  });

  if (idTokenHint) {
    params.set('id_token_hint', idTokenHint);
  }

  const providerLogoutUrl = `${process.env.OIDC_ISSUER_URL}/connect/logout?${params}`;
  return NextResponse.redirect(providerLogoutUrl);
}
Central app: app/api/logout/callback/route.tsts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const cookieStore = await cookies();
  const callbackUrl = cookieStore.get('logout_callback')?.value
    ?? process.env.NEXT_PUBLIC_APP_URL!;

  cookieStore.delete('logout_callback');

  return NextResponse.redirect(callbackUrl);
}
Central app: app/api/logout/route.tsts
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export async function GET(request: NextRequest) {
  const callbackUrl = request.nextUrl.searchParams.get('callbackUrl')
    ?? process.env.NEXT_PUBLIC_APP_URL!;

  // Clear the central app's session
  const session = auth();
  await session.logout();

  // Redirect back to the caller
  return NextResponse.redirect(callbackUrl);
}

Step 3: Logout button

components/logout-button.tsxtsx
'use client';

import { logoutAction } from '@/app/actions/logout';

export function LogoutButton() {
  return (
    <form action={logoutAction}>
      <button type="submit">Sign out</button>
    </form>
  );
}

Storing idToken for OIDC logout

Many OIDC providers require an id_token_hint parameter when calling the logout endpoint. If you use sessionFields, include the ID token:

sessionFields: ['email', 'name', 'accessToken', 'refreshToken', 'idToken', 'expiresAt'],

If you use resolveUser, store the ID token in your database and retrieve it during logout.


Revoking tokens server-side

Clearing cookies only prevents the browser from sending the tokens. The tokens themselves remain valid until they expire. For high-security applications, revoke them at the provider before logging out:

// Before calling auth().logout()
async function revokeTokens(accessToken: string, refreshToken: string) {
  // Revoke the access token
  await fetch(process.env.OAUTH_REVOCATION_URL!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      token: accessToken,
      token_type_hint: 'access_token',
      client_id: process.env.OAUTH_CLIENT_ID!,
      client_secret: process.env.OAUTH_CLIENT_SECRET!,
    }),
  });

  // Revoke the refresh token
  await fetch(process.env.OAUTH_REVOCATION_URL!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      token: refreshToken,
      token_type_hint: 'refresh_token',
      client_id: process.env.OAUTH_CLIENT_ID!,
      client_secret: process.env.OAUTH_CLIENT_SECRET!,
    }),
  });
}

Stateless session limitations

ideal-auth uses stateless encrypted cookies. This means:

  • No server-side session store. There is no central list of active sessions to invalidate.
  • Each domain must clear its own cookie. The redirect chain ensures every domain is visited.
  • Orphaned sessions. If a tenant domain is skipped in the logout chain (e.g., the user closes the browser mid-redirect), that session remains valid until it expires.

If you need instant, guaranteed revocation across all domains, add a server-side session store:

  1. Add a sessions table with session_id and revoked_at columns
  2. Store a session_id in the cookie (via sessionFields)
  3. On every request, check if the session has been revoked
  4. On logout, mark the session as revoked in the database

See the Session Invalidation guide for details.


Security considerations

  • Validate callbackUrl on the central logout endpoint. Apply the same tenant domain allowlist used for login redirects. Without this, an attacker can craft a logout URL that redirects to a malicious site after logout.
  • Use id_token_hint for OIDC logout. Without it, some providers will show a confirmation page instead of logging out silently.
  • Logout cookies should be short-lived. The logout_callback cookie used to preserve the return URL should expire quickly (5 minutes) and be httpOnly.
  • POST for tenant logout, GET for redirects. The initial logout action should be a POST (CSRF-safe via form submission). The redirect chain uses GET because each hop is a browser redirect.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close