SAML Single Logout (SLO) is the feature that promises: when a user logs out of any application in their SSO federation, they're logged out of all of them simultaneously. In theory, it's a critical security feature — especially for enterprise users on shared workstations. In practice, it is one of the most frequently broken, most commonly disabled, and most often misunderstood features in the entire identity ecosystem. This post explains why, and what to actually implement.
What SLO is supposed to do
Consider a user logged into three apps via their company's Okta SSO: Slack, Salesforce, and your SaaS. When they click "Sign out" in Slack, SLO should:
- Slack sends a
LogoutRequestto the IdP (Okta) - The IdP terminates the central SSO session
- The IdP sends
LogoutRequestmessages to all other SPs that have active sessions for that user (Salesforce, your app) - Each SP terminates its local session and sends back a
LogoutResponse - The user is fully logged out everywhere
This requires every SP in the federation to implement the SLO responder endpoint correctly. If any single SP doesn't respond, the flow can hang indefinitely.
Front-channel vs back-channel SLO
Front-channel SLO (HTTP Redirect / POST binding) — logout requests travel through the user's browser. The IdP redirects the browser to each SP's SLO endpoint with the logout request in the URL or POST body. Each SP responds, the IdP redirects to the next SP, and so on. This is a sequential, browser-mediated chain.
Problems with front-channel:
- The user's browser must be open and connected during the entire chain
- Each redirect takes hundreds of milliseconds — with 10 SPs, logout takes 3–5 seconds
- If any SP has a slow endpoint, the whole chain blocks
- If the user closes the browser mid-chain, some SPs won't be notified
- Cross-origin cookie restrictions (
SameSite) can prevent SPs from reading their cookies during the IdP-initiated redirect chain
Back-channel SLO (SOAP binding) — the IdP sends logout requests directly to each SP's endpoint over HTTPS, server-to-server. No browser involvement. This is more reliable but requires SPs to have publicly accessible SOAP endpoints, which many don't expose in cloud environments.
<!-- Example LogoutRequest from IdP to SP -->
<samlp:LogoutRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
ID="_abc123"
Version="2.0"
IssueInstant="2025-01-13T10:30:00Z"
Destination="https://yourapp.com/saml/slo">
<saml:Issuer>https://idp.okta.com</saml:Issuer>
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
user@company.com
</saml:NameID>
<samlp:SessionIndex>_session-index-from-original-authn</samlp:SessionIndex>
</samlp:LogoutRequest>
Session index tracking
The SessionIndex in the LogoutRequest is the IdP's way of telling you which session to terminate. Your SP must store the session index from the original authentication assertion and map it to your local session:
// When processing a SAML AuthnResponse at login:
async function createSsoSession(samlAssertion: SAMLAssertion, req: Request, db: DB): Promise<string> {
const sessionId = generateSecureToken(32);
await db.sessions.create({
id: sessionId,
userId: samlAssertion.nameId,
samlSessionIndex: samlAssertion.sessionIndex, // CRITICAL: store this
samlNameId: samlAssertion.nameId,
samlNameIdFormat: samlAssertion.nameIdFormat,
idpEntityId: samlAssertion.issuer,
createdAt: new Date(),
});
return sessionId;
}
// When receiving a LogoutRequest from the IdP:
async function handleIdpInitiatedLogout(
logoutRequest: SAMLLogoutRequest,
db: DB
): Promise<string> {
const sessions = await db.sessions.findBySessionIndex(
logoutRequest.sessionIndex,
logoutRequest.nameId
);
for (const session of sessions) {
await db.sessions.revoke(session.id, 'idp_slo');
}
// Build and sign a LogoutResponse
return buildLogoutResponse(logoutRequest.id, 'Success');
}
Why most IdPs get it wrong
Real-world SLO is plagued by:
- Okta — supports front-channel SLO but the implementation is known to break with
SameSite=Strictcookies, because the browser-redirected logout request comes from the IdP's domain, not yours. - Azure AD (Entra) — supports both front- and back-channel, but the SOAP endpoint for back-channel is deprecated and replaced with a proprietary "front-channel logout URI" mechanism that doesn't follow the SAML spec.
- Google Workspace — SP-initiated SLO works; IdP-initiated SLO is documented but inconsistently implemented.
- Many corporate IdPs — ship SLO enabled by default but with no validation, so SPs that don't respond properly are silently skipped rather than flagged as errors.
The practical compromise
Given the reliability problems, here's what actually works for enterprise SaaS:
- Implement SP-initiated logout properly. When the user clicks Sign Out in your app, redirect them to the IdP's SLO endpoint with a signed
LogoutRequest. This is the case that actually matters for most security requirements. - Implement the IdP-initiated SLO receiver endpoint. Accept
LogoutRequestfrom the IdP, terminate local sessions, and return aLogoutResponse. Keep it simple — don't time out, don't block on downstream calls. - Use short session TTLs as the safety net. If SLO fails silently, sessions with 8–24 hour TTLs limit the exposure window. This is the most reliable defense against SLO failures.
- Document your SLO behavior. Tell enterprise customers which bindings you support, what your SLO endpoint URL is, and what limitations exist. Customers who care about SLO (compliance teams) will test it during their security review — being upfront prevents nasty surprises.
// SP-initiated logout: redirect user to IdP SLO
function buildSpInitiatedLogout(session: SsoSession, idpSloUrl: string): string {
const logoutRequest = buildLogoutRequest({
id: `_${crypto.randomUUID()}`,
issueInstant: new Date().toISOString(),
destination: idpSloUrl,
issuer: SP_ENTITY_ID,
nameId: session.samlNameId,
nameIdFormat: session.samlNameIdFormat,
sessionIndex: session.samlSessionIndex,
});
const signed = signSamlMessage(logoutRequest, SP_PRIVATE_KEY);
const deflated = deflateRaw(signed);
const encoded = deflated.toString('base64');
const params = new URLSearchParams({ SAMLRequest: encoded });
return `${idpSloUrl}?${params}`;
}