The other half of B2B identity. Twenty-year-old XML standards still running every enterprise sale you'll ever do — and how SCIM 2.0 ties them to user lifecycle.
SAML wasn't a hyperscaler invention. It's older than most of the SaaS companies that depend on it.
OIDC arrived a decade after SAML. New SaaS adopt OIDC for end-user social-login; established enterprise SaaS keep SAML for B2B SSO. Most B2B products in 2026 ship both. SCIM is more universal — used regardless of which auth protocol the SP picks.
SAML doesn't have OIDC's /.well-known auto-discovery. Trust is bootstrapped by exchanging an XML metadata file, once, between the IdP and SP. Each side imports the other's metadata.
<EntityDescriptor entityID="https://app.example/saml">
<SPSSODescriptor protocolSupportEnumeration=
"urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<ds:KeyInfo><ds:X509Data>
<ds:X509Certificate>MIID…</ds:X509Certificate>
</ds:X509Data></ds:KeyInfo>
</KeyDescriptor>
<NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:
emailAddress</NameIDFormat>
<AssertionConsumerService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://app.example/saml/acs"
index="0"/>
<SingleLogoutService
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
Location="https://app.example/saml/slo"/>
</SPSSODescriptor>
</EntityDescriptor>
/saml/metadata; admins paste the URL into the other side's UI. SP refreshes the IdP's metadata periodically. Best.User goes to your app, isn't signed in, gets bounced to the IdP, comes back signed in. The 95% case.
User starts at the IdP (their company "app dock"), clicks the SP's tile, IdP POSTs a SAMLResponse to the SP's ACS without any prior request. Operationally common but has security caveats — see "XSW & SAML attacks".
The whole point. A SAML assertion is a signed XML document the IdP issues about the user, sent to the SP via the browser.
<Assertion ID="_a72f…" IssueInstant="2026-05-06T08:42:13Z" Version="2.0">
<Issuer>https://idp.acme.com/</Issuer>
<ds:Signature>…RSA-SHA256 over the assertion or response…</ds:Signature>
<Subject>
<NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
alice@acme.com
</NameID>
<SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<SubjectConfirmationData
NotOnOrAfter="2026-05-06T08:47:13Z"
Recipient="https://app.example/saml/acs"
InResponseTo="_req_0xa1b2"/>
</SubjectConfirmation>
</Subject>
<Conditions NotBefore="2026-05-06T08:42:13Z" NotOnOrAfter="2026-05-06T08:47:13Z">
<AudienceRestriction><Audience>https://app.example/saml</Audience></AudienceRestriction>
</Conditions>
<AuthnStatement AuthnInstant="2026-05-06T08:42:00Z">
<AuthnContext>
<AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
</AuthnContextClassRef>
</AuthnContext>
</AuthnStatement>
<AttributeStatement>
<Attribute Name="email"><AttributeValue>alice@acme.com</AttributeValue></Attribute>
<Attribute Name="firstName"><AttributeValue>Alice</AttributeValue></Attribute>
<Attribute Name="groups">
<AttributeValue>Engineering</AttributeValue>
<AttributeValue>Admins</AttributeValue>
</Attribute>
</AttributeStatement>
</Assertion>
Issuer matches the configured IdP entityID.Audience = your SP's entityID.InResponseTo matches the request you actually sent.The assertion is always signed. Encryption of the assertion is optional and adds confidentiality if the assertion contains PII you'd rather not leave in browser history / proxy logs.
NameID ≈ OIDC sub.AuthnContextClassRef ≈ OIDC acr.AuthnInstant ≈ OIDC auth_time.AttributeStatement ≈ ID-token claims.The choice of NameID format is the closest SAML analogue of the OIDC pairwise/public-subject debate. It governs how the SP identifies the user across logins.
| Format URN | Meaning | Best for |
|---|---|---|
…format:emailAddress |
The user's email address as the identifier. | Easy to integrate; breaks the day a user changes email or marriage / surname change. |
…format:persistent |
A stable, opaque, per-SP identifier; survives email/name change. The recommended default. | Production B2B SSO. Use this if you can. |
…format:transient |
A new identifier on every assertion. Useful for anonymous services. | Privacy-first portals; rarely seen in B2B. |
…format:unspecified |
Whatever the IdP feels like. Often a username or AD sAMAccountName. |
Legacy; brittle. Avoid. |
…format:X509SubjectName |
An X.509 DN, for smart-card / PIV deployments. | Government, defence. |
Almost always: the SP keyed accounts by NameID = emailAddress, the user's email changed in the IdP, and the SP created a new local account on next login. Fix: key on persistent; treat email as a mutable attribute.
The same user looks like different persistent NameIDs to different SPs (by design — privacy). If you operate two related SPs and want the same identifier in both, you need an explicit cross-SP claim or a separate "userPrincipalName" attribute.
XML signatures are notoriously fragile. SAML libraries have been a recurring source of CVEs.
| Attack | How | Defence |
|---|---|---|
| XML Signature Wrapping (XSW) | Attacker injects an extra unsigned <Assertion> alongside the signed one. Library validates the signed one but the SP code reads the unsigned one. |
Use a library that resolves references via Reference URI + ID, not by document position. Reject documents with multiple <Assertion> elements. |
| XML eXternal Entity (XXE) | SAML XML parser fetches a remote DTD; attacker exfiltrates files. | Disable DOCTYPE / external entity resolution in the XML parser. |
| Comment injection in NameID | Some libraries normalise alice<!--ignore-->@evil.com differently between sig-validation and the application; user is bound to the wrong account. |
Reject NameIDs containing comments / unexpected characters; canonicalise before comparison. |
| Replay | Re-using a captured assertion within its validity window. | Cache the AssertionID for the validity window; reject duplicates. Pin InResponseTo and NotOnOrAfter. |
| IdP-initiated CSRF | Attacker provides a SAMLResponse to the user's browser; SP processes it and creates a session as the attacker's account. | Default-disable IdP-initiated SSO. If you must enable it, pin RelayState to a session-bound token. |
| Algorithm downgrade | IdP signs with SHA-1 / RSA-1024 / unsigned response wrapper. | Pin SignatureMethod = RSA-SHA256 or stronger; reject SHA-1; require the entire <Response> or assertion to be signed. |
Use a maintained one. passport-saml, python3-saml, pysaml2, spring-security-saml2, onelogin/php-saml, itfoxtec.identity.saml2. They all have known issues; track their CVE feeds.
User clicks "Log out" anywhere → IdP propagates a logout request to every SP the user is currently signed into → all sessions die at once.
ForceAuthn=true) — equivalent to OIDC prompt=login.Don't promise users that "Log out" kills every app they're in. Tell them what it actually does. If you operate the IdP, invest in CAEP-style propagation; SLO is a footgun.
| Concern | SAML 2.0 | OIDC |
|---|---|---|
| Wire format | XML, base64-encoded, browser POST | JSON, URL parameters, Bearer JWTs |
| Crypto | XML-DSig (notoriously brittle) | JWS / JWE (compact, well-supported) |
| Discovery | Manual XML metadata exchange | /.well-known/openid-configuration |
| Native / mobile apps | Painful — XML in a webview | Designed for it (PKCE + system browser) |
| API delegation | None — auth only, OAuth needed alongside | Built on OAuth — same token both jobs |
| Logout | SLO often broken; CAEP retrofit | Back-Channel Logout works at scale |
| Provisioning | JIT from assertion attributes; SCIM bolted on | JIT from ID-token claims; SCIM bolted on |
| Library quality | Variable; many CVEs over the years | Mature, narrowly-scoped libraries |
| Enterprise IdP support | Universal — every IdP has spoken SAML for 20 years | Universal — same IdPs all added OIDC by 2018 |
| Buyer expectation in 2026 | Required by every enterprise procurement checklist | Increasingly accepted as equivalent |
RequestSecurityTokenResponse).X-Remote-User, X-Forwarded-Email) populated by an upstream auth proxy.X-Remote-User directly is trivial to spoof if the proxy isn't enforced.SAML signs a user in. It says nothing about creating their account, updating their group memberships, or de-provisioning them on their last day. SCIM 2.0 is the protocol that does that.
PATCH /Users/{id} {"active": false} at 17:00:05 Friday. Account suspended; data accessible to admins; licence freed./Users and /Groups endpoints.GET /scim/v2/Users/72f81b9e
Authorization: Bearer eyJ…
Accept: application/scim+json
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "72f81b9e",
"userName": "alice@acme.com",
"name": { "givenName": "Alice", "familyName": "Example" },
"emails": [{ "value": "alice@acme.com", "primary": true }],
"active": true,
"groups": [
{ "value": "g1", "display": "Engineering" },
{ "value": "g2", "display": "Admins" }
],
"meta": {
"resourceType": "User",
"created": "2024-09-12T10:00:00Z",
"lastModified": "2026-05-03T14:22:11Z"
}
}
| Verb & Path | Job |
|---|---|
GET /Users?filter=… | List users (with filtering, paging). |
GET /Users/{id} | Read a single user. |
POST /Users | Create a new user. |
PUT /Users/{id} | Replace a user (full payload). |
PATCH /Users/{id} | Partial update — the everyday verb. |
DELETE /Users/{id} | Hard delete (rare; most IdPs use active: false). |
POST /Bulk | Atomic batch of operations. |
GET /Schemas · /ResourceTypes · /ServiceProviderConfig | Self-describing metadata. |
GET /Users?filter=userName eq "alice@acme.com"
PATCH /Users/72f81b9e
{
"schemas":["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{ "op":"replace", "path":"active", "value":false },
{ "op":"add", "path":"groups", "value":[{"value":"g3"}] },
{ "op":"remove", "path":"groups[value eq \"g2\"]" }
]
}
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
],
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
"employeeNumber": "12345",
"department": "Engineering",
"manager": { "value": "1f2c…", "displayName": "Bob Boss" }
}
Most SaaS products define their own extension URN for app-specific attributes (Slack bot scopes, Salesforce profile, etc.).
Modern B2B SaaS supports both: SCIM for proper lifecycle, JIT as a fallback for users who slip through (e.g. a contractor who isn't in the SCIM payload but does have an SSO account). The two should agree on the same identifier so the JIT user matches the SCIM-managed one.
Provisioning a user is easy. Keeping their group memberships in sync across systems is where SCIM rollouts go sideways.
displayName updates but the SP's app code might key on the name not the id.id, not displayName. Names can change; ids should not./scim/v2/{tenant_id}/Users — so each customer's IdP only touches its own users.Log every SCIM operation with: tenant, IdP request id, SP user id, operation type, before/after diff, outcome. When a customer reports "Bob still has access", you can trace it back to a missing PATCH or a successful one that didn't take.
Every major IdP has its own SAML & SCIM idiosyncrasies. The first time you ship to enterprise, you'll meet all of them.
userName on update, breaking SPs that key on it.If your B2B SaaS will ever connect to more than two customer IdPs, don't build the SAML / SCIM stack n times. Use a broker.
Adopt a per-tenant abstraction from day one — every customer is an isolated SAML connection with its own metadata, signing certificates, expiry, attribute mapping, SCIM endpoint, audit log destination. This is what the brokers spent years building.
What an enterprise procurement team will ask. If your product can tick most of these, expect to skip past the deeply technical question round.
persistent default, others on request.ForceAuthn / acr_values./Users and /Groups + /ServiceProviderConfig.active: false.OAuth Primer / Part 1 / Part 2 · Introduction / Advanced OpenID Connect · Authentication Methods · Authorization Models.
OASIS SAML 2.0 Core (March 2005) · SAML 2.0 Bindings · SAML 2.0 Profiles · SAML 2.0 Errata · OASIS SAML V2.0 Implementation Profile for Federation Interoperability · RFC 7642 (SCIM Use Cases) · RFC 7643 (SCIM Schema) · RFC 7644 (SCIM Protocol) · RFC 7517 (JWK) · XML-DSig Best Practices · OWASP SAML Security Cheat Sheet · OpenID SSF / CAEP · Auth0 / WorkOS / Stytch B2B SAML guides
SAML is the enterprise tax; SCIM is the enterprise check. Pay both up-front and your B2B sales cycle gets shorter.