Originally published at adityarawas.in
Every few years, a security research team publishes a deep dive into SAML and the conclusion is always the same: the protocol is a minefield disguised as a standard. Trail of Bits' recent breakdown calling SAML "a fractal of bad design" isn't hyperbole — it's a pattern recognized by anyone who has implemented SSO in production. If you're a full-stack engineer tasked with wiring up enterprise authentication, understanding why SAML keeps breaking is more valuable than memorizing its XML schema.
This post breaks down what makes SAML fundamentally fragile, the specific vulnerability classes that recur across implementations, and how to build authentication systems today that avoid the same traps — using OIDC, JWT, and modern Node.js tooling.
Why SAML Keeps Failing Security Audits
SAML (Security Assertion Markup Language) was standardized in 2002. It was designed for an era of enterprise SOAP services, not modern web apps. The core problem isn't a single bug — it's architectural. SAML combines three notoriously hard problems and stacks them on top of each other:
- XML parsing — one of the most exploit-prone data formats in existence
- XML Digital Signatures (XML-DSig) — a spec so flexible it allows valid signatures to apply to different content than what's rendered
- Federated trust — multiple parties (IdP, SP, browser) must agree on identity without a shared session
Each layer introduces its own attack surface, and they compound. A parser bug plus a signature ambiguity plus a trust boundary confusion equals authentication bypass.
The XML Signature Wrapping Problem
The single most common SAML vulnerability class is XML Signature Wrapping (XSW). Because XML-DSig signs a reference to an element (by ID), not the element's position in the document tree, an attacker can:
- Take a validly-signed SAML assertion
- Copy it elsewhere in the XML document
- Insert a malicious, unsigned assertion in the position the application actually reads from
- The signature verification passes (it found the referenced ID) while the application processes the attacker's payload
<samlp:Response>
<!-- Attacker injects a forged assertion HERE -->
<saml:Assertion ID="evil123">
<saml:Subject>
<saml:NameID>admin@company.com</saml:NameID>
</saml:Subject>
</saml:Assertion>
<!-- Original signed assertion moved here, still valid -->
<saml:Assertion ID="legit456">
<ds:Signature>...</ds:Signature>
<saml:Subject>
<saml:NameID>attacker@company.com</saml:NameID>
</saml:Subject>
</saml:Assertion>
</samlp:Response>
If the application's XML parser retrieves assertions by document order rather than by verifying signature coverage matches the processed node, it authenticates as admin@company.com with zero valid credentials.
Why This Isn't a One-Off Bug
XSW has been rediscovered in nearly every major SAML library — Java's OpenSAML, Python's python3-saml, .NET's SAML libraries, and countless custom implementations. It's not that developers are careless; it's that the spec permits a signature model where "signed" and "processed" can refer to different data. Fixing one library doesn't fix the pattern.
Common SAML Vulnerability Classes
| Vulnerability | Root Cause | Typical Impact |
|---|---|---|
| XML Signature Wrapping | Signature validates by ID reference, not document position | Full authentication bypass |
| XXE (XML External Entity) | XML parsers resolving external entities by default | Server-side file read / SSRF |
| Signature exclusion | App accepts unsigned assertions if <Signature> is missing |
Trivial forged login |
| Comment injection in NameID | XML comment parsing inconsistencies between parsers | Identity spoofing (admin<!-- -->@evil.com) |
| Certificate confusion | App trusts any cert in the assertion instead of pinned IdP cert | Self-signed cert bypass |
| Replay attacks | Missing or weak assertion expiration / one-time-use checks | Session/token replay |
A Practical Comparison: SAML vs OIDC
Most greenfield projects shouldn't reach for SAML unless a legacy enterprise IdP mandates it. Here's how it stacks up against OpenID Connect, the modern standard built on OAuth 2.0 and JSON.
| Factor | SAML | OIDC |
|---|---|---|
| Data format | XML | JSON (JWT) |
| Signature model | XML-DSig (ambiguous, reference-based) | JWS (signs the entire compact token) |
| Parsing complexity | High (namespaces, canonicalization, XXE risk) | Low (base64url + JSON) |
| Mobile/SPA support | Poor (browser redirects, POST bindings) | Native (PKCE, redirect or popup flows) |
| Token format | Assertion (SOAP-era XML) | ID Token (JWT), Access Token |
| Library maturity | Fragmented, inconsistent implementations | Standardized, well-tested (jose, passport, etc.) |
| Common use case | Legacy enterprise SSO (Okta, ADFS, Azure AD) | Modern web/mobile apps, APIs |
| Attack surface | XSW, XXE, comment injection | Token replay, JWT alg:none, weak validation |
OIDC isn't immune to bugs, but its attack surface is smaller because JSON parsing and JWS signing don't have the same structural ambiguity as XML canonicalization.
Implementing SAML Safely (When You Have No Choice)
Sometimes an enterprise customer's IdP only speaks SAML. If you're stuck integrating it, here's how to do it without shooting yourself in the foot.
Use a Maintained, Audited Library — Never Roll Your Own
npm install @node-saml/node-saml
node-saml (the maintained fork of passport-saml) handles canonicalization and signature validation correctly, including XSW mitigations.
import { SAML } from '@node-saml/node-saml';
const saml = new SAML({
callbackUrl: 'https://app.example.com/auth/saml/callback',
entryPoint: 'https://idp.example.com/sso',
issuer: 'https://app.example.com/metadata',
idpCert: process.env.SAML_IDP_CERT, // pin the exact cert, never trust embedded certs
wantAssertionsSigned: true,
wantAuthnResponseSigned: true,
disableRequestedAuthnContext: true,
});
app.post('/auth/saml/callback', async (req, res) => {
try {
const { profile } = await saml.validatePostResponseAsync(req.body);
// Never trust NameID formatting blindly — normalize and validate
const email = profile.nameID?.trim().toLowerCase();
if (!email || !isValidEmail(email)) {
return res.status(400).send('Invalid assertion');
}
req.session.user = { email, sessionIndex: profile.sessionIndex };
res.redirect('/dashboard');
} catch (err) {
console.error('SAML validation failed:', err.message);
res.status(401).send('Authentication failed');
}
});
Critical Hardening Checklist
// saml-config.js — production-grade SAML config checklist
const samlHardeningConfig = {
// 1. Always require signed assertions AND signed responses
wantAssertionsSigned: true,
wantAuthnResponseSigned: true,
// 2. Pin the IdP certificate — never extract trust from the response itself
idpCert: process.env.SAML_IDP_CERT,
// 3. Enforce assertion expiration windows
acceptedClockSkewMs: 5000,
// 4. Validate audience restriction matches your SP entity ID
audience: 'https://app.example.com/metadata',
// 5. Disable XXE at the XML parser level
// (node-saml uses xml-crypto + xmldom with safe defaults, but verify)
// 6. Enforce one-time use of assertion IDs (replay protection)
validateInResponseTo: true,
};
Disable XXE at the Parser Level
If you're forced to touch raw XML parsing anywhere in your stack (custom middleware, legacy libraries), explicitly disable external entity resolution:
import { DOMParser } from '@xmldom/xmldom';
// Never allow external entity resolution
const parser = new DOMParser({
errorHandler: {
warning: () => {},
error: (e) => { throw new Error(e); },
fatalError: (e) => { throw new Error(e); },
},
});
Most modern XML libraries disable external entities by default, but "most" and "default" are exactly the words that show up in CVE writeups.
The Better Path: Migrating to OIDC
If your org has flexibility, migrate from SAML to OIDC. Here's a minimal OIDC flow using openid-client in Node.js:
import { Issuer, generators } from 'openid-client';
const oidcIssuer = await Issuer.discover('https://idp.example.com/.well-known/openid-configuration');
const client = new oidcIssuer.Client({
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uris: ['https://app.example.com/auth/callback'],
response_types: ['code'],
});
// Step 1: Redirect user to IdP with PKCE
app.get('/auth/login', (req, res) => {
const codeVerifier = generators.codeVerifier();
req.session.codeVerifier = codeVerifier;
const authUrl = client.authorizationUrl({
scope: 'openid email profile',
code_challenge: generators.codeChallenge(codeVerifier),
code_challenge_method: 'S256',
});
res.redirect(authUrl);
});
// Step 2: Handle callback, exchange code for tokens
app.get('/auth/callback', async (req, res) => {
const params = client.callbackParams(req);
const tokenSet = await client.callback(
'https://app.example.com/auth/callback',
params,
{ code_verifier: req.session.codeVerifier }
);
const claims = tokenSet.claims(); // validated ID token payload
req.session.user = { email: claims.email, sub: claims.sub };
res.redirect('/dashboard');
});
Notice how much smaller the trust surface is: no XML canonicalization, no signature wrapping ambiguity, PKCE prevents authorization code interception, and the ID token is a signed JWT you validate with standard JWS verification — no custom XML parsing required.
Validating JWTs Correctly (The OIDC Equivalent of "Don't Roll Your Own")
import { jwtVerify, createRemoteJWKSet } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://idp.example.com/.well-known/jwks.json'));
async function verifyIdToken(token) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://idp.example.com',
audience: process.env.OIDC_CLIENT_ID,
// Explicitly allow only expected algorithms — never "alg: none"
algorithms: ['RS256'],
});
return payload;
}
Pinning algorithms explicitly prevents algorithm-confusion attacks — the JWT equivalent of SAML's signature exclusion bug, where an attacker submits a token with alg: none hoping the verifier skips signature checks entirely.
When You're Stuck Supporting Both
Many SaaS products need to support SAML for enterprise tier customers while running OIDC or standard sessions for everyone else. A clean pattern is to normalize both into a single internal identity object at the edge:
interface NormalizedIdentity {
email: string;
sub: string;
provider: 'saml' | 'oidc';
raw: unknown; // keep raw payload for audit logging
}
function normalizeSamlProfile(profile: SamlProfile): NormalizedIdentity {
return {
email: profile.nameID.toLowerCase(),
sub: profile.nameID,
provider: 'saml',
raw: profile,
};
}
function normalizeOidcClaims(claims: JWTPayload): NormalizedIdentity {
return {
email: (claims.email as string).toLowerCase(),
sub: claims.sub!,
provider: 'oidc',
raw: claims,
};
}
This isolates SAML's XML mess to a single boundary layer instead of leaking NameID formats and XML quirks throughout your application's session and authorization logic.
Key Takeaways
- SAML's core weakness is architectural: XML parsing, XML-DSig, and federated trust each carry independent attack surfaces that compound into bugs like XML Signature Wrapping.
- XSW attacks work because XML-DSig signs elements by ID reference, not document position — a validly signed assertion can be relocated while a forged one takes its processing slot.
- Never write custom SAML XML parsing or signature validation. Use maintained libraries like
@node-saml/node-samland keep them updated. - Pin the IdP certificate explicitly (
idpCert) rather
Top comments (0)