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:
- A
Referenceto the signed element (by ID). - A digest method (e.g. SHA-256).
- A canonicalization method (turn the XML into a normalized byte sequence).
- 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
NotOnOrAfterand one-time use ofID. - 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://app1shouldn’t work athttps://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.