Skip to content

SAML Service Provider with Express + Passport

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.

Before you begin
  • Node 18+ and pnpm
  • An IntelliAuth tenant with SAML connections allowed
  • 30 minutes
Terminal window
mkdir saml-sp && cd saml-sp
pnpm init -y
pnpm add express express-session passport @node-saml/passport-saml dotenv
pnpm add -D typescript @types/express @types/express-session @types/passport @types/node ts-node

Create tsconfig.json:

{
"compilerOptions": {
"target": "es2022",
"module": "node16",
"moduleResolution": "node16",
"strict": true,
"esModuleInterop": true,
"outDir": "dist"
}
}

In the tenant admin console:

  1. Authentication → Apps as IdP → New SAML app.
  2. Configure:
    • App name — "Local SAML SP test".
    • ACS URLhttp://localhost:3000/saml/acs.
    • Entity IDhttp://localhost:3000.
    • NameID format — Email.
  3. 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.

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-me
SAML_IDP_SSO_URL=https://banking-cymmetri.intelliauth.local/saml/idp/login/saml-sp-test
SAML_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 .env if quoted.
Terminal window
pnpm ts-node src/index.ts

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

When you clicked "Sign in":

  1. The Express app's /login route invoked the Passport SAML strategy.
  2. Passport generated a SAMLRequest, encoded it, redirected the browser to the IntelliAuth IdP endpoint with the encoded request.
  3. The IntelliAuth IdP authenticated you (regular sign-in).
  4. The IdP generated a signed SAMLResponse asserting "this user, with these attributes" and POSTed it back to your /saml/acs URL.
  5. Passport verified the signature against SAML_IDP_CERT, extracted the attributes, called your verify function with the profile.
  6. The verify function turned the SAML attributes into a JS user object; Express-session stored it in the session cookie.
  7. The redirect to /profile happens; 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.

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.

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.

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.

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.