Federated Logout
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
Section titled “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
Section titled “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
Section titled “Implementation”Step 1: Tenant logout action
Section titled “Step 1: Tenant logout action”Clear the local session cookie, then redirect to the central app’s logout endpoint.
'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());}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;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
Section titled “Step 2: Central app logout endpoint”The central app clears its own session and optionally initiates OIDC provider logout.
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);}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);}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
Section titled “Step 3: Logout button”'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
Section titled “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
Section titled “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
Section titled “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:
- Add a
sessionstable withsession_idandrevoked_atcolumns - Store a
session_idin the cookie (viasessionFields) - On every request, check if the session has been revoked
- On logout, mark the session as revoked in the database
See the Session Invalidation guide for details.
Security considerations
Section titled “Security considerations”- Validate
callbackUrlon 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_hintfor OIDC logout. Without it, some providers will show a confirmation page instead of logging out silently. - Logout cookies should be short-lived. The
logout_callbackcookie used to preserve the return URL should expire quickly (5 minutes) and behttpOnly. - 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.