Use a maintained SAML library on a patched version, require signatures from the IdP key you enrolled, and read identity only from the assertion the library verified. Check the issuer, audience, Recipient, NotOnOrAfter and InResponseTo, accept each response once through shared replay state, and map the user by tenant, issuer and persistent NameID, never by email. Then check that the local account is still active before creating a session.
- Signature wrapping and parser differentials make a library verify one element while the application reads another.
- Trust the signing certificate enrolled for the tenant, never a certificate carried in the message’s
KeyInfo. - Check issuer, audience,
Recipient, time conditions andInResponseToseparately; libraries do not all check every one. - Consume the request ID in a store shared by every instance so one response creates at most one session.
- Map users by tenant, issuer and persistent
NameID; an email attribute is contact data. - ruby-saml, xml-crypto, node-saml, samlify and xmlseclibs all shipped authentication bypass fixes in 2024 to 2026.
| Setting | Value | Note | Source |
|---|---|---|---|
| Signatures | Signed Response and signed Assertion, RSA-SHA256 or stronger | Verify with the IdP certificate pinned at onboarding. | OWASP SAML Security Cheat Sheet; ASVS 5.0 V6.8.2 |
| Unsolicited responses | Rejected: InResponseTo must match an outstanding request | node-saml defaults to accepting them; set validateInResponseTo to always. | node-saml 5.1.0 |
| Request ID lifetime | 5 minutes, single use, in a shared store | node-saml’s default is 8 hours in process memory. | This guide; node-saml 5.1.0 |
| Clock skew | 0 to 2 minutes, documented and tested | NotOnOrAfter is an exclusive cutoff. | SAML 2.0 Core |
| XML parsing | No DTDs, no external entities, bounded size | Reject documents with more than one Assertion. | OWASP SAML Security Cheat Sheet |
| Minimum library versions | ruby-saml 1.18.1, xml-crypto 6.0.1, @node-saml/node-saml 5.1.0, samlify 2.13.0, xmlseclibs 3.1.5 | The lowest releases that fix the advisories in this guide; ruby-saml 1.18.1 also fixes a denial of service (CVE-2025-54572). Prefer the latest release. | GitHub Advisory Database |
- Replay a captured valid
SAMLResponseto the ACS endpoint: the second submission must fail. - Post a response without
InResponseTo, and one signed by a key you did not enroll: both must fail. - Change the
NameIDin a signed response, or add a second unsigned Assertion: both must fail before any session exists. - Send an assertion for another audience, another
Recipientand another issuer: each must fail with a distinct internal reason. - List the SAML and XML-signature package versions in every service and compare them with the advisory table.
- Applies to
- @node-saml/node-saml 5.1.0 or later (tested with 5.1.0 and xml-crypto 6.3.2)
Enterprise SAML 2.0 service providers consuming signed assertions through a maintained protocol library.
- Part of
- Workflows: Fix a finding, Review a design, Test an application
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
What you will learn
- Bind consumed identity to the exact signed assertion.
- Evaluate request, recipient, audience, time and replay conditions independently.
- Keep enterprise account mapping and deprovisioning effective after login.
Before you start
Engineers integrating enterprise SSO and reviewers evaluating a SAML service provider.
- Understand HTTP redirects, POSTs and browser sessions.
- Distinguish XML parsing, signature verification and application authorization.
Refresh first: OAuth 2.0 and OpenID Connect login: authorization code with PKCE
Go to the guided practiceThis guide uses SAML 2.0 Web Browser SSO with HTTP POST delivery and service-provider-initiated login. An identity provider authenticates a person; the service provider validates the response and establishes its own session. A browser carrying XML does not make that XML trusted. Keep issuer onboarding, message validation and local account authorization as separate decisions.
For this worked application, require a signed Response and signed authentication Assertion, accept one configured bearer confirmation and reject unsolicited responses. Requiring both signatures is this deployment’s restrictive contract. Do not silently weaken an established profile because an integration sends a different shape; resolve supported behavior during onboarding and test the exact library configuration.
Use a maintained SAML/XML signature implementation with hardened XML parsing, bounded input and local schemas. Disable external entity resolution and unapproved remote references. Reject duplicate IDs and ambiguous assertion selection. Schema validation and signature validation are different checks; successful parsing establishes neither issuer trust nor an authenticated user.
Signature wrapping exploits disagreement between the verifier and the consumer: a signature validates Assertion A1 while application XPath reads an unsigned A2. Consume the validated assertion object returned by the library. Do not verify a document, parse it again with a different routine and select the first element named Assertion. Record the signed reference and consumed element in a controlled regression fixture.
Trust comes from the tenant’s enrolled issuer and signing-key configuration. An embedded KeyInfo certificate is candidate key material, not permission to create accounts. A certificate chaining to a familiar public CA also does not establish that it is the configured identity provider’s SAML signing key.
Each of these advisories let an attacker change the identity or attributes that a service provider accepted from a signed SAML message, or read encrypted content. Most are the failure described above: the code that verified the signature and the code that read the identity disagreed about which XML they were looking at. Check your dependency tree, including libraries such as omniauth-saml or passport-saml that pull these in.
| Library | Advisories | What went wrong | Fixed in |
|---|---|---|---|
| ruby-saml | CVE-2024-45409 (September 2024) | An incorrect XPath selector let a forged assertion pass signature validation | 1.17.0, or 1.12.3 on the 1.12 line |
| ruby-saml | CVE-2025-25291 and CVE-2025-25292 (March 2025) | The REXML and Nokogiri parsers chose different signature elements | 1.18.0, or 1.12.4 |
| ruby-saml | CVE-2025-66567 and CVE-2025-66568 (December 2025) | A namespace parser differential and a libxml2 canonicalization error bypassed digest checks | 1.18.0; the 1.12 line is not fixed |
| xml-crypto (Node.js) | CVE-2025-29774 and CVE-2025-29775 (March 2025) | Multiple SignedInfo references and a comment in DigestValue bypassed verification | 6.0.1, 3.2.1 or 2.1.6 |
| @node-saml/node-saml | CVE-2025-54369 and CVE-2025-54419 (July 2025) | The assertion was read from the unsigned original document | 5.1.0 |
| samlify | CVE-2025-47949 (May 2025); CVE-2026-46490 (May 2026) | Signature wrapping; XML injected through attribute values that the IdP then signed | 2.10.0; 2.13.0 |
| xmlseclibs (PHP) | CVE-2025-66578 (December 2025); CVE-2026-32313 (March 2026) | A libxml2 canonicalization error; an unchecked AES-GCM tag length on encrypted nodes | 3.1.4; 3.1.5 |
Validate the expected issuer, successful response status and configured Destination. For bearer confirmation, check Recipient against the actual assertion consumer service URL, its NotOnOrAfter cutoff, and InResponseTo against the outstanding login request. Bind that request to the initiating browser and tenant; expire and consume it. For this SP-initiated profile, an unknown request is denied.
Evaluate assertion Conditions separately from subject confirmation. NotOnOrAfter is an exclusive cutoff. Define and test clock skew rather than accepting an arbitrary time extension. In SAML audience restrictions, audiences inside one restriction are alternatives, while multiple restrictions must all be satisfied. The SP entity ID is an identity identifier; it is not interchangeable with the ACS URL.
A valid assertion is still a bearer credential. Atomically record its used ID and consume the correlated request before creating the session. Keep replay state for the full possible acceptance interval, including configured skew. Multiple application instances need a shared decision; independent in-memory sets let the same response establish multiple sessions.
The library verifies signatures, audience, time conditions and InResponseTo, and since 5.1.0 it reads only the verified assertion. Two checks remain yours: node-saml applies idpIssuer only to logout messages, and it does not check the bearer confirmation’s Recipient, so the handler checks both. It was tested with node-saml 5.1.0 and xml-crypto 6.3.2 on Node.js 26 against responses signed locally with RSA-SHA256: a solicited response succeeds once, and a replay, an unsolicited or unknown request ID, an unenrolled signing key, a NameID changed after signing, an extra unsigned assertion, another audience, another issuer, an expired assertion and another endpoint are all rejected. The same test shows the library default accepting an unsolicited response.
import {SAML, ValidateInResponseTo} from '@node-saml/node-saml';
const ACS_URL = 'https://billing.example/saml/acs';
// One instance per enrolled tenant, built from the values agreed at onboarding.
export function samlFor(tenant, sharedCache) {
return new SAML({
callbackUrl: ACS_URL,
issuer: 'urn:example:billing', // this service provider's entity ID
audience: 'urn:example:billing',
entryPoint: tenant.ssoUrl,
idpCert: tenant.signingCerts, // pinned at onboarding; never trust KeyInfo in the message
wantAuthnResponseSigned: true,
wantAssertionsSigned: true,
validateInResponseTo: ValidateInResponseTo.always, // the default, "never", accepts unsolicited responses
requestIdExpirationPeriodMs: 5 * 60_000,
cacheProvider: sharedCache, // shared by every instance, so each request ID is consumed once
acceptedClockSkewMs: 0,
signatureAlgorithm: 'sha256',
});
}
// POST /saml/acs. Every value comes from the assertion the library verified.
export async function acs(saml, tenant, body) {
const {profile} = await saml.validatePostResponseAsync({SAMLResponse: body.SAMLResponse});
// node-saml checks idpIssuer only on logout messages and never checks Recipient: do both here.
if (profile.issuer !== tenant.idpEntityId) throw new Error('assertion from an unexpected issuer');
const confirmations = profile.getAssertion().Assertion.Subject[0].SubjectConfirmation;
if (!confirmations.some(c => c.SubjectConfirmationData?.[0]?.$?.Recipient === ACS_URL))
throw new Error('assertion was issued for another endpoint');
return {issuer: profile.issuer, nameID: profile.nameID, format: profile.nameIDFormat};
}An onboarding checklist should identify entity IDs, ACS endpoints, signing algorithms, signing keys, metadata ownership and refresh rules. Planned key rollover needs a controlled overlap and tests for both keys. A compromise requires removal of affected trust and review of sessions already created; merely changing the IdP’s current signer leaves other accepted keys and sessions untouched.
Use redacted event records with correlation ID, tenant, configured issuer, validation stage and reason. Avoid logging complete SAML responses, session cookies or sensitive attributes. Keep a test account and a deliberate failure matrix for each supported IdP configuration. A real integration test must execute the selected SAML library against signed XML; the supplied trace below deliberately starts after cryptographic verification.
Library versions are part of the trust configuration. Record the SAML and XML-signature packages each service uses, their minimum safe versions from the table above, and who upgrades them when a new advisory appears.
- Subscribe to advisories for every SAML and XML-signature package you use, through Dependabot, OSV or your scanner.
- Keep regression fixtures of real signed responses, including published attack payloads, and run them against every library upgrade.
- Reject documents that contain more than one Assertion or a
DOCTYPE, before any signature processing. - Treat an IdP built on a SAML library as part of your attack surface too: CVE-2026-46490 let users inject attributes that the IdP signed.
The baseline below is accepted only when every row remains true. A failing identity-binding check must prevent session creation even if cryptography succeeds. A current-authority failure can reject a perfectly valid SAML response without implying that its signature is broken.
| Check | Evidence to retain | Failure to inject |
|---|---|---|
| Signed node binding | Validated reference ID equals consumed Assertion ID | Valid A1 signature, application consumes A2 |
| Request/browser binding | Outstanding request belongs to this browser and tenant | Valid response copied into another browser |
| Audience/recipient/time | SP ID, ACS URL and effective exclusive cutoff | Other audience; wrong ACS; exact cutoff |
| Replay/current account | One atomic consumption and active local mapping | Second submission; deprovisioned local account |
Guided practice
Review a complete post-verification trace for a billing service, then identify exactly which boundary rejects each variation.
Supplied scenario material
Configured tenant: acme
Trusted issuer: https://idp.acme.example/saml
SP entity ID: urn:example:billing
ACS: https://billing.example/saml/acs
Policy: SP-initiated POST; require signed Response AND Assertion; zero clock skew.
Outstanding request: _req7, browser B1, tenant acme, unused, expires 10:04:00Z
Current time: 10:02:00Z
Library output: trusted signatures VALID; Response ID _resp9; signed Assertion ID _a1.
Application consumes Assertion ID _a1.
Response: Success; Destination=https://billing.example/saml/acs; InResponseTo=_req7
Assertion issuer: https://idp.acme.example/saml
Conditions: NotBefore=10:00:00Z; NotOnOrAfter=10:05:00Z
AudienceRestriction 1: [urn:example:billing, urn:example:reports]
AudienceRestriction 2: [urn:example:billing]
Bearer confirmation: Recipient=https://billing.example/saml/acs; InResponseTo=_req7;
NotOnOrAfter=10:03:00Z
Persistent NameID mapping: (acme, configured issuer, user-42) -> active local account 17
Assertion _a1 has not been consumed. Browser is B1. RelayState maps to /invoices.
Each variation starts from this independent baseline unless stated otherwise.
A: application consumes unsigned Assertion _a2 outside the verified Response subtree, with NameID admin instead.
B: AudienceRestriction 2 is [urn:example:reports].
C: current time is exactly 10:03:00Z.
D: identical baseline response is delivered from browser B2.
E: baseline is submitted twice concurrently to two app instances.
F: local account 17 is disabled before the baseline response arrives.1. Establish the baseline
Decide whether the baseline may create a session, and name its effective time cutoff.
Expected result and reasoning
It may create a session for local account 17 only after atomic request/assertion consumption and the current account check. Its effective cutoff is 10:03:00Z: bearer confirmation expires before the 10:05 assertion condition or 10:04 request record. Zero skew makes 10:03:00 invalid.
2. Follow the signed identity
Evaluate A. Explain why the supplied valid signatures do not authenticate admin.
Expected result and reasoning
Reject A. The verifier authenticated
_a1, but the application consumed_a2. This is a signed-node binding failure. No attribute from_a2is usable as authenticated identity; reusing a booleansignatureValidflag cannot repair the mismatch.3. Apply SAML-specific conditions
Evaluate B and C without collapsing the two audience restrictions into one list.
Expected result and reasoning
Reject both. B fails the second independent restriction even though billing appears in the first. C fails the bearer confirmation’s exclusive
NotOnOrAftercutoff. The longer assertion Conditions interval cannot extend that confirmation.4. Bind the browser and make replay atomic
Evaluate D and E. Specify the shared state transition E requires.
Expected result and reasoning
D fails the initiating-browser binding. For E, one transaction/atomic operation can consume
_req7and_a1and authorize one session creation; the competing submission must observe already-used state and fail. Two separate per-instance replay sets are insufficient.5. Check local authority after federation
Evaluate F and write a regression criterion for account linking.
Expected result and reasoning
F fails current local account status despite valid SAML. An assertion must not silently recreate or reactivate account 17. Add a test that an equal email from another enrolled tenant cannot link to account 17 without an explicit approved account-linking flow.
This is a supplied validation trace, not executable XML signature verification. Verify the selected library and IdP integration with real signed XML; it does not cover artifact/ECP bindings or unsolicited login.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Prove deprovisioning reaches sessions and queued work
Repair a durable SQLite identity lifecycle model. Disable current sessions/tokens, remove group-derived authority, cancel pending jobs, and handle duplicate, stale and interrupted changes without crossing tenant boundaries.
In the extracted folder, run:
python3 -m unittest -v test_lifecycle.py18 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
Part of the learning path Identity through the whole lifecycle
Actual local transactions and authorization state. Normalized provisioning events, opaque credentials and local job effects; not a SCIM HTTP/PATCH server or external revocation service.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OASIS SAML 2.0 Profiles — Web Browser SSO processing. OASIS Standard, 15 March 2005.
- OASIS SAML 2.0 Core — Conditions and audience restrictions. OASIS Standard, 15 March 2005.
- OWASP SAML Security Cheat Sheet — parsing and signature wrapping
- OASIS SAML 2.0 Security and Privacy Considerations. OASIS Standard, 15 March 2005.
- GitHub Security Lab: bypassing SAML authentication with parser differentials (March 2025)
- PortSwigger Research: The Fragile Lock, novel bypasses for SAML authentication (December 2025)
- GitHub Advisory Database: node-saml CVE-2025-54369
- node-saml documentation and options
Reference information
SAML 2.0 OASIS specifications. The linked identity-lifecycle project models normalized lifecycle events, not SAML parsing.
- SDLC phase
- Development
- Role
- Developers
- ASVS 5.0.0 requirements
- v5.0.0-6.8.1 · v5.0.0-6.8.2 · v5.0.0-6.8.3