XML Signature Wrapping, Explained With Actual XML

Most explanations of XML Signature Wrapping stop at the summary: "the attacker moves the signed element somewhere else and the parser reads the wrong one." That sentence is accurate and completely useless. It doesn't tell you what your code is doing wrong, and it doesn't tell you what a fix looks li

Most explanations of XML Signature Wrapping stop at the summary: "the attacker moves the signed element somewhere else and the parser reads the wrong one." That sentence is accurate and completely useless. It doesn't tell you what your code is doing wrong, and it doesn't tell you what a fix looks like.

So here is the attack in XML, with the actual documents, and then the architectural reason it keeps happening — including to vendors with security teams larger than your engineering org.

The thesis is short: "we validate the signature" is not the same as "we are safe." XSW exploits the gap between where a signature is cryptographically valid and where the application reads its data from. Both operations succeed. Neither is lying. They're just talking about different elements.

The legitimate document

Here's a simplified SAML Response containing one signed assertion. Namespaces trimmed for readability, but the structure is real.

<samlp:Response ID="_resp1" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
  <saml:Assertion ID="_abc" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
    <saml:Issuer>https://idp.example.com</saml:Issuer>
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
      <ds:SignedInfo>
        <ds:Reference URI="#_abc">
          <ds:DigestValue>Kx9...=</ds:DigestValue>
        </ds:Reference>
      </ds:SignedInfo>
      <ds:SignatureValue>Qm1...=</ds:SignatureValue>
    </ds:Signature>
    <saml:Subject>
      <saml:NameID>[email protected]</saml:NameID>
    </saml:Subject>
  </saml:Assertion>
</samlp:Response>

The important line is URI="#_abc". An XML signature does not sign "the document" or "the assertion." It signs whatever element has the ID _abc, canonicalized, hashed, and covered by DigestValue. The reference is an indirection — a pointer resolved at verification time.

That indirection is the entire vulnerability. Everything below follows from it.

The wrapped document

The attacker has a legitimately signed assertion for [email protected] — perhaps their own, obtained by logging in normally. They cannot forge a signature. They don't need to.

<samlp:Response ID="_resp1" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">

  <samlp:Extensions>
    <!-- The original, untouched, still-valid signed assertion -->
    <saml:Assertion ID="_abc" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
      <saml:Issuer>https://idp.example.com</saml:Issuer>
      <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
        <ds:SignedInfo>
          <ds:Reference URI="#_abc">
            <ds:DigestValue>Kx9...=</ds:DigestValue>
          </ds:Reference>
        </ds:SignedInfo>
        <ds:SignatureValue>Qm1...=</ds:SignatureValue>
      </ds:Signature>
      <saml:Subject>
        <saml:NameID>[email protected]</saml:NameID>
      </saml:Subject>
    </saml:Assertion>
  </samlp:Extensions>

  <!-- Forged, unsigned, and sitting exactly where the app looks -->
  <saml:Assertion ID="_evil" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
    <saml:Issuer>https://idp.example.com</saml:Issuer>
    <saml:Subject>
      <saml:NameID>[email protected]</saml:NameID>
    </saml:Subject>
  </saml:Assertion>

</samlp:Response>

Now run the two passes your SP actually performs.

Pass 1, signature verification. The library finds a ds:Signature. It reads URI="#_abc". It resolves that ID against the document, finds the original assertion inside Extensions, canonicalizes it, computes the digest, compares to DigestValue — match. It checks SignatureValue against the IdP's public key — match. The library returns true. It is not wrong. There genuinely is a valid signature over element _abc, and element _abc is genuinely unmodified.

Pass 2, data extraction. The application does something like response.getElementsByTagNameNS(SAML_NS, "Assertion").item(0) or an XPath like /Response/Assertion. Depending on the API, it gets either the first assertion in document order or the first direct child — and in a well-crafted variant, that's _evil. It reads NameID, gets [email protected], and logs the attacker in as an administrator.

Nothing crashed. No exception. Both operations reported success. The bug is that nobody compared their answers.

Why this is an architecture problem, not a parsing bug

Here's the part that matters more than the XML: signature verification and business-data extraction are two independent traversals of the same document, and typically through two different APIs.

Verification happens inside a crypto library (xmlsec, SignedXml, Apache Santuario) that thinks in terms of ID resolution and canonicalization. Extraction happens in your code, thinking in terms of DOM navigation, XPath, or an object binding. The crypto library returns a boolean. That boolean is the only thing that crosses the boundary.

flowchart LR
    D[XML document] --> V["Verifier<br/>resolves ID '_abc'"]
    D --> X["App parser<br/>selects first Assertion"]
    V --> B["true"]
    B --> A["Application logic"]
    X --> A
    A --> R["Logs in as whatever<br/>the parser found"]

The verifier knows exactly which node it validated. It has a reference to it in memory. Then it throws that away and hands you a boolean. Your code then goes and finds an assertion by itself, with no notion that it should be the same node.

That is the root cause. Everything else — the wrapper placement, the namespace tricks — is just a search for a document shape where "the node the verifier resolved" and "the node the parser selects" diverge. Given a large enough XML surface, that shape almost always exists.

Variants worth knowing by name

ID shadowing / duplicate IDs. Two elements carry ID="_abc". The verifier's ID resolution and the parser's getElementById may pick different ones, especially when one implementation is schema-aware about which attributes are of type ID and the other isn't. Well-formedness rules say IDs must be unique; without schema validation, nothing enforces it.

Wrapping in Object or Extensions. ds:Object (a child of ds:Signature itself) and samlp:Extensions both accept arbitrary content by schema. They're legal hiding places for a signed original that the application will never look inside.

