Why Unsigned SAML Is Dangerous

Your ACS endpoint is a URL on the public internet that accepts a POST with a Base64-encoded XML document and, if it likes what it reads, logs someone in.

Your ACS endpoint is a URL on the public internet that accepts a POST with a Base64-encoded XML document and, if it likes what it reads, logs someone in.

Say that sentence out loud, slowly. Everything about SAML security follows from it. The endpoint has no idea who sent the request. It has no session with the IdP. It does not know whether the browser posting the form ever visited the IdP. The only thing standing between "this XML claims to be a successful authentication for [email protected]" and an actual admin session is the cryptographic signature — and whether your code checked it, correctly, before believing anything.

Let's demonstrate what happens when it doesn't. Not abstractly: with the document.

The document

A minimal SAML response, decoded. This is what arrives at your endpoint, in the SAMLResponse form field.

<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                ID="_8e8dc5f69a98cc4c1ff3427e5ce34606fd672f91e6"
                InResponseTo="_2b8f1c9d4a"
                Destination="https://app.example.com/saml/acs"
                IssueInstant="2026-08-17T10:22:14Z">
  <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
    https://idp.customer.com/metadata
  </saml:Issuer>
  <samlp:Status>
    <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
  </samlp:Status>
  <saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="_a1b2c3">
    <saml:Issuer>https://idp.customer.com/metadata</saml:Issuer>
    <saml:Subject>
      <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
        [email protected]
      </saml:NameID>
      <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
        <saml:SubjectConfirmationData NotOnOrAfter="2026-08-17T10:27:14Z"
                                      Recipient="https://app.example.com/saml/acs"
                                      InResponseTo="_2b8f1c9d4a"/>
      </saml:SubjectConfirmation>
    </saml:Subject>
    <saml:Conditions NotBefore="2026-08-17T10:22:14Z"
                     NotOnOrAfter="2026-08-17T10:27:14Z">
      <saml:AudienceRestriction>
        <saml:Audience>https://app.example.com/saml/metadata</saml:Audience>
      </saml:AudienceRestriction>
    </saml:Conditions>
    <saml:AuthnStatement AuthnInstant="2026-08-17T10:22:14Z">
      <saml:AuthnContext>
        <saml:AuthnContextClassRef>
          urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport
        </saml:AuthnContextClassRef>
      </saml:AuthnContext>
    </saml:AuthnStatement>
    <saml:AttributeStatement>
      <saml:Attribute Name="groups">
        <saml:AttributeValue>engineering</saml:AttributeValue>
      </saml:Attribute>
    </saml:AttributeStatement>
  </saml:Assertion>
</samlp:Response>

Every claim your application will act on is in there: who the user is, which groups they're in, that authentication succeeded.

Now notice what is not in there. There is no <ds:Signature> element. This document is unsigned.

If your SP accepts it, here is what an attacker does. They log into the customer's SSO once as themselves — an ordinary employee, or a guest account, or any identity they legitimately hold. They capture the POST body from their browser's developer tools. They Base64-decode it, change one line:

        <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
          [email protected]
        </saml:NameID>

and while they're in there, since it costs nothing:

      <saml:Attribute Name="groups">
        <saml:AttributeValue>engineering</saml:AttributeValue>
        <saml:AttributeValue>global-admin</saml:AttributeValue>
      </saml:Attribute>

They re-encode, POST it, and they are the CEO with admin rights.

No cryptography was attacked. No vulnerability was exploited in the sense of a memory bug or an injection. They edited a text file. The total skill required is "can use base64 -d and a text editor," and the tooling to do it graphically has existed for over a decade as a standard browser extension.

Base64 is not a security boundary

The reason this attack surprises people — and it does surprise people, repeatedly, in real code review — is a subtle intuition failure about encoding.

Base64 output looks opaque. PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1cm46... reads as ciphertext to the eye. It has the texture of something protected. And a developer who has only ever seen SAML in its encoded form can carry an unexamined assumption that it's hard to tamper with.

It is an encoding. It has no key. Its purpose is to let arbitrary bytes survive an HTTP form field, exactly like URL-encoding or hex. Decoding it is a single shell command. The transformation is public and reversible by construction.

This same intuition failure produces JWTs treated as opaque, session cookies with Base64-encoded user IDs treated as unguessable, and "encrypted" configuration files that are Base64 with extra steps. The rule is worth stating in the strongest available terms: if it doesn't have a key, it isn't protecting anything.

The four ways this goes wrong in real code

Almost nobody deliberately ships an SP that skips signature validation. The failure is more specific than that, and it takes four recognizable forms.

1. Accept-if-present. The code checks: is there a Signature element? If yes, validate it; if no, proceed. This reads as defensive — it's validating whenever it can! — and it is completely bypassable, because the attacker simply removes the signature element along with their other edits. The control must be required, not applied when available. Any code path where the absence of a signature leads anywhere other than rejection is equivalent to no validation.

2. Signing the response but not the assertion (or vice versa), with an SP that accepts either. SAML permits the signature over the Response, over the Assertion, or both. An SP that is satisfied by a signature on either element has a gap: if only the response is signed, an attacker who can get any signed response can swap the assertion inside it; if only the assertion is signed, the response's Status and Destination are unprotected.

The correct posture: decide per connection what must be signed, configure it explicitly, and reject anything that doesn't match. If you accept "assertion signed" — which is the more common configuration and generally the better one — then verify that the signature covers the specific assertion element whose claims you consume. Which is not the same as verifying that a valid signature exists somewhere in the document; that gap is the subject of signature-wrapping attacks and it has hit nearly every major implementation at some point.

