CVE-2025-54419: How two bugs combine to break SAML authentication in Node-SAML 5.0.1
A deep dive into the root causes of CVE-2025-54419: a SAML signature verification bypass involving a C14N spec deviation in XML Processing Instructions and a parser differential.
This vulnerability was discovered and responsibly disclosed by ahacker1-securesaml credited in the GitHub Security Advisory GHSA-4mxg-3p6v-xgq3. The following is an independent technical analysis.
CVE-2025-54419 is a signature verification bypass in Node-SAML versions below 5.1.0. An attacker who controls a legitimate Identity Provider (IdP) account can authenticate as a different, more privileged user. All it takes is a valid signed SAML Response and one obscure XML node type.
What makes this vulnerability interesting is that it is not a single flaw. It is the intersection of two independent bugs that are individually non-exploitable but together allow an authenticated user to modify their own username by removing any characters. In applications with open registration, this becomes a full account takeover.
Connect with the author on LinkedIn

How does SAML signature verification work?
TL;DR: The IdP signs each SAML assertion with its private key, and the SP checks that signature with the IdP’s public key before trusting it. The tricky part is that XML can be written in many equivalent ways, so both sides first convert the document into one standardized format (canonicalization) before hashing and signing it. If thatโs all you need, skip ahead to the next section (What is an XML Processing Instruction?).
How does SAML signature verification work?
SAML 2.0 uses XML Digital Signatures (XML-DSIG) to ensure that assertions from an Identity Provider arrive at the Service Provider unmodified. The process works like this:
- The IdP signs the assertion: it computes a hash (digest) over the canonical form of the XML, then signs that hash with its private key. The digest and the signature are embedded in the response along with metadata about the used algorithms. If you want to dig deeper into the specifics of SAML signing, check out my article about SAML.
- The SP verifies the assertion in two steps. First, it re-canonicalizes the referenced XML and recomputes the digest, confirming the content was not modified after signing. Second, it verifies the signature using the IdP’s public key, confirming that it was produced by the legitimate IdP. Only if both checks pass is the assertion considered authentic.
Why is canonicalization important for XML?
The word “canonical” is the load-bearing term here. XML is problematic for signing because the same logical document can have many valid physical representations (attribute order, whitespace, namespace declarations), all of which change the bytes without changing the meaning. Consider these three entities:

