backend / authentication / sso / 01_saml_deep_dive.md

SAML 2.0 Deep Dive

6 interview angles 7 min read source

SAML 2.0 Deep Dive

SAML (Security Assertion Markup Language) 2.0 is the enterprise SSO standard since 2005. XML-based, signed, often opaque to implementers because libraries hide the details. Knowing what’s in an assertion and what signing protects matters for interviews and for debugging “SAML response invalid” errors.

The cast of characters

Term Meaning
IdP (Identity Provider) system that authenticates users (Okta, Azure AD, ADFS, Keycloak)
SP (Service Provider) the application receiving the assertion
Subject the user being authenticated (NameID in the assertion)
Assertion XML statement of authentication facts, signed by the IdP
Metadata XML doc each side publishes describing endpoints, certs, supported bindings

The SAML Web SSO flow (SP-initiated, HTTP POST binding)

1. User → SP                  GET /protected-resource
2. SP → User                  302 → IdP/sso with AuthnRequest (base64+inflate)
3. User → IdP                 GET /sso?SAMLRequest=...&RelayState=...
4. IdP → User                 login form (if no IdP session)
5. User → IdP                 submits credentials, MFA
6. IdP → User                 HTML auto-submit form to SP/acs with SAMLResponse
7. User → SP                  POST /acs with SAMLResponse + RelayState
8. SP                         validate signature, conditions, audience
9. SP → User                  302 → original protected resource (with local session)

Step 6’s “HTML form that auto-submits via JavaScript” is the HTTP POST binding — the user’s browser actually POSTs the response back to the SP’s ACS (Assertion Consumer Service) endpoint. The browser is the courier; the protocol runs through it.

Structure of a SAML assertion (simplified)

<samlp:Response ID="..." Version="2.0" IssueInstant="2024-01-15T10:30:00Z" Destination="https://app/acs">
  <saml:Issuer>https://idp.example.com</saml:Issuer>
  <ds:Signature>...</ds:Signature>        <!-- signs the response -->
  <samlp:Status>
    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </samlp:Status>
  <saml:Assertion ID="..." Version="2.0" IssueInstant="...">
    <saml:Issuer>https://idp.example.com</saml:Issuer>
    <ds:Signature>...</ds:Signature>      <!-- signs the assertion -->
    <saml:Subject>
      <saml:NameID Format="...emailAddress">alice@example.com</saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData NotOnOrAfter="..." Recipient="https://app/acs"
                                       InResponseTo="..."/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="..." NotOnOrAfter="...">
      <saml:AudienceRestriction>
        <saml:Audience>https://app/sp</saml:Audience>
      </saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="..." SessionIndex="...">
      <saml:AuthnContext>
        <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
      </saml:AuthnContext>
    </saml:AuthnStatement>
    <saml:AttributeStatement>
      <saml:Attribute Name="email"><saml:AttributeValue>alice@example.com</saml:AttributeValue></saml:Attribute>
      <saml:Attribute Name="groups">
        <saml:AttributeValue>admins</saml:AttributeValue>
        <saml:AttributeValue>finance</saml:AttributeValue>
      </saml:Attribute>
    </saml:AttributeStatement>
  </saml:Assertion>
</samlp:Response>

What the SP must verify:

Field Check
Issuer matches the configured IdP entity ID
Signature valid, by the IdP’s published cert
Destination matches this SP’s ACS URL (anti-replay to different SP)
Audience matches this SP’s entity ID (anti-token-substitution)
Conditions/NotBefore, NotOnOrAfter current time is in window (clock skew handled)
SubjectConfirmationData/Recipient matches ACS URL
SubjectConfirmationData/NotOnOrAfter not expired
InResponseTo matches the AuthnRequest ID the SP sent
Status Success

Skip any check, and you have a vulnerability. The XML signature alone isn’t enough — without audience check, an attacker who has some valid SAML response can replay it against you.

Signing — what’s signed, what’s not

SAML supports signing the Response, the Assertion, or both. Best practice: at minimum the Assertion is signed; many deployments sign both.

Signing scope is critical. Sign the Response = the whole envelope is integrity-protected. Sign only the Assertion = the assertion is integrity-protected but the Response wrapper isn’t (the StatusCode could be tampered with — usually not exploitable but still).

The signature uses XML Digital Signature (XMLDSig) — a different beast from JWT signatures. It includes:

  1. A Reference to the signed element (by ID).
  2. A digest method (e.g. SHA-256).
  3. A canonicalization method (turn the XML into a normalized byte sequence).
  4. The signature value (RSA/ECDSA over the canonicalized form).

The canonicalization step is what enables XSW attacks — see 08_attack_vectors.md. The signature validates the canonicalized form; if the parser sees a different element than the validator, you have a vulnerability.

Encryption (optional)

For sensitive deployments:

<saml:EncryptedAssertion>
  <xenc:EncryptedData>...</xenc:EncryptedData>
</saml:EncryptedAssertion>

The IdP encrypts the assertion with the SP’s public key from metadata. Only the SP can decrypt. Adds privacy on top of signing’s integrity. Rare in practice (HTTPS already encrypts the channel).

NameID format

<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
    alice@example.com
</saml:NameID>

Common formats:

Format Meaning
emailAddress the user’s email
unspecified catch-all
persistent opaque, stable per-(IdP, SP) pair — privacy-preserving
transient one-time, different on each login
WindowsDomainQualifiedName DOMAIN\username

