IdP-Initiated vs SP-Initiated SAML: The Difference Is Where the Journey Starts
Put the two flows side by side at the protocol level and they look almost identical. Same assertion format. Same XML signature over the same elements. Same trust model — you configured the IdP's certificate, you check the signature, you accept the statement. If you diffed two Response documents from
Put the two flows side by side at the protocol level and they look almost identical. Same assertion format. Same XML signature over the same elements. Same trust model — you configured the IdP's certificate, you check the signature, you accept the statement. If you diffed two Response documents from the same IdP, one from each flow, you'd find a handful of attributes differing and nothing structural.
And yet one of them can have replay protection and the other one, by construction, cannot. Not "is often deployed without it" — cannot have it, because the thing you would correlate against does not exist.
That's the whole article. The rest is why it matters more than it sounds like it should.
SP-initiated: the SP asks a question and remembers asking
The user hits your application, has no session, and you begin the flow. You generate an AuthnRequest with a unique ID, you store that ID somewhere you can look it up later — session, signed cookie, short-lived cache — and you redirect the browser to the IdP.
The IdP authenticates the user and produces an assertion. Crucially, the Response carries InResponseTo="<the ID you generated>", and the SubjectConfirmationData carries the same value. The assertion is not a general statement about the user. It is an answer to a specific question you asked, addressed to you, correlated to a request you can prove you issued.
sequenceDiagram
participant U as Browser
participant SP as Service Provider
participant IdP as Identity Provider
U->>SP: GET /reports/q3
SP->>SP: Generate AuthnRequest ID=_a7f3, store it
SP->>U: Redirect with AuthnRequest (RelayState=/reports/q3)
U->>IdP: AuthnRequest
IdP->>U: Signed Response, InResponseTo=_a7f3
U->>SP: POST assertion + RelayState
SP->>SP: Signature, audience, conditions, Destination
SP->>SP: Does _a7f3 match a request I issued and haven't consumed?
SP->>U: Session, redirect to /reports/q3
That last check is the one that does the work. An assertion captured from another user's flow carries an InResponseTo the SP never issued. An assertion replayed a second time carries an ID already consumed. Correlation is a nonce, and a nonce is what turns a bearer credential into a single-use one.
IdP-initiated: an answer to a question nobody asked
The user is in the Okta or Entra app launcher. They click a tile. The IdP mints an assertion for that SP and POSTs it at the SP's ACS endpoint. The SP had no idea this was coming.
There is no AuthnRequest, so there is no request ID, so InResponseTo is absent — and the spec is explicit that it must be absent, because there is nothing to reference. This is what "unsolicited" means. It isn't laxity in someone's implementation; it's the definition of the flow.
Now consider what the SP has to work with. A signed XML document that says "this is [email protected], issued at 14:02, valid until 14:07, intended for this audience." Every one of those facts is true of a legitimate assertion and equally true of a copy of that assertion. The signature proves the IdP said it. It proves nothing about who is delivering it or how many times.
The attack is unglamorous and entirely practical. The assertion travels as a base64 blob in an HTML form POST body, and lands in more places than people expect:
- A corporate TLS-terminating proxy that logs request bodies. Plenty do, for DLP.
- A malicious or merely overreaching browser extension with
webRequestaccess. - A shared or kiosk machine where the auto-submitting form page sits in history and back-button territory.
- An SP-side application log where someone helpfully logged the full POST body while debugging an integration, and never took it out.
Anyone with the blob POSTs it to the same ACS endpoint from their own browser. Signature valid, audience correct, NotOnOrAfter not yet passed. The SP has no basis to reject it. It creates a session as alice.
The window is typically five minutes because that's what everyone's default is. Five minutes is a long time when the capture is automated.
The only defense that actually works is a replay cache: the SP records every assertion ID it has accepted and refuses a repeat until the validity window closes. This is real, mandatory state that the SP must maintain correctly across every node behind its load balancer. In SP-initiated flows, request correlation gives you the same property largely for free, and the replay cache is defense in depth. In IdP-initiated flows the replay cache is the defense, it's the only one, and a surprising number of SAML libraries make it opt-in or leave it to the host application entirely.
RelayState, and the redirect bug nobody talks about
RelayState is deliberately underspecified: opaque state the SP round-trips through the IdP so it can remember what the user was doing before the detour. Send it with the AuthnRequest, get it back with the Response, resume.
The word doing all the work there is round-trips. In SP-initiated flows, the SP generated the value. It should treat the returned value as untrusted anyway — it went through the browser — but the well-designed version is an opaque key into server-side state, not the destination itself. The destination never leaves your process.
In IdP-initiated flows there is no round trip, because there was no outbound leg. RelayState is now a value the IdP supplies, and in practice it's configured per-tile by an administrator and frequently just is a URL path. So SPs implement it the obvious way: read RelayState, redirect there after establishing the session.
That's an open redirect gated on nothing. The value arrives in a form POST the user's browser submits, so anyone who can construct the POST controls it. And it's an open redirect with a freshly minted authenticated session attached, which is the version that matters — send the user to an attacker-controlled page immediately post-login, at the exact moment they're primed to re-enter credentials or approve something. If the SP puts tokens in the URL fragment or the landing page reflects query parameters, it gets worse from there.
The fix is boring: RelayState is either an opaque server-side key, or it is validated against an allowlist of relative paths. Never a URL you follow because it was in the request.
Why IdP-initiated exists anyway
It would be easy to end here with "never accept it," and that advice would be ignored for good reason.
The app launcher is a genuine product. Users open one portal, see the applications they have access to, and click one. There is no SP to start at — the whole point is that the user hasn't chosen a destination yet. Making that tile work SP-initiated means it links to the SP's login URL and the SP bounces back to the IdP. That works, and it's what modern IdPs do when you let them, but it requires the SP to expose a stable initiation endpoint and the IdP admin to configure it correctly.
Deep links have the same shape. "Approve this expense" in an email should land the user on the expense, and if they aren't authenticated the SP should start a flow preserving that destination — again solvable SP-initiated, again requiring the SP to have built it.
IdP-initiated persists because it works without the SP building anything. That's a real advantage during integration and a liability forever after.
If you must accept it
- Compress the window.
NotOnOrAfterat 60–120 seconds, not five minutes. The assertion only needs to survive one browser POST. - Replay cache, not optional. Keyed on assertion
ID, shared across all nodes, TTL at least as long as the validity window plus clock skew. Verify it's actually shared — the classic failure is a per-process in-memory cache behind four app servers, which gives an attacker four free replays. - Validate
DestinationandRecipientstrictly against your exact ACS URL. This is what stops an assertion minted for one SP being POSTed at another, and it's routinely skipped because it "worked without it." - Allowlist
RelayStatetargets. Relative paths only, or opaque keys. - Bound the blast radius. Sessions established via unsolicited assertion can be shorter-lived, or barred from step-up-requiring operations without reauthentication.
Where this argument breaks down
SP-initiated is not a safety property you acquire by choosing a flow. It's a set of checks you have to actually perform.
The common failure is exactly the one this article is built around, inverted: SPs that read InResponseTo, find it present and well-formed, and proceed. Present is not the same as valid. The check has to be does this ID match a request I issued, for this session, that I have not already consumed — and then consume it. An SP that accepts any InResponseTo value has the ceremony without the protection, and is in exactly the position of an IdP-initiated SP that believes it's safe. That's arguably worse, because nobody flagged it in the design review.
Everything else applies equally to both flows and is skipped at similar rates: signature validation on the right element (XML Signature Wrapping has embarrassed nearly every major implementation at least once), AudienceRestriction enforcement, NotBefore/NotOnOrAfter with a bounded skew allowance, certificate rotation that doesn't quietly fall back to accepting unsigned documents.
And the honest cost note: the replay cache you need for IdP-initiated is the same replay cache you should have for SP-initiated. Choosing SP-initiated doesn't let you skip it. It lets you have two independent defenses instead of one.
The recommendation
Default to SP-initiated. It's the only flow where the protocol gives you a correlation value, and correlation is the difference between a credential that can be replayed and one that can't.
The conditions under which that flips: you're integrating with an enterprise whose identity team has already built the tile, the users expect the launcher, and the alternative is a six-week change request against an IdP you don't control. Take the IdP-initiated integration — and take the full mitigation list with it, especially the shared replay cache and the RelayState allowlist, because in that configuration they are not hardening. They're the security model.
The thing worth carrying away is smaller than the flow diagram: an assertion nobody asked for is a bearer token with a timestamp. Everything you do to secure IdP-initiated SAML is an attempt to reconstruct, in application state, the single-use property that SP-initiated gets from having asked a question first.