Namespace confusion. The forged assertion is placed in a subtly different namespace, or namespace declarations are moved so that canonicalization of the signed element produces identical bytes while the parser's namespace-aware selectors resolve differently. Exclusive canonicalization deliberately omits ancestor namespace context, which is exactly what makes a signed fragment portable between positions in the tree.

XPath transform abuse. ds:Transform can carry an XPath expression narrowing what's signed. An attacker-influenced transform can shrink coverage to a nearly empty node set while the signature still verifies over that (tiny) set.

DTD and entity tricks. An internal DTD subset can declare an attribute as type ID, changing how a DTD-aware parser resolves references — while the verifier, using a different parser configuration, resolves them the old way. This is the same family as XXE, and the same mitigation applies.

The defense that does not work

Checking that a valid signature exists.

if verify_signature(xml, idp_cert):     # returns bool
    assertion = xml.find(".//Assertion")  # unrelated lookup
    login(assertion.find(".//NameID").text)

This is the shape of the majority of vulnerable implementations, and it's worth being honest about why it's so common: it is what every library's README shows you. The API surface is a boolean. The tutorial says "verify, then parse." The code review looks correct — there is a crypto check, and it is before the sensitive operation. The bug is invisible because the wrong thing is absent, not present.

The defenses that actually work

Validate against the schema before signature processing. Strict schema validation kills a large fraction of wrapping shapes outright — unexpected children, misplaced assertions, duplicate IDs where the schema declares ID type. Do it first, so the crypto layer never sees a document with a weird shape.

Extract data only from the node that was verified. This is the one that actually closes the class. Don't re-query the document. Take the node reference the verifier resolved and pass it forward:

signed_node = verify_and_return_signed_node(xml, idp_cert)  # raises on failure
name_id = signed_node.find("./Subject/NameID").text          # relative to signed node

Every selector after verification must be rooted at signed_node, never at the document. If your library doesn't expose the verified node, that is a reason to change libraries, not a reason to work around it.

Reject documents with more than one Assertion. You expect exactly one. Two is either a bug or an attack; neither deserves a login.

Reject documents containing duplicate IDs. Walk the tree once, collect ID attributes, fail on any repeat.

Disable DTD processing entirely. No external entities, no internal subset. This removes an entire divergence surface between parsers and costs you nothing — SAML has no legitimate use for DTDs.

Prefer "show me what is signed" APIs over "is something signed." The question you need answered is not is there a valid signature in this document but which node is covered by a signature from this issuer's key. Any API that can only answer the first is the wrong abstraction for this problem.

The honest part

The obvious counterargument: this is why some teams encrypt assertions, and why more teams just prefer OIDC. Both have real merit, and both are oversold.

Encrypting the assertion helps — an attacker who can't read the plaintext has a much harder time constructing a wrapper around it. But it's a confidentiality control being used as an integrity control. If the attacker can obtain a validly signed assertion issued to themselves (usually trivial — log in), decryption happens before parsing anyway, and the wrapping problem reappears on the decrypted DOM. Encryption raises cost; it doesn't remove the class.

Preferring OIDC is more defensible, mostly because JWT's compact serialization has no equivalent indirection: the signature covers the exact bytes of the exact payload you're about to decode. There is no "which node" question, because there are no nodes. That's a genuine structural advantage, and it's the strongest argument for JWT-based protocols that isn't about developer convenience.

But JWT has its own version of this bug, and it's the same bug. alg: none and RS256-to-HS256 confusion are also cases where verification "succeeds" while validating something other than what you assumed — there, the mismatch is between the algorithm you think you're checking and the one the token declares. Same structure: the attacker controls a parameter that the verifier obeys and the application never inspects. And on the newer side, JSON serialization of JWS with unprotected headers reintroduces some of the ambiguity XML has.

The generalizable lesson is not "XML is bad." It's this: verify and use the same object. Any protocol where the verification step and the consumption step can disagree about which thing they're operating on will grow this vulnerability class. XML made the gap wide and easy to find. It did not invent it.

Also worth saying plainly: XSW has hit nearly every major SSO vendor at least once, including ones with dedicated appsec teams and formal review processes. If your reaction to this article is "our implementation is fine," that reaction is itself the risk factor. The bug is invisible to code review by design.

A checklist you can actually run

Against your own SP, today:

  1. Find the extraction code. After signature verification, is any selector rooted at the document rather than the verified node? If yes, you are probably vulnerable.
  2. Does your verify function return a node, or a boolean? A boolean means the binding between verified and used is enforced by convention, i.e. not enforced.
  3. Send a Response with two assertions — one validly signed, one forged with a different NameID, as a sibling. Do you reject it? Try the forged one both before and after the signed one.
  4. Send the signed assertion inside samlp:Extensions with a forged one as a direct child of Response.
  5. Send the signed assertion inside ds:Object within the Signature element itself.
  6. Send two elements with the same ID. Does anything complain?
  7. Send a document with an internal DTD subset declaring an attribute as ID. If it parses at all, DTD processing is enabled — fix that first.
  8. Send a Response where the signature is valid but over the Response, not the Assertion (or vice versa). Does your code care which one was signed? It should — decide which you require and enforce it.
  9. Check schema validation ordering. Is it before or after crypto? Before.
  10. Check what happens with zero signatures but a well-formed assertion. Some code paths only verify if a signature is present.

Tests 3 through 8 belong in your test suite permanently, not in a one-off pentest. This class of bug is reintroduced by refactoring — someone "cleans up" the extraction code, replaces a relative selector with a document-level XPath, and every test still passes, because no normal test distinguishes those two lookups. Only the malicious documents do.