SAML 2.0 Explained with Infographics: The Full Flow, the Assertion, and Every Check That Matters

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.

SP-Initiated SSO — the browser relays every message Browser the user Service Provider your app Identity Provider Okta / Entra ID 1 GET /dashboard — no session cookie 2 302 Redirect + SAMLRequest + RelayState 3 GET /sso/saml?SAMLRequest=(deflated+base64) 4 Authenticate the user password + MFA, or existing IdP session 5 200 OK — HTML page with a self-submitting form (signed SAMLResponse) 6 POST /saml/acs — SAMLResponse + RelayState 7 Validate everything signature, audience, timestamps, InResponseTo, replay cache 8 Set session cookie → 302 to /dashboard SAML is now done. Your own session cookie takes over.

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.

Anatomy of a SAML Response — outer envelope vs. signed assertion <samlp:Response> the envelope — transport only <ds:Signature> Optional. Signs the whole Response. Many IdPs sign here, the assertion, or both — your SP must know which it requires. <samlp:Status> Success, Requester, Responder, or AuthnFailed. Check it first. <saml:Assertion> the security token — this is what you trust <saml:Issuer> The IdP's entityID. Must match the IdP you configured. <ds:Signature> XML-DSig over the assertion, verified with the IdP's public cert. <saml:Subject> NameID — who the user is (email, opaque persistent ID, transient ID) + SubjectConfirmation: Recipient, NotOnOrAfter, InResponseTo <saml:Conditions> NotBefore / NotOnOrAfter — a validity window of minutes, not hours + AudienceRestriction: must equal YOUR SP entityID <saml:AuthnStatement> AuthnInstant — when they actually logged in (step-up decisions) + AuthnContextClassRef (was MFA used?) + SessionIndex (for logout) <saml:AttributeStatement> The payload your app cares about: email, displayName, department, groups / roles for authorization. Anything outside a signed element is untrusted. If only the Assertion is signed, the Status and envelope can be forged.

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>
Signed vs. encrypted. Signing is mandatory in practice and proves origin and integrity. Encryption (<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.

  1. Status is Success — before you parse anything else.
  2. 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.
  3. The signed thing is the thing you use. Resolve the signature's Reference URI and 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.
  4. Audience matches your SP entityID — stops an assertion minted for another app being replayed at yours.
  5. Destination / Recipient matches your ACS URL exactly.
  6. Timestamps: NotBefore / NotOnOrAfter on both Conditions and SubjectConfirmationData, with a clock skew tolerance of 60 seconds at most.
  7. InResponseTo matches an AuthnRequest ID you actually issued and have not yet consumed. Reject unsolicited responses unless you have deliberately enabled IdP-initiated login.
  8. Replay protection: cache assertion IDs until their NotOnOrAfter passes and reject duplicates.
  9. Algorithm allowlist: RSA-SHA256 or better. Explicitly reject SHA-1, and reject the Transform tricks that let signatures cover nothing.
  10. Disable DTD / external entity resolution in your XML parser. SAML endpoints are the classic XXE target.
Do not write this yourself. XML canonicalization plus XML-DSig plus namespace handling is a minefield with a decade of published CVEs. Use a maintained library — 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.

One-time trust setup — swap metadata, pin certificates Service Provider (your app) entityID: https://app.example.com/saml/metadata ACS URL: https://app.example.com/saml/acs Stores: IdP signing certificate (pinned) Needs: NameID + attribute mapping No private key needed unless it signs requests. Identity Provider entityID: https://idp.example.com SSO URL: https://idp.example.com/sso/saml Stores: SP entityID + allowed ACS URLs Holds: the private signing key Rotating this cert is what breaks logins. SP metadata entityID, ACS URL, SLO URL IdP metadata entityID, SSO URL, X.509 cert Prefer a metadata URL over a pasted file — it survives rotation. Certificates expire. Publish a metadata endpoint, refresh it on a schedule, and accept two signing certs during a rollover window.
<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.

SP-Initiated — the safe default User opens app.example.com/dashboard SP issues AuthnRequest (ID + RelayState) IdP authenticates, returns Response SP matches InResponseTo to its own request CSRF-resistant, deep links preserved, replay window tied to a request you made. Use this unless a customer forces otherwise. IdP-Initiated — convenient, riskier User is already signed in to the IdP portal Clicks the app tile Unsolicited Response POSTed to the ACS No InResponseTo to match — trust or reject Login CSRF is possible, deep links are lost, and replay defence rests only on the ID cache. Enable per-tenant, never globally by default.

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/Signature parameters.
  • 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 failureSP 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 workingThe IdP rotated its signing certificate
Signature fails on every request, from day oneCanonicalization 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 loginSession cookie dropped — missing SameSite=None; Secure on a cross-site POST
Login works, but the user has no rolesAttribute names differ per IdP — groups vs the long http://schemas... claim URIs
Every login creates a duplicate accountKeying users on a transient NameID instead of persistent or email
Debugging tip. Grab the raw 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
TokenXML assertion, XML-DSigJWT, JWS — smaller, easier to log
TransportBrowser redirects and form POSTsRedirects plus a back-channel token call
Best atBrowser SSO into enterprise web appsMobile, SPAs, APIs, delegated authorization
Native appsPainful — needs an embedded browserDesigned for it (PKCE)
DiscoveryMetadata XML, often pasted by hand/.well-known/openid-configuration + JWKS rotation
Why you still need itEvery large IT department already runs it, and procurement asks for it by nameGreenfield 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, InResponseTo and 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.

Post a Comment

Previous Post Next Post