In this article
July 24, 2026
July 24, 2026

SAML security checklist for SaaS developers

A code-level checklist for auditing your SAML service provider implementation: XML parser configuration, signature validation, assertion extraction, replay prevention, and testing.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

This checklist is for developers who have built or are reviewing a homegrown SAML service provider implementation. It focuses on the code and configuration level: the specific things to verify in your parser setup, validation logic, and assertion extraction code, rather than the architectural decisions covered in our SAML security best practices guide.

Most SAML vulnerabilities disclosed in the past two years (ruby-saml, authentik, OneUptime, and others) weren't caused by exotic attacks. They came from implementers making subtly wrong choices in exactly the areas this checklist covers. Run through it before shipping a SAML integration, and again before any security review.

1. XML parser configuration

Disable DTD processing and external entities

SAML responses are XML documents, and XML parsers that process document type definitions (DTDs) or external entities are vulnerable to XXE injection. An attacker who can influence the SAML response can use a crafted DTD to read arbitrary files from your server or perform server-side request forgery.

The fix is a single configuration change on your XML parser. Verify it is in place:

Java (DocumentBuilderFactory)

  
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
  

Python (lxml)

  
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True)
doc = etree.fromstring(saml_response_bytes, parser)
  

Node.js (fast-xml-parser)

  
import { XMLParser } from 'fast-xml-parser';
const parser = new XMLParser({ processEntities: false });
  

.NET

  
XmlReaderSettings settings = new XmlReaderSettings();
settings.DtdProcessing = DtdProcessing.Prohibit;
settings.XmlResolver = null;
XmlReader reader = XmlReader.Create(samlStream, settings);
  

Check: does your parser configuration explicitly disable DTD processing? Do not rely on library defaults, which can change across versions.

Use one parser for the entire response

A SAML response should be parsed once, by a single parser instance, and that same parsed document should be used for both signature verification and assertion extraction. The OneUptime vulnerability disclosed in April 2026 showed exactly what happens when this rule is broken: isSignatureValid() verified the first <Signature> element using xml-crypto, while getEmail() always read from assertion[0] via xml2js. Two different parsers, two different views of the same document, one exploitable gap.

If your code does something like this, it is vulnerable:

  
// Dangerous: two parsers, two views of the document
const isValid = xmlCrypto.validateSignature(rawXml);
const email = xml2js.parse(rawXml).assertion[0].email;
  

The correct pattern:

  
// Safe: parse once, use the same doc for both operations
const doc = parseXml(rawXml);
const isValid = validateSignatureOnDoc(doc);
const email = extractEmailFromSignedAssertion(doc);
  

Check: search your codebase for every location that parses the raw SAML response string. There should be exactly one.

2. Signature validation

Validate that a signature is present before doing anything else

An unsigned assertion should be rejected immediately, before any other processing. Do not fall through to a "signature optional" mode or log a warning and continue. The check should look like this:

  
const signature = doc.xpath('/samlp:Response/saml:Assertion/ds:Signature');
if (!signature || signature.length === 0) {
  throw new AuthError('Assertion is unsigned: rejected');
}
  

Check: is there code anywhere that processes an assertion even when no signature is found?

Validate the signature using an absolute XPath expression

XSW attacks exploit the gap between what gets signed and what gets read: a valid signature validates one element, but the application reads authentication attributes from a different, attacker-controlled element. The most common way this gap appears is by locating the signature with a relative or position-based XPath expression that matches the first element with a given tag name, rather than the specifically signed element.

Do not locate the signature or the assertion like this:

  
// Dangerous: matches the first <Assertion> anywhere in the document
const assertion = doc.getElementsByTagName('Assertion')[0];
const signature = doc.getElementsByTagName('Signature')[0];
  

Use absolute paths instead:

  
// Safe: exactly the element at the expected location
const assertion = doc.xpath('/samlp:Response/saml:Assertion');
const signature = doc.xpath('/samlp:Response/saml:Assertion/ds:Signature');
  

Check: review every XPath expression or DOM traversal used to locate the <Assertion> and <Signature> elements. None of them should use relative paths or getElementsByTagName.

Verify the Reference URI matches the assertion ID

