SAML 2.0 is twenty years old, written in XML, and still the thing standing between your app and every enterprise deal you want to close. If you have ever stared at a SAML_ASSERTION_INVALID log line at 2 a.m., this post is the map you wished you had: the full flow, the anatomy of an assertion, real XML, and the validation checklist that separates a working integration from a security hole.
The cast: three actors and a browser that does all the walking
SAML has exactly three participants, and the most important thing to understand is that they never talk to each other directly. Everything is relayed through the user's browser.
| Actor | Who it is | What it owns |
|---|---|---|
| Principal | The human, via their browser | The session cookies, and the job of carrying messages |
| Service Provider (SP) | Your application | An entityID, an Assertion Consumer Service (ACS) URL, and the IdP's public certificate |
| Identity Provider (IdP) | Okta, Entra ID, Ping, Keycloak, ADFS | The user directory, the login UI, and the private signing key |
Two more terms you will see everywhere: an assertion is the signed XML statement "I authenticated this person, here is what I know about them", and metadata is the XML business card each side publishes so the other knows its URLs and certificates.
The main event: SP-initiated SSO, step by step
This is the flow you will implement 95% of the time. The user starts at your app, not at the IdP.
The SP and IdP never open a socket to each other. Every arrow is a browser redirect or form POST.
Step 2 in detail: the AuthnRequest
Your SP builds a small XML document asking "please authenticate whoever is holding this browser":
<samlp:AuthnRequest
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
ID="_8f3a91c4d5e6f7a8b9c0"
Version="2.0"
IssueInstant="2026-09-03T08:14:22Z"
Destination="https://idp.example.com/sso/saml"
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
AssertionConsumerServiceURL="https://app.example.com/saml/acs">
<saml:Issuer>https://app.example.com/saml/metadata</saml:Issuer>
<samlp:NameIDPolicy
Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"
AllowCreate="true"/>
</samlp:AuthnRequest>
That XML is then DEFLATE-compressed, base64-encoded and URL-encoded into a query parameter (the HTTP-Redirect binding), which is why a SAML redirect URL looks like line noise:
HTTP/1.1 302 Found
Location: https://idp.example.com/sso/saml
?SAMLRequest=fVLLTsMwEPyVyPfGiZ2XrbYSUCEhFYT...
&RelayState=%2Fdashboard%3Ftab%3Dbilling
&SigAlg=http%3A%2F%2Fwww.w3.org%2F2001%2F04%2Fxmldsig-more%23rsa-sha256
&Signature=BqvR9M8bJ2xI...
Two parameters deserve attention. RelayState is opaque round-trip state — the IdP must hand it back untouched, which is how you land the user on the deep link they originally asked for. Treat it as untrusted input: store an ID and look it up, never redirect to a raw URL from it, or you have just built an open redirect. Signature is a detached signature over the query string itself, because you cannot embed XML-DSig inside a compressed URL parameter.
Step 5 in detail: the auto-submitting form
The IdP cannot POST to your ACS endpoint itself, so it returns a page that makes the browser do it:
<body onload="document.forms[0].submit()">
<form method="POST" action="https://app.example.com/saml/acs">
<input type="hidden" name="SAMLResponse" value="PHNhbWxwOl..."/>
<input type="hidden" name="RelayState" value="/dashboard?tab=billing"/>
<noscript><button>Continue</button></noscript>
</form>
</body>
That white flash you see mid-login? That is this page.
Anatomy of a SAML Response
The SAMLResponse is a base64 blob wrapping a samlp:Response envelope, which wraps the actual saml:Assertion. Knowing which element does what is the difference between guessing and debugging.
Here is a trimmed but realistic response, signature bodies elided:
<samlp:Response ID="_r91f" InResponseTo="_8f3a91c4d5e6f7a8b9c0"
Destination="https://app.example.com/saml/acs"
IssueInstant="2026-09-03T08:14:31Z" Version="2.0">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<samlp:Status>
<samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
</samlp:Status>
<saml:Assertion ID="_a44d" IssueInstant="2026-09-03T08:14:31Z" Version="2.0">
<saml:Issuer>https://idp.example.com</saml:Issuer>
<ds:Signature>...RSA-SHA256 over #_a44d...</ds:Signature>
<saml:Subject>
<saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">
7c9e6679-7425-40de-944b-e07fc1f90ae7
</saml:NameID>
<saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
<saml:SubjectConfirmationData
InResponseTo="_8f3a91c4d5e6f7a8b9c0"
Recipient="https://app.example.com/saml/acs"
NotOnOrAfter="2026-09-03T08:19:31Z"/>
</saml:SubjectConfirmation>
</saml:Subject>
<saml:Conditions NotBefore="2026-09-03T08:14:01Z"
NotOnOrAfter="2026-09-03T08:19:31Z">
<saml:AudienceRestriction>
<saml:Audience>https://app.example.com/saml/metadata</saml:Audience>
</saml:AudienceRestriction>
</saml:Conditions>
<saml:AuthnStatement AuthnInstant="2026-09-03T08:14:28Z"
SessionIndex="_a44d-sess">
<saml:AuthnContext>
<saml:AuthnContextClassRef>
urn:oasis:names:tc:SAML:2.0:ac:classes:MultiFactor
</saml:AuthnContextClassRef>
</saml:AuthnContext>
</saml:AuthnStatement>
<saml:AttributeStatement>
<saml:Attribute Name="email">
<saml:AttributeValue>dana@customer.com</saml:AttributeValue>
</saml:Attribute>
<saml:Attribute Name="groups">
<saml:AttributeValue>billing-admin</saml:AttributeValue>
<saml:AttributeValue>eu-region</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
<saml:EncryptedAssertion>) is optional and protects confidentiality from the browser and from anything in between. Since the whole exchange rides over TLS, most deployments sign only — encrypt when the attributes themselves are sensitive and you do not want them sitting in a browser's POST body or a proxy log.
The validation checklist (this is the whole security model)
A SAML assertion is a bearer token. Whoever holds it becomes the user. Every one of these checks exists because someone once shipped a login bypass without it.
- Status is Success — before you parse anything else.
- Signature verifies against the exact certificate from the IdP's metadata. Never trust a certificate embedded in the response's own
<KeyInfo>: that is attacker-supplied. - The signed thing is the thing you use. Resolve the signature's
Reference URIand confirm it points at the assertion you are about to consume. This is the defence against XML Signature Wrapping (XSW), where an attacker keeps a valid signed assertion and grafts a forged one alongside it. - Audience matches your SP
entityID— stops an assertion minted for another app being replayed at yours. - Destination / Recipient matches your ACS URL exactly.
- Timestamps:
NotBefore/NotOnOrAfteron both Conditions and SubjectConfirmationData, with a clock skew tolerance of 60 seconds at most. - InResponseTo matches an AuthnRequest ID you actually issued and have not yet consumed. Reject unsolicited responses unless you have deliberately enabled IdP-initiated login.
- Replay protection: cache assertion IDs until their
NotOnOrAfterpasses and reject duplicates. - Algorithm allowlist: RSA-SHA256 or better. Explicitly reject SHA-1, and reject the
Transformtricks that let signatures cover nothing. - Disable DTD / external entity resolution in your XML parser. SAML endpoints are the classic XXE target.
python3-saml, ruby-saml, Sustainsys.Saml2, ITfoxtec.Identity.Saml2, node-saml, Shibboleth SP, mod_auth_mellon — keep it patched, and turn on every strict flag it offers.
Where the trust comes from: metadata
None of the above works until each side knows the other's URLs and public key. That is a one-time exchange of metadata XML — and it is the step that breaks silently every 1–3 years when a certificate expires.
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
entityID="https://app.example.com/saml/metadata">
<SPSSODescriptor AuthnRequestsSigned="true"
WantAssertionsSigned="true"
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
<ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIDx...</ds:X509Certificate>
</ds:X509Data></ds:KeyInfo>
</KeyDescriptor>
<AssertionConsumerService index="0" isDefault="true"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="https://app.example.com/saml/acs"/>
</SPSSODescriptor>
</EntityDescriptor>
SP-initiated vs. IdP-initiated
The other flow starts inside the IdP's app portal: the user clicks a tile and an assertion arrives at your ACS endpoint out of nowhere. It is convenient, widely requested by enterprise admins, and structurally weaker.
If you must support IdP-initiated login, the mitigation is to treat the unsolicited assertion as an invitation, not a session: land the user on a neutral page, then bounce them through your own SP-initiated flow. Okta and Entra both support this pattern.
Bindings, in one paragraph each
- HTTP-Redirect — message deflated into the query string. Used for AuthnRequests and logout. Size-limited by URL length, signed with the detached
SigAlg/Signatureparameters. - HTTP-POST — message base64-encoded in a form field. Used for responses, because assertions are far too big for a URL.
- HTTP-Artifact — the IdP sends a small reference; your SP redeems it over a back-channel SOAP call. Keeps the assertion out of the browser entirely. Rare, and it requires direct SP-to-IdP connectivity, which defeats the usual reason people pick SAML.
Debugging table: symptom → cause
| What you see | Almost always |
|---|---|
"Invalid audience" / AudienceRestriction failure | SP entityID mismatch — a trailing slash, or http vs https |
| "Assertion not yet valid" / "expired" | Clock drift on the SP host, or a load balancer in a different timezone |
| Signature verification fails after months of working | The IdP rotated its signing certificate |
| Signature fails on every request, from day one | Canonicalization or the wrong cert pasted in; check for whitespace and PEM headers |
| "InResponseTo not found" | Request IDs held in per-instance memory behind a round-robin load balancer |
| Infinite redirect loop after a successful login | Session cookie dropped — missing SameSite=None; Secure on a cross-site POST |
| Login works, but the user has no roles | Attribute names differ per IdP — groups vs the long http://schemas... claim URIs |
| Every login creates a duplicate account | Keying users on a transient NameID instead of persistent or email |
SAMLResponse with the SAML-tracer browser extension and decode it locally (base64 -d plus xmllint --format). Avoid pasting real assertions into online decoders — they contain live bearer tokens and real user attributes.
Single Logout, honestly
SAML Single Logout (SLO) exists: the IdP fans a LogoutRequest out to every SP holding a session, keyed by the SessionIndex from the original assertion. In practice it is the least reliable part of the spec — front-channel logout depends on third-party iframes and cookies that modern browsers block, back-channel logout requires every SP to be reachable, and one unresponsive SP breaks the chain. Implement it because enterprise checklists ask for it, but keep your own session lifetimes short and never treat SLO as a security control.
SAML vs. OIDC: choosing, not arguing
| SAML 2.0 | OIDC / OAuth 2.0 | |
|---|---|---|
| Token | XML assertion, XML-DSig | JWT, JWS — smaller, easier to log |
| Transport | Browser redirects and form POSTs | Redirects plus a back-channel token call |
| Best at | Browser SSO into enterprise web apps | Mobile, SPAs, APIs, delegated authorization |
| Native apps | Painful — needs an embedded browser | Designed for it (PKCE) |
| Discovery | Metadata XML, often pasted by hand | /.well-known/openid-configuration + JWKS rotation |
| Why you still need it | Every large IT department already runs it, and procurement asks for it by name | Greenfield default for anything not enterprise-web |
The practical rule: new product, new clients, mobile or API in scope → OIDC. Selling B2B into companies with an existing IdP and an SSO line item in the security questionnaire → you will be shipping SAML, and you should ship it correctly.
The short version
- SAML moves a signed XML bearer token from the IdP to your app through the user's browser.
- The signature is the only thing standing between you and an attacker minting their own identity — verify it against a pinned certificate from metadata, and verify that the signature covers the assertion you actually consume.
- Audience, Destination, timestamps,
InResponseToand a replay cache are not optional extras. They are the protocol. - Prefer SP-initiated. Gate IdP-initiated per tenant.
- Use a maintained library, publish a metadata endpoint, and put a calendar reminder on the IdP certificate expiry — that last one will save you an outage.
Next up in this series: doing the same teardown for OIDC, and what actually changes when you migrate an enterprise tenant from one to the other.