---
key: "saml-verification-boundaries"
title: "SAML single sign-on: verify signed assertions without losing the identity boundary"
url: "https://softwaresecurity.com/domains/identity-crypto/authentication/saml-verification-boundaries/"
api: "https://softwaresecurity.com/api/v1/articles/saml-verification-boundaries.json"
domain: "identity-crypto"
topic: "Identity \u0026 cryptography"
type: "Guide"
identifiers: ["CWE-347","CWE-290","CWE-294","CWE-611","A07:2025","A04:2025","API2:2023","V6.8.1","V6.8.2","V6.8.3"]
tags: ["Authentication and sessions","SAML, SCIM and enterprise SSO"]
stages: ["build"]
roles: ["developer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# SAML single sign-on: verify signed assertions without losing the identity boundary

Bind a validated SAML assertion to its trusted issuer, the exact signed element, your service, the browser request and a replay record before creating a local session, and keep your SAML library ahead of its advisories.

Canonical URL: https://softwaresecurity.com/domains/identity-crypto/authentication/saml-verification-boundaries/

## In short

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 and `InResponseTo` separately; 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.

## Recommended defaults

| 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 |

## Verify it

1. Replay a captured valid `SAMLResponse` to the ACS endpoint: the second submission must fail.
2. Post a response without `InResponseTo`, and one signed by a key you did not enroll: both must fail.
3. Change the `NameID` in a signed response, or add a second unsigned Assertion: both must fail before any session exists.
4. Send an assertion for another audience, another `Recipient` and another issuer: each must fail with a distinct internal reason.
5. List the SAML and XML-signature package versions in every service and compare them with the advisory table.

## 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.

Audience: Engineers integrating enterprise SSO and reviewers evaluating a SAML service provider.

Before you start:

- Understand HTTP redirects, POSTs and browser sessions.
- Distinguish XML parsing, signature verification and application authorization.

Time: About 35 minutes: 8 to read and 27 for the exercise

## Choose an explicit SSO contract

This 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 the identity element the library actually verified

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.

## Which SAML library vulnerabilities should you check for?

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 |

## Check where, when and in response to what

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.

## Configure a Node.js service provider with node-saml

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.

```javascript
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};
}

```

## Map the federation identity to current local authority

For this product, the mapping key contains enrolled tenant, issuer and the configured `NameID` identifier semantics, including relevant qualifiers. An email attribute is display/contact data unless an explicit verified linking process says otherwise. The same email at two tenants must not merge their accounts. An issuer-provided group string becomes a local grant only through an approved mapping.

Check that the local account remains active after SAML validation. Disabling a user in provisioning, which the SCIM guide at /domains/identity-crypto/authentication/scim-provisioning-deprovisioning/ covers, must not be undone by an older login assertion or just-in-time account creation. Store the selected local account ID in the session and rotate the browser session identifier, as the session guide at /domains/identity-crypto/authentication/session-lifecycle/ describes. Treat `RelayState` as an opaque, browser-bound lookup into an allowed local destination; it is not a trusted arbitrary redirect URL.

## Test changes to trust as carefully as login

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.

## Keep protocol and application failures distinguishable

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 material

```text
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.

Worked answer: 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.

Worked answer: Reject A. The verifier authenticated `_a1`, but the application consumed `_a2`. This is a signed-node binding failure. No attribute from `_a2` is usable as authenticated identity; reusing a boolean `signatureValid` flag cannot repair the mismatch.

### 3. Apply SAML-specific conditions

Evaluate B and C without collapsing the two audience restrictions into one list.

Worked answer: Reject both. B fails the second independent restriction even though billing appears in the first. C fails the bearer confirmation’s exclusive `NotOnOrAfter` cutoff. 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.

Worked answer: D fails the initiating-browser binding. For E, one transaction/atomic operation can consume `_req7` and `_a1` and 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.

Worked answer: 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.

Deliverable: A six-case decision record naming the rejecting layer, a signed-node regression test specification, and an atomic replay/session-creation design.

Scope: 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.

## Executable practice

- [Prove deprovisioning reaches sessions and queued work: project ZIP](https://softwaresecurity.com/downloads/projects/identity-lifecycle-510849ac517b9ec2.zip) — Python 3.11+ · standard library

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OASIS SAML 2.0 Profiles — Web Browser SSO processing](https://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf). OASIS Standard, 15 March 2005.
- [OASIS SAML 2.0 Core — Conditions and audience restrictions](https://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf). OASIS Standard, 15 March 2005.
- [OWASP SAML Security Cheat Sheet — parsing and signature wrapping](https://cheatsheetseries.owasp.org/cheatsheets/SAML_Security_Cheat_Sheet.html)
- [OASIS SAML 2.0 Security and Privacy Considerations](https://docs.oasis-open.org/security/saml/v2.0/saml-sec-consider-2.0-os.pdf). OASIS Standard, 15 March 2005.
- [GitHub Security Lab: bypassing SAML authentication with parser differentials (March 2025)](https://github.blog/security/sign-in-as-anyone-bypassing-saml-sso-authentication-with-parser-differentials/)
- [PortSwigger Research: The Fragile Lock, novel bypasses for SAML authentication (December 2025)](https://portswigger.net/research/the-fragile-lock)
- [GitHub Advisory Database: node-saml CVE-2025-54369](https://github.com/advisories/GHSA-m837-g268-mmv7)
- [node-saml documentation and options](https://github.com/node-saml/node-saml)