Skip to content

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.


LibraryBest forNotes
@boxyhq/saml-jacksonMulti-tenant B2B SaaSStores SP configs per-tenant in your DB. Used by Cal.com, Retool. Apache 2.0.
@node-saml/node-samlSingle-tenant appsLower-level. You own the metadata, the routes, the assertion handling.
samlifyCustom flowsMost 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.


Terminal window
bun add ideal-auth @node-saml/node-saml

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 subject
CREATE INDEX idx_users_saml_subject ON users (saml_idp, saml_subject);

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.
lib/saml.ts
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',
});

SAML SP-initiated login has three endpoints. Each maps to a small route handler.

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.

app/auth/saml/login/route.ts
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);
}

The IdP fetches this once during setup. It describes your SP — entity ID, ACS URL, optional SP signing cert. Serve it as XML.

app/auth/saml/metadata/route.ts
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().

app/auth/saml/callback/route.ts
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.


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 },
});
}

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:

app/auth/saml/logout/route.ts
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.


  • Signature verification. wantAssertionsSigned: true and wantAuthnResponseSigned: true. Reject unsigned responses outright.
  • SHA-256, not SHA-1. signatureAlgorithm: 'sha256' and digestAlgorithm: 'sha256'. SHA-1 is broken for signatures.
  • Clock skew. Set acceptedClockSkewMs to ~5 seconds. The IdP and SP must agree on time within the assertion’s NotBefore / NotOnOrAfter window.
  • Replay protection. The library checks the assertion’s ID against recently-seen IDs. Ensure your node-saml cache backend (memory by default) is sufficient for your deployment — for multi-instance apps, use a Redis-backed cache.
  • RelayState allowlist. Treat RelayState like 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 Audience matches your SP entity ID by default. Don’t disable it.
  • InResponseTo validation. Only meaningful for SP-initiated flows; the library handles it when you use getAuthorizeUrlAsync.

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.


  • 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.