SAML is wordy. Building a Service Provider by hand involves XML, certificates, signature verification, and a fair bit of state. Passport's SAML strategy hides most of it. This tutorial builds a small Express app that accepts SAML SSO from an IntelliAuth tenant — useful as a reference implementation, a testbed for tenant SAML connections, or a starting point for a real product.
The end state: a user signs in to the IntelliAuth tenant via the regular flow; from IntelliAuth's tenant admin you trigger a SAML connection to this Express app; the user clicks the Cymmetri tile in IntelliAuth's app catalogue (or wherever the IdP-initiated trigger lives) and lands signed in inside the Express app.
In a normal production deployment, the IntelliAuth tenant is the Service Provider and an external IdP (Okta, Entra) is the Identity Provider. In this tutorial we flip — IntelliAuth is acting as IdP, the Express app is the SP. Useful for testing.
- Node 18+ and pnpm
- An IntelliAuth tenant with SAML connections allowed
- 30 minutes
Scaffold
Section titled “Scaffold”mkdir saml-sp && cd saml-sppnpm init -ypnpm add express express-session passport @node-saml/passport-saml dotenvpnpm add -D typescript @types/express @types/express-session @types/passport @types/node ts-nodeCreate tsconfig.json:
{ "compilerOptions": { "target": "es2022", "module": "node16", "moduleResolution": "node16", "strict": true, "esModuleInterop": true, "outDir": "dist" }}Set up the IntelliAuth side
Section titled “Set up the IntelliAuth side”In the tenant admin console:
- Authentication → Apps as IdP → New SAML app.
- Configure:
- App name — "Local SAML SP test".
- ACS URL —
http://localhost:3000/saml/acs. - Entity ID —
http://localhost:3000. - NameID format — Email.
- Save. Download the IntelliAuth metadata XML (or note the URL).
You'll use the metadata in the Passport config below. The platform side now believes there's a downstream SP at http://localhost:3000.
The Express app
Section titled “The Express app”Create src/index.ts:
import express from 'express'import session from 'express-session'import passport from 'passport'import { Strategy as SamlStrategy } from '@node-saml/passport-saml'import 'dotenv/config'
const app = express()
app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: false, sameSite: 'lax' },}))
app.use(passport.initialize())app.use(passport.session())
const samlStrategy = new SamlStrategy( { callbackUrl: 'http://localhost:3000/saml/acs', entryPoint: process.env.SAML_IDP_SSO_URL!, issuer: 'http://localhost:3000', idpCert: process.env.SAML_IDP_CERT!, signatureAlgorithm: 'sha256', identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', wantAssertionsSigned: true, acceptedClockSkewMs: 30_000, }, (profile, done) => { // profile contains the SAML assertion attributes mapped to a JS object. done(null, { id: profile?.nameID, email: profile?.email ?? profile?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'], name: profile?.firstName + ' ' + profile?.lastName, }) }, (_profile, done) => done(null), // logout verify, unused here)
passport.use(samlStrategy)passport.serializeUser((user, done) => done(null, user))passport.deserializeUser((user, done) => done(null, user as Express.User))
// SP-initiated entry point.app.get('/login', passport.authenticate('saml'))
// ACS — IdP POSTs the SAMLResponse here.app.post('/saml/acs', express.urlencoded({ extended: false }), passport.authenticate('saml', { failureRedirect: '/login-failed', session: true }), (req, res) => res.redirect('/profile'),)
// SP metadata — give this URL to IntelliAuth if it prefers a metadata fetch.app.get('/saml/metadata', (req, res) => { res.type('application/xml').send( samlStrategy.generateServiceProviderMetadata(null, null), )})
app.get('/profile', (req, res) => { if (!req.isAuthenticated()) return res.redirect('/login') res.json(req.user)})
app.get('/login-failed', (req, res) => res.status(401).send('Sign-in failed.'))
app.get('/', (req, res) => { res.send(req.isAuthenticated() ? 'Signed in. <a href="/profile">View profile</a>' : '<a href="/login">Sign in</a>')})
app.listen(3000, () => console.log('SP on http://localhost:3000'))Create .env:
SESSION_SECRET=please-change-meSAML_IDP_SSO_URL=https://banking-cymmetri.intelliauth.local/saml/idp/login/saml-sp-testSAML_IDP_CERT="-----BEGIN CERTIFICATE-----MIIDxxx...-----END CERTIFICATE-----"Fill in:
SAML_IDP_SSO_URL— the SSO endpoint from the IntelliAuth IdP-app you created. It's in the metadata XML; look for<SingleSignOnService Location="...">.SAML_IDP_CERT— the X.509 certificate from the metadata, contents of<X509Certificate>...</X509Certificate>. Multi-line is fine in.envif quoted.
pnpm ts-node src/index.tsOpen http://localhost:3000. Click "Sign in". You should be redirected to the IntelliAuth sign-in page. Sign in with your tenant account. You should come back to /profile with a JSON dump of your SAML attributes.
What just happened
Section titled “What just happened”When you clicked "Sign in":
- The Express app's
/loginroute invoked the Passport SAML strategy. - Passport generated a SAMLRequest, encoded it, redirected the browser to the IntelliAuth IdP endpoint with the encoded request.
- The IntelliAuth IdP authenticated you (regular sign-in).
- The IdP generated a signed SAMLResponse asserting "this user, with these attributes" and POSTed it back to your
/saml/acsURL. - Passport verified the signature against
SAML_IDP_CERT, extracted the attributes, called your verify function with the profile. - The verify function turned the SAML attributes into a JS user object; Express-session stored it in the session cookie.
- The redirect to
/profilehappens; the cookie is sent; the user is authenticated.
Steps 4–6 are where SAML earns its complexity. Passport hides the XML signature work; your code only deals with the resulting profile object.
Attribute mapping nuance
Section titled “Attribute mapping nuance”SAML attributes come in two flavours:
- Friendly names —
email,firstName,surname. - URI-style names —
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress.
Different IdPs send different shapes. The verify function above checks both for email. For production, decide on a mapping policy:
- Either configure the IdP to send friendly names only.
- Or write your verify function to handle both forms.
If you control the IntelliAuth IdP-app configuration, friendly names are simpler. Configure them in the attribute mapping section.
SP-initiated vs IdP-initiated
Section titled “SP-initiated vs IdP-initiated”This tutorial implements SP-initiated SSO — the user starts at the SP (/login), gets bounced to the IdP, comes back. SAML also supports IdP-initiated, where the user starts at the IdP (e.g., the IntelliAuth app catalogue) and the IdP POSTs an unsolicited SAMLResponse to the SP's ACS URL.
Passport's SAML strategy handles both. To allow IdP-initiated, ensure your /saml/acs route accepts POSTs without a corresponding SAMLRequest. The strategy does that by default; just make sure the InResponseTo validation isn't blocking unsolicited responses — { disableRequestedAuthnContext: true } and similar options in the strategy config can help.
Signing your own requests
Section titled “Signing your own requests”By default, the strategy sends unsigned SAMLRequests. Production deployments usually sign their requests so the IdP can verify it came from the registered SP. To sign:
{ privateKey: fs.readFileSync('./sp-private.pem', 'utf-8'), signatureAlgorithm: 'sha256', // Your public cert goes into the SP metadata (auto-generated).}Generate the keypair with openssl req -x509 -newkey rsa:2048 -keyout sp-private.pem -out sp-public.pem -nodes -days 365. The public cert goes into the IntelliAuth IdP-app configuration so the IdP can verify your signed requests.
When this approach is the wrong tool
Section titled “When this approach is the wrong tool”Two cases where you shouldn't use Passport-SAML:
- Your app uses IntelliAuth for primary auth. Don't introduce a separate SAML path; have your app use IntelliAuth's OIDC sign-in directly. SAML adds complexity that's only justified when an external IdP is the source of truth.
- You're building a SaaS that customers sign into via THEIR SAML IdP. That's the SAML topics — IntelliAuth is the SP, the customer's Okta / Entra is the IdP. You don't write SAML code; you configure a SAML connection in your tenant admin.
This tutorial is specifically for the "I want to validate SAML config" or "I'm building an SP from scratch" cases.