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)

  1. User visits your app and enters a work email.
  2. You match the domain to a configured IdP and redirect with a SAMLRequest.
  3. The user authenticates at the IdP — with whatever policy the customer enforces.
  4. The IdP POSTs a signed SAMLResponse to your ACS endpoint.
  5. 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, and AudienceRestriction naming 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

Example · bash
# 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.

Example · javascript
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.

Example · bash
# 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

  • Be the first to comment on this lesson.