All three represent the same XML node. A naive byte-level hash would produce three different digests. XML Canonicalization (C14N) resolves this by enforcing deterministic rules: attributes are sorted alphabetically by namespace URI then local name, redundant namespace declarations are removed or propagated, whitespace outside elements is normalized, and character encoding is fixed to UTF-8. After canonicalization, all three examples above produce the same byte sequence, and a hash computed over that sequence is stable regardless of how the document was originally serialized.
Exclusive XML Canonicalization
SAML typically uses Exclusive XML Canonicalization (http://www.w3.org/2001/10/xml-exc-c14n#), which serializes only the namespaces actually used by the signed element, excluding inherited namespace declarations from ancestor elements and making signatures stable when assertions are moved between XML contexts (e.g., embedded in a SOAP envelope). In practice, most SAML implementations use the variant without comments, meaning XML comment nodes are excluded from the canonicalized output before signing. Exclusive C14N renders only namespaces that are visibly utilized by the node being canonicalized.
What is an XML Processing Instruction?
Now let’s take a look at XML Processing Instructions, a lesser-known XML feature that’s essential to exploiting this vulnerability.
A Processing Instruction is an XML node that carries out-of-band metadata for the application processing the document. It looks like this:
<?xml-stylesheet href="doc.xsl" type="text/xsl"?>
The general format is <?target data?>, where target identifies who the instruction is for and data is an arbitrary string passed to that processor. PIs can appear almost anywhere in an XML document, including inside elements that carry signed content.
The Exclusive C14N spec delegates PI handling entirely to the base C14N specification. According to C14N 1.1, Section 2.3, the canonicalization rules for PIs are:

Additionally, root-level PIs get a leading or trailing newline (#xA) depending on whether they appear before or after the document element.
The key point is what the spec does not say: there is no rule that merges PI data into adjacent text nodes or discards the PI node. A PI stays a PI, in its original position in document order, with only its surrounding whitespace touched. C14N 1.1, Section 3.1 illustrates this with a concrete example:

But wait, it looks like one PI is missing from the canonical output. The input had <?xml version=”1.0″?> at the top, yet it does not appear anywhere in the result.
The special case: The <?xml ?> declaration
<?xml version=”1.0″?> looks exactly like a Processing Instruction. It uses the same <? and ?> delimiters, it has a target name, and it carries pseudo-attributes. But the XML 1.0 specification explicitly excludes the name xml (in any case combination) from valid PI target names. The XML declaration is a parser-level directive consumed before the document tree is constructed. As a result, the XML declaration never makes it into the tree and therefore has nothing to canonicalize.
Knowing everything there is to know about Processing Instructions and their canonical forms, let’s now look at how xml-crypto handles them.
The Enabler: xml-crypto non-standard C14N
xml-crypto, the Node.js library that Node-SAML uses for signature verification, deviates from the C14N rules for PI nodes. During Exclusive C14N, it folds PI data into the surrounding text stream rather than preserving it as a PI node. The result:

The PI <?x Secu?> disappears as a node, but its data content Secu is absorbed inline, producing SecuRing, byte-for-byte identical to the original signed content. This means an attacker can take a legitimate, validly signed SAML Response and simply wrap any substring of the NameID (or an Attribute) value in a PI node. The signature will remain valid.
On its own, this is a spec compliance bug, not a security vulnerability. Real-world IdPs do not embed PIs in SAML responses, so the scenario never arises in production. Its practical consequence is that xml-crypto will compute the same digest for SecuRing and <?x Secu?>Ring, making a modified document pass signature verification. The PI is silently normalized away, much like how XML comments are stripped during C14N, except that comments are supposed to disappear and PI nodes are not.
The one real-world consequence of this bug is an interoperability issue. If a standard compliant IdP included a PI in a signed SAML response, the following would happen:
- The IdP computes its DigestValue with the PI preserved in C14N form.
- Node-SAML recomputes the digest without the PI, finds a mismatch, and rejects a valid signature.
This would cause Node-SAML to reject a perfectly valid signature, though in practice no real IdP uses PIs in SAML responses, so this remains a theoretical interoperability concern rather than an operational issue.
The Escalation: Node-SAML parser differential
A parser differential occurs when two components in the same processing pipeline parse the same XML document and arrive at different values for the same node. As a result, the XML that gets cryptographically verified is not the same XML from which user identity data is later extracted.
In Node-SAML < 5.1.0, validatePostResponseAsync in src/saml.ts goes through the following steps:
Step 1: Verify the signature
let validSignature = false;
if (validateSignature(xml, doc.documentElement, pemFiles)) {
validSignature = true;
}
validateSignature() used an XPath query to find a Signature element whose Reference URI matched the ID attribute of the current node, then called sig.checkSignature(fullXml) and returned a bare boolean. It told the caller whether something was validly signed, but not what.
Step 2: Extract the assertion from the original XML
const assertions = xpath.selectElements(
doc,
"/*[local-name()='Response']/*[local-name()='Assertion']",
);
Step 3: Pass the original assertion string to identity extraction
return await this.processValidlySignedAssertionAsync(
assertions[0].toString(),
xml,
inResponseTo,
);
processValidlySignedAssertionAsync then parsed that string with xml2js, a completely separate XML parser, and extracted identity data from it, including NameID and any attributes from the AttributeStatement.
The problem is, the two parsers (xml2js and xml-crypto) disagree on PI nodes. xml-crypto folds the PI data into the text stream during C14N. xml2js strips the PI node entirely on extraction. The signature verifier and the identity extractor never compare notes.

The vulnerability applies regardless of where the identity data lives. A PI injected into a NameID and one injected into an AttributeStatement are equally effective. It also does not matter whether the signature covers the Assertionelement or the outer Response element. In both cases, the verified content and the extracted content come from different sources.
CVE-2025-54419: Exploit
The victim is admin. The attacker registers a new account: fakeadmin.
The attacker authenticates through the IdP and receives a legitimately signed SAML Response containing:
<saml:nameid>fakeadmin</saml:nameid>
Before submitting the response to the Service Provider, the attacker wraps the fake prefix in a Processing Instruction:
<saml:NameID><?x fake?>admin</saml:NameID>
The Service Provider receives the modified response and processes it:
- xml-crypto canonicalizes the NameID element, folds fake from the PI body into the text stream, and computes the digest over fakeadmin. The digest matches the signed value. Signature passes.
- Node-SAML reads the NameID from the original document. xml2js drops the PI entirely and returns admin.
- The application authenticates the attacker as admin.
The only prerequisite to this attack is the ability to register an account whose username contains the target username as a substring.
Why both bugs are required?
Neither bug is exploitable alone.

Takeaways
The patch in Node-SAML 5.1.0 fixed the parser differential. Assertions are now read from verified content only, which breaks the exploit chain regardless of what the attacker injects into the document. The xml-crypto C14N spec deviation remains, but it is not a security issue and its practical impact is near-zero given that no real IdP uses PIs in SAML responses.
This one turned out to be a particularly interesting find. Even though I did not discover this vulnerability and it is already a year old, I still decided to share my research on its root causes. It is the first SAML vulnerability I have seen that exploits XML Processing Instructions, and parser differentials are fascinating on their own. If nothing else, it is a good reminder that XML still has a few surprises left in it.