For most enterprise SSO, emailAddress or persistent. transient is for anonymous/pseudonymous use.

Bindings — how the message moves

How the SAML message actually traverses HTTP. Three matter:

Binding Direction Where the message lives
HTTP-Redirect SP → IdP (AuthnRequest) URL query string (compressed + base64)
HTTP-POST IdP → SP (Response) hidden form field, auto-submitted by JS
HTTP-Artifact both small “artifact” reference in URL; receiver pulls full message via back-channel SOAP

URL-length limits force AuthnRequest to use Redirect (smaller) and Response to use POST (larger; contains the assertion). Artifact binding avoids browser-borne assertions entirely (better for high-security) but requires server-to-server SOAP — more deployment complexity.

Metadata exchange

Both sides publish XML metadata files describing themselves. Sample SP metadata:

<md:EntityDescriptor entityID="https://app.example.com/sp">
  <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
    <md:KeyDescriptor use="signing">
      <ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIB...</ds:X509Certificate></ds:X509Data></ds:KeyInfo>
    </md:KeyDescriptor>
    <md:AssertionConsumerService
        Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
        Location="https://app.example.com/acs" index="0"/>
    <md:SingleLogoutService
        Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
        Location="https://app.example.com/slo"/>
  </md:SPSSODescriptor>
</md:EntityDescriptor>

Import the IdP’s metadata; export your SP metadata to the IdP. Each side knows the other’s:

  • Entity ID.
  • Endpoints (ACS, SLO).
  • Certificates for signing/verification.
  • Supported bindings.

Most IdP admin consoles (Okta, Azure AD) accept metadata XML directly — much faster than hand-configuring entity ID + cert + URL.

RelayState

?SAMLRequest=...&RelayState=https://app.example.com/dashboard

RelayState is an opaque value the SP passes to the IdP and gets back. Use case: “user requested /dashboard before login; preserve that across the SSO flow.”

Risk: open redirect. If you blindly redirect to RelayState after login, an attacker can craft a link that logs the user in and then sends them to https://evil.com. Always validate RelayState is an allowed URL (or use an opaque token mapped server-side to the real destination).

IdP-initiated SSO

Some IdPs let users start in the IdP portal:

1. User → IdP portal           (already authenticated)
2. User clicks "App" tile
3. IdP → SP/acs                POST SAMLResponse (unsolicited — no InResponseTo)
4. SP                          validates, creates session
5. SP → User                   redirects to default landing page

Risks:

  • No InResponseTo — can’t tie back to a specific request the SP made.
  • More susceptible to replay if you don’t validate NotOnOrAfter and one-time use of ID.
  • Cross-Site Request Forgery into SSO: attacker tricks user’s browser into POSTing an old assertion. Mitigate with strict timestamps and ID tracking.

Some SPs disable IdP-initiated entirely; others restrict the post-login landing URL.

SAML vs OIDC — when to pick which

SAML OIDC
Year 2005 2014
Format XML JSON / JWT
Mobile-friendly? poor (browser POST flow) yes
API-friendly? poor yes (OAuth access tokens)
Enterprise IdP support universal growing, near-universal now
Implementation complexity high (XML, canonicalization, signing) medium (HTTP + JWT)

For new B2B SaaS apps: support both. Enterprise customers’ IdPs may speak one or the other.

Common interview confusions

  • “SAML uses JWT.” — no. SAML is XML. JWT is JSON. Different worlds.
  • “You only need to check the signature.” — and the audience, the destination, the conditions, the InResponseTo, and the NotOnOrAfter. A valid signature on a response intended for a different SP is a valid signature.
  • “SAML is deprecated by OIDC.” — OIDC is preferred for new mobile/API work, but SAML is still very alive in enterprise. Many IdPs support both.

Interview angle

  • “Walk through a SAML SP-initiated flow.” — user hits SP without session → SP returns redirect/HTML with AuthnRequest pointing at IdP → user authenticates at IdP → IdP returns auto-submitting HTML POSTing SAMLResponse to SP’s ACS → SP validates and creates session.
  • “What does an SP need to verify in a SAML response?” — signature (by IdP’s cert), Issuer matches expected IdP, Destination matches our ACS URL, Audience matches our entity ID, Conditions/NotBefore-NotOnOrAfter is current, InResponseTo matches our request ID, Status is Success.
  • “What’s the audience restriction for?” — prevents token substitution: a valid assertion for https://app1 shouldn’t work at https://app2. The SP must reject responses whose audience isn’t itself.
  • “Why are SAML AuthnRequests sent over HTTP-Redirect but Responses over HTTP-POST?” — Redirect’s URL is size-limited (browser URL length); the much-smaller AuthnRequest fits. The Response carries the full assertion and won’t, so HTTP-POST (form body) is used.
  • “What’s the difference between SP-initiated and IdP-initiated SAML?” — SP-initiated starts at the app, which redirects to IdP. IdP-initiated starts at the IdP portal and unsolicitedly POSTs a response to the SP (no InResponseTo). IdP-initiated is more vulnerable to replay and CSRF; some SPs disable it.
  • “What’s signing the Assertion vs signing the Response?” — Assertion-signed protects the identity claims; Response-signed additionally protects the envelope (Status, Destination). Sign at least the Assertion; many configs sign both for defense in depth.