3. Trusting the certificate embedded in the message. SAML signatures usually include a <ds:KeyInfo> with the signing certificate inline. Some implementations read that certificate and use it to verify the signature. This is not validation; it's asking the document to grade its own homework. The attacker generates a keypair, signs their forged assertion, embeds their own certificate, and the signature verifies perfectly.

The trust anchor must come from configuration — the certificate you obtained from that specific IdP's metadata, out of band, pinned to that connection. Any embedded certificate is at most a kid-style hint about which of your configured certificates to try. If the embedded certificate is not one you already trust for that issuer, the message is rejected.

This one has real-world weight: it was the shape of the Golden SAML technique's abuse of stolen signing keys, and of multiple CVEs in SAML libraries where an unconfigured trust store meant any self-signed certificate was accepted.

4. Validating the signature but not the constraints. A valid signature only proves the document is authentic and unaltered. It does not prove the document is for you, for now, or for this login attempt. Which brings us to the second half of the problem.

A valid signature is necessary and not sufficient

Assume the signature is now validated correctly against a pinned certificate. Here's what an attacker can still do.

Replay. They capture their own legitimate, correctly-signed assertion and POST it again. And again. Without replay protection, one captured assertion is a reusable login. Worse: an assertion captured from a user's browser — via a malicious extension, a shared machine, a proxy, or browser history if a Redirect binding was used — is a session-minting credential for as long as your SP accepts it.

The defences, and you need all of them:

  • NotBefore / NotOnOrAfter in Conditions, enforced with a tight clock-skew tolerance. Minutes, not hours. An assertion valid for eight hours is an eight-hour replay window, and generous skew tolerance is how deployments accidentally create one.
  • A used-assertion cache keyed on the assertion ID, retained for at least the assertion's validity window. This is what actually prevents replay; the timestamps only bound it. An SP without this cache has no replay protection, only a replay deadline.
  • InResponseTo matched against a request ID you generated and stored. This binds the response to a specific authentication request that your SP initiated for a specific browser session. It is the strongest single anti-replay control available.

Which is the reason IdP-initiated SAML is structurally weaker, and worth stating plainly: in an IdP-initiated flow there is no request, so there is no InResponseTo, so there is nothing to correlate against. You are accepting an unsolicited assertion and must rely entirely on timestamps and the ID cache. If you support IdP-initiated flows, that's a deliberate acceptance of a weaker replay posture — narrow the validity window hard, make the ID cache mandatory, and prefer SP-initiated wherever the customer's IdP allows it.

Cross-audience use. An attacker takes a correctly-signed assertion issued for a different service provider — one their own company also uses, or one they operate — and posts it to yours. The signature is valid. The issuer is a trusted IdP. If you don't check AudienceRestriction against your own entity ID, you accept it.

Enforce Audience equals your entity ID, exactly. Also enforce Destination and SubjectConfirmationData/@Recipient equal your ACS URL, which is the same defence at two other layers.

Wrong-issuer acceptance in multi-tenant systems. This is the multi-tenant SP's version, and it's the most damaging bug in the family. Tenant A's IdP signs an assertion for [email protected]. Your SP validates the signature — correctly! — against tenant A's configured certificate. But the code then resolves the user by NameID globally rather than within tenant A. If the NameID says [email protected], tenant A's IdP has just authenticated a user in tenant B.

The rule: the connection determines the tenant, and the assertion may only assert identities within that tenant's namespace. The signature tells you which IdP spoke; it tells you nothing about whose users that IdP is entitled to speak for, and that entitlement is a property of your configuration, not of the document.

The validation checklist

In the order it must happen, for every response:

  1. A signature is present on the required element(s) for this connection — or reject. No conditional path.
  2. The signature verifies against a certificate configured for this connection's issuer. Never against an embedded certificate that isn't already trusted.
  3. The signature covers the element you consume. Resolve the reference and confirm it points at the assertion whose claims you read — not merely that a valid signature exists in the document.
  4. Issuer matches the expected issuer for this connection.
  5. Status is Success — before parsing anything else in the assertion.
  6. Destination and Recipient match your ACS URL.
  7. Audience matches your entity ID.
  8. NotBefore / NotOnOrAfter are satisfied, with small skew tolerance.
  9. InResponseTo matches a stored, unconsumed request ID from this browser session — and for IdP-initiated flows, note explicitly that this check is absent.
  10. The assertion ID has not been seen before. Record it.
  11. The subject and any attributes are resolved within this connection's tenant, never globally.

Only then may you create a session.

One more thing, because it's the version of this bug that ships in 2026

Nobody writing a SAML SP today implements XML signature validation themselves, and they shouldn't. The library handles it.

Which relocates the danger without removing it: the failure mode is now a configuration in which validation is off, optional, or trust-anchored on nothing. wantAssertionsSigned: false. An empty IdP certificate field, because the connection was set up during a trial when the customer hadn't sent their metadata yet and it worked, so nobody went back. A dev-environment flag that disables validation, copied into a production Helm chart.

So the practical version of this article's advice isn't "validate signatures." It's: go and look at what your SP configuration currently says, per connection, for every customer. Then make the insecure configuration impossible to express — no boolean that turns validation off, no connection that can reach active state without a pinned certificate, and an alert on any connection whose certificate field is empty.

A checklist is a good thing to have. A configuration that cannot represent the unsafe state is better, because checklists are applied by people under deadline pressure and constraints are applied by the system every time.