SAML and Enterprise SSO
The XML-based standard your enterprise customers will ask for, and how it compares to OIDC.
SAML 2.0 is the enterprise SSO standard. It predates OAuth, speaks XML, and remains the thing large customers mean when they say "we need SSO". If you sell to enterprises, you will implement it.
The vocabulary
- Identity Provider (IdP) — the customer's directory: Okta, Entra ID, Ping, Google Workspace.
- Service Provider (SP) — you.
- Assertion — a signed XML document asserting who the user is.
- ACS (Assertion Consumer Service) — your endpoint that receives it.
- Metadata — an XML file each side publishes describing endpoints and certificates.
The flow (SP-initiated)
- User visits your app and enters a work email.
- You match the domain to a configured IdP and redirect with a
SAMLRequest. - The user authenticates at the IdP — with whatever policy the customer enforces.
- The IdP
POSTs a signedSAMLResponseto your ACS endpoint. - You verify the signature, the conditions and the audience, then create your session.
IdP-initiated also exists: the user clicks a tile in their Okta dashboard and lands on your ACS unannounced. It skips the request you would have correlated against, so it needs extra care — many implementations disable it.
The verification checklist
SAML's failures are almost all verification failures:
- Verify the XML signature against the IdP's certificate — and check what was signed covers the assertion you are reading.
- Beware XML signature wrapping. Attackers add a second, unsigned assertion that some parsers read while verifying the first. Use a maintained library; do not parse SAML yourself.
- Check
Conditions:NotBefore,NotOnOrAfter, andAudienceRestrictionnaming your entity id. - Enforce single use via the assertion id, to block replay.
- Disable external entity resolution in your XML parser (XXE).
SAML or OIDC?
OIDC is simpler, JSON, mobile-friendly, and what you should prefer for anything new. SAML is what the customer's IT department already has and will ask for by name. Most B2B products end up supporting both, usually via a library or a provider that normalises them.
Example
# Roughly, what a SAMLResponse contains (base64-encoded in a form POST)
<samlp:Response Destination="https://abc.com/saml/acs" ...>
<saml:Issuer>http://www.okta.com/exk1fx...</saml:Issuer>
<ds:Signature>...</ds:Signature> <!-- verify against the IdP cert -->
<saml:Assertion ID="_8e8dc5f6...">
<saml:Subject>
<saml:NameID Format="...emailAddress">[email protected]</saml:NameID>
</saml:Subject>
<saml:Conditions NotBefore="2026-08-04T10:00:00Z"
NotOnOrAfter="2026-08-04T10:05:00Z">
<saml:AudienceRestriction>
<saml:Audience>https://abc.com/saml/metadata</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AttributeStatement>
<saml:Attribute Name="groups"><saml:AttributeValue>engineering</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>When to use it
- An enterprise customer requires SAML so that deprovisioning an employee in Okta immediately removes their access to your product.
- A tenant maps IdP group attributes to your roles, so 'engineering' in their directory becomes 'developer' in your app automatically.
- A team disables IdP-initiated SSO after a review, because it accepts an unsolicited assertion with no request to correlate it against.
More examples
Per-tenant configuration and the ACS endpoint
insertIfNew on the assertion id is the replay defence: a unique index makes a duplicate insert fail, which is simpler and safer than a check-then-write.
import * as saml from 'samlify';
// Each enterprise customer is its own IdP configuration.
async function idpFor(domain) {
const tenant = await db.tenants.findByEmailDomain(domain);
if (!tenant?.samlEnabled) return null;
return saml.IdentityProvider({
metadata: tenant.samlMetadataXml, // uploaded by the customer's IT
});
}
const sp = saml.ServiceProvider({
entityID: 'https://abc.com/saml/metadata',
assertionConsumerService: [{
Binding: saml.Constants.namespace.binding.post,
Location: 'https://abc.com/saml/acs',
}],
wantAssertionsSigned: true, // refuse unsigned assertions
wantMessageSigned: true,
});
// SP-initiated: email domain decides where to send them
app.post('/login/sso', async (req, res) => {
const domain = String(req.body.email).split('@')[1]?.toLowerCase();
const idp = await idpFor(domain);
if (!idp) return res.status(400).json({ error: 'sso_not_configured' });
const { context } = sp.createLoginRequest(idp, 'redirect');
res.redirect(context);
});
// The assertion arrives here
app.post('/saml/acs', express.urlencoded({ extended: false }), async (req, res) => {
const idp = await idpFor(req.body.RelayStateDomain);
let extract;
try {
// The library checks the signature, Conditions and Audience for you.
({ extract } = await sp.parseLoginResponse(idp, 'post', req));
} catch {
return res.status(400).json({ error: 'invalid_saml_response' });
}
// Replay protection: an assertion id may be consumed exactly once.
const fresh = await db.samlAssertions.insertIfNew({
id: extract.response.id,
expiresAt: new Date(extract.conditions.notOnOrAfter),
});
if (!fresh) return res.status(400).json({ error: 'assertion_replayed' });
// Just-in-time provisioning, with group → role mapping
const user = await upsertSsoUser({
email: extract.nameID,
tenantId: req.tenant.id,
roles: mapGroups(extract.attributes.groups ?? []),
});
res.cookie('sid', await createSession(user.id, req, { sso: true }), COOKIE_OPTIONS);
res.redirect('/dashboard');
});What to ask the customer for, and what to give them
Publishing a metadata URL rather than emailing values around means the customer's IT can self-serve, and your certificate rotation propagates without a support ticket.
# Send them YOUR values (or a metadata URL that contains them)
Entity ID / Audience URI : https://abc.com/saml/metadata
ACS URL / Reply URL : https://abc.com/saml/acs
Name ID format : EmailAddress
Metadata URL : https://abc.com/saml/metadata.xml
# Ask for THEIRS
IdP metadata XML (or URL) — contains the sign-in URL, entity id and certificate
# Attributes to request in their mapping
email → NameID or an email attribute
firstName / lastName
groups → mapped to your roles
# Test before going live
1. SP-initiated login from your login page
2. Wrong-tenant email → must not reach the IdP
3. Expired assertion (NotOnOrAfter in the past) → rejected
4. Replayed assertion → rejected
5. Certificate rotation → both old and new accepted during the window
6. Deprovision a user in the IdP → next login fails
# Certificate expiry is the #1 cause of 'SSO stopped working overnight'.
# Alert 30 days before, and support two certificates during rotation.
Discussion