← All posts

The SAML signature was valid. That was never the problem.

Authagonal·July 16, 2026
samlssosecurity

SAML single sign-on has a replay problem built into its shape, and most service providers "solve" it in the one place an attacker can reach.

Here is the setup. You are the service provider. A user lands on your login, you send an AuthnRequest to their identity provider, and that request carries a random ID. The IdP authenticates the user and posts back a SAML Response with InResponseTo="<that ID>". You look up the ID, confirm you actually issued it, and consume it so it can never be used twice. Request bound to response, response used once, replay defeated. Textbook.

Now the question that breaks it: what did the IdP actually sign?

Not the Response. The Assertion. Entra, Okta, most of the field sign the <Assertion> element and leave the <Response> envelope around it unsigned. That is normal, and the spec allows it. But InResponseTo lives on the <Response>. So the field your entire replay defense keys off is the one field the signature does not cover. You can edit it, delete it, do whatever you like to it, and the assertion's signature still validates perfectly, because you never touched the assertion.

So watch what happens when an attacker does the obvious thing.

SAML has two flavors. SP-initiated responses answer a request you sent, so they carry InResponseTo. IdP-initiated (unsolicited) responses were never asked for, so they have no InResponseTo at all. Your code, reasonably, branches on this: if there is an InResponseTo, match it to a stored request and consume it; if there is not, there is no request to match, so it skips that check. That skip is the whole vulnerability.

The attack is three steps and zero cryptography:

  1. Capture one real, successful SAML Response. A genuine login, a genuine signature, a genuine assertion.
  2. Delete the InResponseTo attribute. The signature covers the assertion, not the envelope, so it still verifies.
  3. Post it back. With no InResponseTo, your SP treats it as IdP-initiated, takes the branch with no replay check, validates the signature (valid), and logs the user in.

Then post it again. And again. The assertion is a skeleton key now. Every check you were proud of still passes: signature valid, certificate pinned, issuer correct, conditions in date. The one control that would have stopped a replay was keyed to a field the attacker deletes for free.

Here is the trap in one line: you signed the assertion, but you gated on the envelope.

The fix is not more validation. It is validating the right thing. Stop keying replay protection off InResponseTo, which is unsigned and optional, and key it off the assertion's own ID, which is signed and always present. Every assertion has an ID, it sits inside the signature, and tampering with it breaks verification. Keep a one-time-use cache of assertion IDs, bounded by each assertion's NotOnOrAfter so the cache never grows without limit, and reject any ID you have already seen. Now deleting InResponseTo buys the attacker nothing, because the value you actually check cannot be forged and cannot be replayed.

While you are in there, treat the request binding as defense in depth rather than the primary control. Still match InResponseTo when it is present. Still reject unsolicited responses outright if you never offer IdP-initiated login. Still enforce Audience and the Conditions window. But the load-bearing anti-replay check has to sit on signed bytes, full stop.

We know this one because we found it auditing our own SAML before we launched, not because a customer found it afterward. And the lesson generalized well past SAML. A signature answers exactly one question: did the holder of this key produce these bytes. It does not tell you the bytes are fresh, that they were meant for you, or that you have not already accepted them once. Those are three separate checks, and every one of them has to read a field the signature actually covers. The moment a security decision depends on data sitting outside the signature, it stops being a security decision and becomes a polite request that an attacker is free to decline.

Sign the field you gate on, or gate on the field that is signed. There is no third option that survives a replay.

If you would rather not hand-roll SAML validation and get the replay case wrong, that is a good reason to let someone else run it. Authagonal keys replay off the signed assertion ID, so stripping InResponseTo goes nowhere.