The <ds:Reference URI> inside <ds:SignedInfo> tells you which element the signature covers. It should match the ID attribute on the assertion you extracted. If it doesn't, you may be validating a signature that covers a different element than the one you're about to trust.

  
const referenceUri = signature
  .find('.//ds:Reference')
  .getAttribute('URI')
  .replace(/^#/, '');

const assertionId = assertion.getAttribute('ID');

if (referenceUri !== assertionId) {
  throw new AuthError('Signature reference does not match assertion ID');
}
  

Check: does your validation code explicitly compare the Reference URI to the assertion ID?

Validate signatures at the right level

The authentik vulnerability disclosed in 2025 was a textbook example of checking at only one level: the SAML spec allows signing at either the response level or the assertion level, or both, and implementations that check only one layer leave an opening for injection at the other.

Verify your implementation handles all three cases:

  • Response signed, assertion unsigned
  • Response unsigned, assertion signed
  • Both signed

At minimum, require the assertion to be signed. If only the response envelope is signed and you accept unsigned assertions inside it, an attacker can potentially swap the assertion.

Check: write a test that sends a valid signed response containing an unsigned assertion with modified attributes. Your implementation should reject it.

Reject deprecated signing algorithms

SHA-1 is deprecated for cryptographic use. Your validation code should maintain an allowlist of accepted algorithms and reject anything not on it:

  
const ALLOWED_ALGORITHMS = new Set([
  'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256',
  'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512',
  'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256',
  'http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512',
]);

const signingAlgorithm = signedInfo
  .find('.//ds:SignatureMethod')
  .getAttribute('Algorithm');

if (!ALLOWED_ALGORITHMS.has(signingAlgorithm)) {
  throw new AuthError(`Rejected signing algorithm: ${signingAlgorithm}`);
}
  

Check: can an IdP signed with rsa-sha1 successfully authenticate against your application?

3. Assertion field validation

Each of the following fields must be checked on every incoming assertion, in this order, before you create a session. A gap in any one of them is a potential authentication bypass.

Issuer

The <saml:Issuer> value must exactly match the entity ID you have stored for this SSO connection. Compare as a string, not as a URL (trailing slashes and case differences matter).

  
const issuer = assertion.xpath('saml:Issuer')[0]?.textContent;
if (issuer !== connection.idpEntityId) {
  throw new AuthError(`Issuer mismatch: got ${issuer}`);
}
  

Audience restriction

The <saml:AudienceRestriction> must contain your application's entity ID. Without this check, an assertion issued for a different service provider could be accepted by yours.

  
const audiences = assertion
  .xpath('saml:Conditions/saml:AudienceRestriction/saml:Audience')
  .map(node => node.textContent);

if (!audiences.includes(YOUR_SP_ENTITY_ID)) {
  throw new AuthError('Audience restriction does not include SP entity ID');
}
  

Destination

The Destination attribute on the <samlp:Response> must match the ACS URL that received the POST request, including scheme, host, port, and path.

  
const destination = response.getAttribute('Destination');
if (destination !== ACS_URL) {
  throw new AuthError(`Destination mismatch: got ${destination}`);
}
  

NotBefore and NotOnOrAfter

Assertions are time-bounded. Check both timestamps against the current time, with a small clock skew tolerance. Five minutes is the common default; two minutes is stricter and more secure.

  
const CLOCK_SKEW_MS = 2 * 60 * 1000;
const now = Date.now();
const notBefore = new Date(conditions.getAttribute('NotBefore')).getTime();
const notOnOrAfter = new Date(conditions.getAttribute('NotOnOrAfter')).getTime();

if (now < notBefore - CLOCK_SKEW_MS) {
  throw new AuthError('Assertion not yet valid');
}
if (now >= notOnOrAfter + CLOCK_SKEW_MS) {
  throw new AuthError('Assertion has expired');
}
  

InResponseTo (SP-initiated flows)

For SP-initiated SSO, the InResponseTo attribute on the response must match the ID of an AuthnRequest your application actually sent. If there's no match, the response is unsolicited.

  
const inResponseTo = response.getAttribute('InResponseTo');
const pendingRequest = await requestStore.get(inResponseTo);

if (!pendingRequest) {
  throw new AuthError('InResponseTo does not match any outstanding request');
}

// Consume the request so it can't be used again
await requestStore.delete(inResponseTo);
  

Check: can an assertion with a fabricated or missing InResponseTo successfully log someone in?

4. Replay prevention

Assertion IDs must be tracked and deduplicated. Any replayed assertion whose NotOnOrAfter has not yet passed should be rejected.

The cache must be shared across all application instances. A per-process in-memory map doesn't work in a load-balanced environment.

  
async function checkReplay(assertionId, notOnOrAfter) {
  const key = `saml:seen:${assertionId}`;

  // Throws or returns null if key doesn't exist
  const seen = await redis.get(key);
  if (seen) {
    throw new AuthError('Assertion ID has already been used');
  }

  const ttlSeconds = Math.ceil(
    (new Date(notOnOrAfter).getTime() - Date.now()) / 1000
  );

  // Store with TTL equal to the remaining assertion validity window
  await redis.set(key, '1', { EX: Math.max(ttlSeconds, 0) });
}
  

Check: submit the same raw SAML response twice to your ACS endpoint. The second submission should be rejected, not processed.

5. Certificate and metadata handling

Pin certificates per connection

The certificate used to verify an assertion's signature must be the certificate associated with that specific SSO connection, not a global trusted certificate. One customer's IdP should not be able to sign an assertion that your application trusts for a different customer's connection.

Check: does your verification code look up the certificate from the connection record before validating, or does it use a global certificate store?

Verify the certificate chain

Do not accept a certificate embedded in the SAML response itself as the trust anchor. Only trust certificates you have previously stored when the connection was configured. If the response contains an <ds:X509Certificate> element, you can use it to identify which stored certificate to use, but you must verify the signature against your stored copy, not the one in the response.

  
// Wrong: trusting the certificate from the response
const cert = response.find('.//ds:X509Certificate').textContent;
verifySignature(doc, cert); // attacker controls this cert

// Correct: looking up the stored certificate for this connection
const cert = await db.getCertificate(connection.id);
verifySignature(doc, cert);
  

Handle multiple certificates during rotation

IdPs publish both their current and upcoming certificate in metadata during rotation windows. Your implementation should accept a signature verified by any certificate currently in the connection's trust store, not just the first one.

  
const certs = await db.getCertificates(connection.id); // returns array
let verified = false;

for (const cert of certs) {
  if (tryVerifySignature(doc, cert)) {
    verified = true;
    break;
  }
}

if (!verified) {
  throw new AuthError('Signature could not be verified against any trusted certificate');
}
  

Check: if you manually rotate an IdP certificate without updating your application's stored certificate first, does SSO break immediately? It should work during the overlap window.

6. Test assertions to run

Before shipping any SAML integration, run all of the following as automated tests. Each test submits a crafted assertion to your ACS endpoint and asserts that it is rejected with an authentication error, not accepted.

Test What to modify Expected result
No signature Remove the <ds:Signature> element entirely Rejected
Untrusted certificate Sign with a certificate not in the connection's trust store Rejected
Wrong issuer Change <saml:Issuer> to an arbitrary string Rejected
Wrong audience Replace the <saml:Audience> value with a different entity ID Rejected
Wrong destination Change the Destination attribute to a different URL Rejected
Expired assertion Set NotOnOrAfter to a timestamp in the past Rejected
Future assertion Set NotBefore to a timestamp in the future Rejected
Replayed assertion Submit the same valid assertion twice Second submission rejected
Wrong InResponseTo Use an ID that was never sent in an AuthnRequest Rejected
SHA-1 signed Sign with rsa-sha1 instead of rsa-sha256 Rejected
Unsigned inner assertion Sign the response envelope but not the inner assertion Rejected
XSW: injected assertion Insert a second <saml:Assertion> with modified attributes before the signed one Rejected

If any of these tests pass when they should fail, you have a validation gap. The last one is the most commonly missed: submit a response containing two assertion elements, where the second is signed correctly and the first contains escalated attributes. Your implementation should not authenticate the user as the identity in the first assertion.

When not to do this yourself

Building a secure SAML SP implementation requires getting every item on this checklist right, keeping up with SAML library CVEs, and maintaining the code as the threat landscape evolves. If your core product is not identity infrastructure, this is a significant ongoing commitment.

WorkOS handles signature validation, XSW prevention, XML parser hardening, replay detection, certificate rotation, and assertion field validation as part of the SSO platform, so your application receives a normalized user profile rather than raw SAML XML. See the WorkOS SSO documentation for details.