When a client complained that their SSO login suddenly redirected to a phishing page, the stack trace pointed to the SAML response parser. The XML payload looked fine, the signature verified, yet the user was logged in as someone else. The culprit was an XML signature wrapping attack, a subtle trick that lets an attacker slip a malicious assertion past a seemingly valid signature.

How SAML works under the hood

SAML (Security Assertion Markup Language) is an XML-based protocol that lets an identity provider (IdP) assert a user's identity to a service provider (SP). The flow is simple enough to describe in a few steps:

  1. The SP redirects the user to the IdP with a SAML request.
  2. The IdP authenticates the user and builds a SAML response containing an <Assertion> element.
  3. The response is signed with the IdP's private key; the SP validates the signature with the IdP's public certificate.
  4. If the signature checks out, the SP extracts the user attributes and creates a session.

All of that happens inside a single XML document. The signature is calculated over a specific node, usually the <Assertion> itself, and the SP must verify that the signed node is the one it later consumes.

What XML signature wrapping does

The XML Signature spec allows you to sign any element and then reference it elsewhere using an Id attribute. An attacker can exploit this by creating two copies of the <Assertion> element:

<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                ID="_response123">
  <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
    <ds:SignedInfo>…</ds:SignedInfo>
    <ds:SignatureValue>…valid-signature…</ds:SignatureValue>
    <ds:KeyInfo>…IdP-cert…</ds:KeyInfo>
  </ds:Signature>

  <!-- Legitimate assertion that matches the signature -->
  <saml:Assertion ID="_good">
    <saml:Subject>…</saml:Subject>
  </saml:Assertion>

  <!-- Malicious assertion placed after the signed one -->
  <saml:Assertion ID="_evil">
    <saml:Subject>
      <saml:NameID>admin@example.com</saml:NameID>
    </saml:Subject>
  </saml:Assertion>
</samlp:Response>
  • ds:Signature signs the first <Assertion> (_good).
  • The parser that only checks the signature may then pick the last <Assertion> in the document, _evil, and treat it as the user identity.

Because XML is permissive about element order, the signature remains valid while the SP processes the attacker-controlled assertion. That's the essence of a wrapping attack.

Why the bug is easy to miss

Many libraries expose a simple "validateSignature" call and return a boolean. Developers often assume that a true result means the entire document is trustworthy. The library does not automatically bind the signed node to the node you later read; it just tells you something in the document was signed correctly. If you later query for the first <Assertion> without checking its Id, you may inadvertently use the wrong one.

Concrete steps to prevent wrapping

Below is the checklist I now run on every SAML integration. It grew out of a real incident where the IdP was fine but our SP code was lax.

  1. Enforce exclusive ID attributes, Ensure every <Assertion> has a unique ID and that the signature references that exact ID via a Reference element.
  2. Select the signed node explicitly, After validating the signature, locate the node whose ID matches the Reference URI and discard any other assertions.
  3. Reject multiple assertions, If the response contains more than one <Assertion>, abort the login. Legitimate IdPs never send duplicates.
  4. Use a hardened XML parser, Turn on secure processing, disable external entity resolution, and enable namespace-aware parsing. In PHP's XMLSecurityDSig, set setCanonicalMethod to the algorithm you signed with.
  5. Validate the audience and recipient, Check that the <Audience> element matches your SP entity ID and that the <Recipient> attribute points to your ACS URL. This stops an attacker from re-using a signed assertion for a different service.
  6. Log the Signature reference, Store the signed ID in your audit log. If you ever see a mismatch between the logged ID and the assertion you processed, you have a red flag.

Sample code with explicit node selection (PHP)

<?php
use RobRichards\XMLSecLibs\XMLSecurityDSig;
use RobRichards\XMLSecLibs\XMLSecurityKey;

$xml = new DOMDocument();
$xml->loadXML($samlResponse);

$dsig = new XMLSecurityDSig();
$dsig->locateSignature($xml);
$dsig->canonicalizeSignedInfo();
$dsig->validateReference();

$key = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type'=>'public']);
$key->loadKey($idpCert);
if (!$dsig->verify($key)) {
    throw new Exception('Signature verification failed');
}

// Find the signed Assertion ID from the Reference URI
$ref = $dsig->sigNode->getElementsByTagName('Reference')->item(0)->getAttribute('URI');
$signedId = ltrim($ref, '#');

$assertion = $xml->getElementById($signedId);
if (!$assertion) {
    throw new Exception('Signed Assertion not found');
}

// Disallow extra assertions
if ($xml->getElementsByTagName('Assertion')->length > 1) {
    throw new Exception('Multiple Assertions detected');
}

// Continue processing $assertion safely …
?>
  • locateSignature finds the <Signature> block.
  • validateReference ensures the digest matches the signed node.
  • The code extracts the URI from the <Reference> element, strips the leading #, and uses getElementById to fetch exactly the node that was signed.
  • A length check aborts the flow if more than one <Assertion> appears.

Common pitfalls when hardening SAML

  • Skipping canonicalization, Different whitespace or attribute ordering can change the digest. Always call canonicalizeSignedInfo before verification.
  • Relying on XPath shortcuts, An XPath like //Assertion will return the first match, which may be the malicious one. Use the ID-based lookup instead.
  • Assuming the IdP never changes, Some IdPs rotate signing keys. Keep your certificate store up-to-date and validate the KeyInfo against a trusted source.

When to consider a library upgrade

I was using a popular SAML toolkit that abstracted away the signature check. A later version added a SignedInfo flag that forces the library to return the signed node instead of a generic boolean. Upgrading saved me from rewriting the verification logic myself. If your toolkit still returns only a boolean, you either need to patch it or switch to one that exposes the signed reference.

Testing your implementation

A quick way to confirm you’re safe is to craft a minimal wrapping payload (similar to the snippet above) and feed it to your SP in a test environment. Your code should reject it with an error about multiple assertions or a mismatched ID. Automate this test in your CI pipeline; a single failing test will alert you if a future library change re-introduces the flaw.

FAQ

How can I tell if my IdP is vulnerable to wrapping?

The IdP itself is not vulnerable; the issue lies in how the SP consumes the response. However, you can ask the IdP to include a Reference element that points to the exact assertion ID and to sign only that node. If they already do, the risk is lower, but you still need to enforce the checks on your side.

Does XML signature wrapping affect JWT-based SSO?

No. JWTs are compact, signed strings that do not contain nested XML elements, so the wrapping technique does not apply. The lesson, however, remains: always verify that the token you decode is the one you signed.

What if I need to support multiple assertions for a single response?

That pattern is rare and typically only used for attribute aggregation. If you must support it, process each assertion individually, verify each signature, and ensure none of the assertions can be swapped by ordering. Prefer a design where each assertion is signed separately.

Wrapping up

If you ever see a SAML login that works in the IdP console but logs the wrong user on your site, the first thing to check is whether you’re inadvertently processing an unsigned assertion. Bind the signature to the exact node, reject duplicate assertions, and keep your XML parser in a hardened mode. Those few lines of defensive code have saved me from a serious security breach and will keep your SSO flow trustworthy.