The first large customer who signs a contract will ask for single sign-on with their identity provider, and very often that means SAML 2.0. The standard dates from 2005, it moves XML through browser redirects and form posts, and its failure messages range from vague to silent. Most SAML bugs, including serious security holes, come from not knowing exactly which message goes where and what the service provider must check.
This post walks through the SP-initiated flow message by message with a sequence diagram, then the IdP-initiated variant and its risks, the anatomy of a SAML response, the validation checklist for your app, metadata and certificate rotation, and how SAML compares with OpenID Connect.
The three parties
| Party | In SAML terms | Examples |
|---|---|---|
| The user's browser | User agent | Any browser |
| Your application | Service provider (SP) | Your SaaS app |
| The customer's login system | Identity provider (IdP) | Microsoft Entra ID, Okta, Google Workspace, AD FS |
In the common Web Browser SSO profile, the SP and the IdP never talk to each other directly. Every message travels through the browser, as a redirect or an auto-submitted form. What they share is configuration, exchanged once as metadata: each side's entity ID, the IdP's sign-in URL, the SP's Assertion Consumer Service (ACS) URL and the IdP's signing certificate.
The SP-initiated flow, step by step
The user starts at your app:
- The user opens
https://app.example.com/dashboardwithout a session. - Your app creates an
AuthnRequestwith a unique ID, its entity ID and its ACS URL, stores the ID in the pending login state, and redirects the browser to the IdP. With the HTTP-Redirect binding, the request travels in the query string asSAMLRequest(deflated, base64-encoded), next to aRelayStatevalue that remembers where the user wanted to go. - The IdP authenticates the user: password and MFA, or an existing IdP session, in which case the user sees nothing.
- The IdP returns an HTML page with a form that posts a signed
SAMLResponseand the sameRelayStateto your ACS URL. This is the HTTP-POST binding, and a small script submits the form automatically. - Your app validates the response, creates its own session cookie and redirects the user to the page saved in
RelayState.
From then on, your app's own session decides who the user is. SAML is only involved again when that session expires.
The bindings specification limits RelayState to 80 bytes, so store the real return URL server-side and send a short key.
What a SAML response contains
Trimmed to the parts your app reads, a response looks like this:
<samlp:Response ID="_r81c" InResponseTo="_req42" Destination="https://app.example.com/saml/acs">
<saml:Assertion ID="_a77e">
<saml:Issuer>https://idp.customer.com/entity</saml:Issuer>
<ds:Signature>…</ds:Signature>
<saml:Subject>
<saml:NameID>ana@customer.com</saml:NameID>
<saml:SubjectConfirmationData InResponseTo="_req42"
Recipient="https://app.example.com/saml/acs" NotOnOrAfter="2026-11-02T09:05:00Z"/>
</saml:Subject>
<saml:Conditions NotBefore="2026-11-02T08:59:30Z" NotOnOrAfter="2026-11-02T09:05:00Z">
<saml:AudienceRestriction><saml:Audience>https://app.example.com</saml:Audience></saml:AudienceRestriction>
</saml:Conditions>
<saml:AttributeStatement>…email, name, groups…</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>
The assertion is the signed statement: who the user is (NameID), who says so (Issuer), for whom (Audience), for how long, and in answer to which request (InResponseTo).
The validation checklist
Your ACS endpoint is where SAML security succeeds or fails. It must:
- Verify the signature with the certificate from the IdP's metadata, never with a certificate embedded in the message itself.
- Read only what the signature covers. XML signature wrapping attacks insert a second, unsigned assertion and hope the code verifies one element and reads another. Researchers found this flaw in 11 of 14 major SAML frameworks in 2012, and it keeps coming back: ruby-saml fixed a signature bypass rated CVSS 10.0 (CVE-2024-45409) in 2024 and two parser-differential bypasses (CVE-2025-25291 and CVE-2025-25292) in 2025.
- Check
Issueragainst the IdP's entity ID for this customer. - Check
Audienceagainst your own entity ID. - Check
DestinationandRecipientagainst your ACS URL. - Check
NotBeforeandNotOnOrAfterwith a tolerance of a minute or two for clock skew. - Match
InResponseToto anAuthnRequestID you issued for this browser and have not used yet. - Reject replays by remembering assertion IDs until they expire.
- Treat
RelayStateas untrusted. Redirect only to relative paths or an allowlist, or you have an open redirect.
The lesson from a decade of SAML vulnerabilities: use a maintained library, pin it, patch it quickly, and never write your own XML signature verification.
IdP-initiated SSO and why it is riskier
In the IdP-initiated flow the user starts at the IdP, usually by clicking your app's tile in a dashboard such as the Okta or Microsoft My Apps portal. The IdP posts an unsolicited response to your ACS URL. There was no AuthnRequest, so there is no InResponseTo to match.
| SP-initiated | IdP-initiated | |
|---|---|---|
| Starts at | Your app | The IdP's portal |
AuthnRequest |
Yes | No |
InResponseTo check |
Yes | Not possible |
| Login CSRF protection | Built in | Needs extra care |
| Deep links | Through RelayState |
Usually lands on a default page |
Without InResponseTo, an attacker who obtains a valid response, or who gets a victim's browser to post one, can sign the victim into the attacker's account or replay a stolen response within its validity window. If customers need dashboard tiles, the safer pattern is a tile that opens your app's login URL, which then starts a normal SP-initiated flow. If you must accept unsolicited responses, keep validity short, enforce one-time assertion IDs, and allow it per customer rather than globally.
Metadata, certificates and multi-tenant setups
Each customer's IdP is a separate trust relationship. Store per tenant: the IdP entity ID, sign-in URL, signing certificates and attribute mapping. The multi-tenant SaaS architecture diagram shows where this per-tenant configuration usually lives.
Signing certificates expire, and an expired or rotated certificate is the most common cause of a whole customer suddenly unable to sign in. Accept two certificates per IdP during a rollover, read the IdP's metadata URL on a schedule if it publishes one, and alert weeks before expiry.
SAML authenticates; it does not keep your user list in sync. Creating accounts on first login is easy, but removing access when someone leaves needs provisioning, usually SCIM 2.0 (RFC 7644). Without it, a deactivated employee keeps any session your app already issued.
SAML or OpenID Connect?
| SAML 2.0 | OpenID Connect | |
|---|---|---|
| Format | XML assertions with XML signatures | JSON, with JWT ID tokens |
| Transport | Browser redirects and form posts | Redirects plus a back-channel token request |
| Mobile apps and SPAs | Awkward | Designed for them |
| Typical use | Enterprise SSO into SaaS apps | Consumer login, APIs, modern enterprise SSO |
New apps can start with OpenID Connect, but enterprise buyers will keep asking for SAML, so most B2B products support both. The OAuth 2.0 flow diagram covers the protocol OpenID Connect builds on.
The SAML SSO diagram
Diagram contents
- User
- Browser
- Service provider (your app)
- Identity provider
- no IdP session yet
- 1. Open the app
- 2. GET /dashboard (no session)
- 3. 302 to IdP: SAMLRequest + RelayState
- 4. GET /sso?SAMLRequest=…&RelayState=… (HTTP-Redirect)
- 5. Login page
- 6. Password + MFA
- 7. HTML form: signed SAMLResponse + RelayState
- 8. POST /saml/acs (HTTP-POST, auto-submit)
- 9. 302 /dashboard + session cookie
- 10. GET /dashboard (cookie)
- 11. 200 OK
How to read it:
- Four participants. The user, the browser, your app as the service provider and the identity provider. Time runs downward.
- The redirect carries the
AuthnRequestandRelayStatefrom your app to the IdP, through the browser. - The optional fragment is the login step. With an existing IdP session it is skipped and the user sees only a quick redirect.
- The form post delivers the signed
SAMLResponseto the ACS URL. Your app validates it and sets its own session cookie. - The Markdown block lists the checks the ACS endpoint must make.
Drawing SSO flows that help with debugging
- Draw the browser as its own participant. SAML is a front-channel protocol; hiding the browser hides where messages can be intercepted or replayed.
- Name the binding on each arrow: redirect or POST, and the parameter name. Most configuration errors are a wrong URL or binding.
- Put IDs on the messages. Showing that
InResponseToechoes the request ID explains why it is checked. - Draw the IdP-initiated flow separately, with its risk noted, instead of as a branch of the main diagram.
The sequence diagram example covers participants, fragments and labels in more depth, and the zero trust architecture diagram shows where the identity provider sits in a broader access model.
Build it in Archboard
- Sign in with Google or GitHub and create a diagram.
- Add an Actor from the Sequence pack for the user, then three Lifeline nodes: Browser, Service provider and Identity provider.
- Draw the messages top to bottom as edges between lifelines: the first request, the redirect with
SAMLRequestandRelayState, the request to the IdP, the form withSAMLResponse, the POST to the ACS URL and the final redirect with the session cookie. - Add a Fragment from the Sequence pack with the operator
optaround the login and MFA step. - Add a Markdown block from the Core pack with the validation checklist.
The example uses 6 diagram nodes, within the Free plan.
FAQ
What is the difference between SP-initiated and IdP-initiated SSO? SP-initiated login starts at your app, which sends an AuthnRequest to the IdP. IdP-initiated login starts in the IdP's portal, which posts an unsolicited response to your app. The second has no request to match, which weakens protection against replay and login CSRF.
What is an ACS URL? The Assertion Consumer Service URL is the endpoint in your app that receives the SAML response by HTTP POST. It is configured in the IdP and must match the Destination and Recipient values in each response.
What is RelayState used for? It carries the place the user wanted to reach, so your app can send them there after login. It is limited to 80 bytes and is untrusted input, so use it as a key to server-side state or validate it against an allowlist.
Is SAML outdated? It is older than OpenID Connect and harder to implement safely, but it remains the standard many enterprise identity providers and buyers expect. Most B2B products support both.
Does SAML remove users when they leave the company? No. SAML only authenticates at login. Deprovisioning needs SCIM or another provisioning integration, plus reasonable session lifetimes in your app.