SAML SSO
SAML 2.0 is the dominant SSO protocol for enterprise IdPs (Okta, Azure AD/Entra, OneLogin, Ping, ADFS). The protocol itself — XML signing, assertion parsing, metadata exchange — is complex and out of scope for ideal-auth. This guide shows how to wire a battle-tested SAML library to ideal-auth’s session layer: the library handles the protocol, ideal-auth handles the cookie.
Choosing a SAML library
Section titled “Choosing a SAML library”| Library | Best for | Notes |
|---|---|---|
@boxyhq/saml-jackson | Multi-tenant B2B SaaS | Stores SP configs per-tenant in your DB. Used by Cal.com, Retool. Apache 2.0. |
@node-saml/node-saml | Single-tenant apps | Lower-level. You own the metadata, the routes, the assertion handling. |
samlify | Custom flows | Most flexible, but you own more of the security surface. |
This guide uses @node-saml/node-saml for the single-tenant case and notes the multi-tenant differences in a callout. The integration pattern is identical — only the config lookup changes.
Installation
Section titled “Installation”bun add ideal-auth @node-saml/node-samlDatabase schema
Section titled “Database schema”SAML users are typically created on first login (just-in-time provisioning). You need a way to recognize a returning SAML user via their IdP-issued NameID.
ALTER TABLE users ADD COLUMN saml_subject TEXT UNIQUE;ALTER TABLE users ADD COLUMN saml_idp TEXT; -- which IdP issued the subjectCREATE INDEX idx_users_saml_subject ON users (saml_idp, saml_subject);export const users = pgTable('users', { id: text('id').primaryKey(), email: text('email').notNull(), samlSubject: text('saml_subject').unique(), samlIdp: text('saml_idp'), // ... your other columns});SP configuration
Section titled “SP configuration”Your app is the Service Provider (SP). The IdP needs two URLs from you:
- ACS (Assertion Consumer Service) — where the IdP posts the signed assertion. Example:
https://app.example.com/auth/saml/callback. - Entity ID — a unique identifier for your SP. Example:
https://app.example.com/saml/metadata.
You configure the IdP with these, and the IdP gives you back:
- Entity ID — the IdP’s identifier.
- SSO URL — where you redirect users to start login.
- Signing certificate — used to verify assertion signatures.
import { SAML } from '@node-saml/node-saml';
export const saml = new SAML({ // Your SP issuer: process.env.SAML_SP_ENTITY_ID!, callbackUrl: `${process.env.APP_URL}/auth/saml/callback`,
// Your IdP entryPoint: process.env.SAML_IDP_SSO_URL!, idpIssuer: process.env.SAML_IDP_ENTITY_ID!, idpCert: process.env.SAML_IDP_CERT!.replace(/\\n/g, '\n'),
// Security hardening signatureAlgorithm: 'sha256', digestAlgorithm: 'sha256', wantAssertionsSigned: true, wantAuthnResponseSigned: true, acceptedClockSkewMs: 5_000,
// Identifier format — most IdPs default to emailAddress or persistent identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',});The three routes
Section titled “The three routes”SAML SP-initiated login has three endpoints. Each maps to a small route handler.
1. Login initiation
Section titled “1. Login initiation”User clicks “Sign in with SSO.” You generate a SAMLRequest, optionally bind a RelayState (a redirect target after login), and 302 the user to the IdP.
import { saml } from '@/lib/saml';import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) { const callbackUrl = req.nextUrl.searchParams.get('callbackUrl') ?? '/';
// RelayState is echoed back by the IdP — use it to remember where to send the user const url = await saml.getAuthorizeUrlAsync( callbackUrl, // RelayState (validated on return) req.nextUrl.host, {}, );
return NextResponse.redirect(url);}2. Metadata endpoint
Section titled “2. Metadata endpoint”The IdP fetches this once during setup. It describes your SP — entity ID, ACS URL, optional SP signing cert. Serve it as XML.
import { saml } from '@/lib/saml';
export async function GET() { const xml = saml.generateServiceProviderMetadata(null, null); return new Response(xml, { headers: { 'Content-Type': 'application/xml' }, });}3. ACS callback — where ideal-auth takes over
Section titled “3. ACS callback — where ideal-auth takes over”The IdP POSTs the signed SAMLResponse here. This is where you validate the assertion, look up (or create) the user, and hand off to session.login().
import { saml } from '@/lib/saml';import { auth } from '@/lib/auth';import { db } from '@/lib/db';import { NextRequest, NextResponse } from 'next/server';
const SAFE_REDIRECTS = new Set(['/dashboard', '/settings', '/']);
export async function POST(req: NextRequest) { const form = await req.formData(); const samlResponse = form.get('SAMLResponse') as string; const relayState = form.get('RelayState') as string | null;
let profile; try { const result = await saml.validatePostResponseAsync({ SAMLResponse: samlResponse, }); profile = result.profile; } catch (err) { console.error('SAML validation failed', err); return new Response('Invalid SAML response', { status: 401 }); }
if (!profile?.nameID) { return new Response('Missing NameID', { status: 400 }); }
// Look up or provision (JIT) const samlIdp = process.env.SAML_IDP_ENTITY_ID!; let user = await db.user.findUnique({ where: { samlIdp_samlSubject: { samlIdp, samlSubject: profile.nameID } }, });
if (!user) { user = await db.user.create({ data: { email: (profile.email ?? profile.nameID) as string, name: (profile.displayName ?? profile.cn) as string | undefined, samlSubject: profile.nameID, samlIdp, }, }); }
await auth().login(user);
const redirectTo = relayState && SAFE_REDIRECTS.has(relayState) ? relayState : '/'; return NextResponse.redirect(new URL(redirectTo, process.env.APP_URL!));}The handoff to ideal-auth is one line: await auth().login(user). Everything before it is SAML; everything after it is your normal authenticated app.
Mapping IdP attributes
Section titled “Mapping IdP attributes”The IdP usually sends additional attributes alongside NameID — email, display name, group memberships. Map them at provisioning time and refresh on every login if the IdP is the source of truth:
const attrs = profile.attributes ?? {};
const userData = { email: (attrs['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] ?? profile.email ?? profile.nameID) as string, name: (attrs['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] ?? profile.displayName) as string | undefined, groups: (attrs['http://schemas.xmlsoap.org/claims/Group'] as string[] | undefined) ?? [],};
if (user) { user = await db.user.update({ where: { id: user.id }, data: { email: userData.email, name: userData.name }, });}Single Logout (SLO)
Section titled “Single Logout (SLO)”SLO is optional and many deployments skip it. When the user logs out of your app, you can also notify the IdP — which then notifies every other SP. It’s nice in theory but fiddly in practice (timing, cert rotation, IdP support varies).
If you need it:
import { saml } from '@/lib/saml';import { auth } from '@/lib/auth';import { db } from '@/lib/db';
export async function GET() { const session = auth(); const userId = await session.id(); if (!userId) return new Response('Not logged in', { status: 401 });
const user = await db.user.findUnique({ where: { id: userId } });
// Clear the local session first — if the IdP redirect fails, the user is still logged out locally await session.logout();
if (!user?.samlSubject) { return Response.redirect(new URL('/', process.env.APP_URL!)); }
const url = await saml.getLogoutUrlAsync( { nameID: user.samlSubject, nameIDFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' }, null, {}, ); return Response.redirect(url);}For most apps, plain await session.logout() is enough. SLO is overkill if your app is the only SP the user touches.
Security checklist
Section titled “Security checklist”- Signature verification.
wantAssertionsSigned: trueandwantAuthnResponseSigned: true. Reject unsigned responses outright. - SHA-256, not SHA-1.
signatureAlgorithm: 'sha256'anddigestAlgorithm: 'sha256'. SHA-1 is broken for signatures. - Clock skew. Set
acceptedClockSkewMsto ~5 seconds. The IdP and SP must agree on time within the assertion’sNotBefore/NotOnOrAfterwindow. - Replay protection. The library checks the assertion’s
IDagainst recently-seen IDs. Ensure yournode-samlcache backend (memory by default) is sufficient for your deployment — for multi-instance apps, use a Redis-backed cache. - RelayState allowlist. Treat
RelayStatelike any other open-redirect surface. - Cert rotation. Plan for cert renewal. Many IdPs let you publish two certs simultaneously during rotation — support that on the SP side too.
- Audience restriction. The library enforces
Audiencematches your SP entity ID by default. Don’t disable it. -
InResponseTovalidation. Only meaningful for SP-initiated flows; the library handles it when you usegetAuthorizeUrlAsync.
Testing
Section titled “Testing”samltest.id provides a public test IdP — register your SP metadata there and you can run end-to-end flows without standing up your own IdP. For Okta/Azure dev tenants, the free tiers are sufficient for non-prod testing.
For unit tests, mock the saml.validatePostResponseAsync call and assert that your callback route calls auth().login() with the right user shape. The SAML library’s own tests cover the protocol layer — don’t try to re-test it.
When not to use SAML
Section titled “When not to use SAML”- Your IdP supports OIDC. Use OIDC. It’s simpler, JSON instead of XML, and the libraries are smaller.
- You only need Google/Microsoft personal accounts. Use OAuth (Google/Microsoft consumer OAuth, not their enterprise SAML).
- You want internal team SSO without an IdP. Stand up an OIDC provider like Logto or Zitadel instead of running SAML through a SaaS IdP.
SAML earns its place when an enterprise customer says “we only support SAML.” Until then, OIDC is almost always the better tool.