npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@node-saml/node-saml

v5.2.0

Published

SAML 2.0 implementation for Node.js

Downloads

5,140,173

Readme

Node SAML

Build Status npm version code style: prettier codecov DeepScan grade

NPM

A SAML 2.0 implementation for Node.js. It acts as the service provider (SP) half of a SAML exchange: it builds the AuthnRequest and logout messages you send to an identity provider (IdP), and it decides whether the responses that come back are trustworthy.

This package is transport-agnostic and framework-agnostic — it takes strings in and hands strings out, and you wire it into whatever HTTP layer you already have. If your application uses Passport, reach for @node-saml/passport-saml instead; it wraps this library in a Passport strategy.

Sponsors

We gratefully acknowledge support from our sponsors:

If your company benefits from node-saml being secure and up-to-date, consider asking them to sponsor the project at $25/month. See the Github Sponsors page for more sponsorship levels. It's easy to do, appearing as another line-item on the Github bill they already have.

Installation

npm install @node-saml/node-saml

TypeScript type definitions ship with the package; there is no separate @types package to install. See the Node support policy for supported runtimes.

Usage

Create a SAML instance

const fs = require("node:fs");
const { SAML } = require("@node-saml/node-saml");

const saml = new SAML({
  // Required
  callbackUrl: "https://sp.example.com/login/callback",
  issuer: "https://sp.example.com/metadata",
  idpCert: fs.readFileSync("./idp-signing-cert.pem", "utf-8"),

  // Where to send the user to authenticate
  entryPoint: "https://idp.example.com/sso",

  // Accept only responses to requests we made; see "InResponseTo".
  validateInResponseTo: "always",

  // Sign our own requests. Set the algorithms explicitly; see "Security and signatures".
  privateKey: fs.readFileSync("./sp-private-key.pem", "utf-8"),
  publicCert: fs.readFileSync("./sp-public-cert.pem", "utf-8"),
  signatureAlgorithm: "sha256",
  digestAlgorithm: "sha256",
});

callbackUrl, issuer, and idpCert are required. Omitting one throws a TypeError naming the option, and so does passing a non-boolean to an option that gates behavior — for example the string "false". All of this happens in the constructor, so a misconfiguration surfaces at startup rather than in the middle of someone's login.

In TypeScript, the constructor takes a SamlConfig and saml.options is a SamlOptions. Both are exported from the package root, and so is every other type the package's API uses: Profile, CacheProvider, CacheItem, InMemoryCacheProvider and its CacheProviderOptions, ValidateInResponseTo, RacComparison, SignatureAlgorithm, SamlSigningOptions, SamlScopingConfig, SamlIDPListConfig, SamlIDPEntryConfig, IdpCertCallback, AuthOptions, MandatorySamlOptions, SamlStatusError, GenerateServiceProviderMetadataParams, and the XML types that SAML's protected methods use: XMLOutput, XmlJsObject, AudienceRestrictionXML, XMLObject, and XMLValue.

Import from the package root only. The compiled modules under lib/ are internal: nothing there is part of the API unless the root exports it. The next major version closes that path, so a deep import will fail to load with ERR_PACKAGE_PATH_NOT_EXPORTED. Anything the root exports moves over unchanged:

// deprecated: fails to load in the next major version
import { SAML } from "@node-saml/node-saml/lib/saml";
import type { Profile } from "@node-saml/node-saml/lib/types";

// use this instead
import { SAML, type Profile } from "@node-saml/node-saml";

The root doesn't export the XML helpers under lib/, such as parseDomFromString, xpath, signXml, and signSamlPost. They wrap @xmldom/xmldom, xpath, and xml-crypto, so depend on those packages directly instead.

Start a login

All three of these require entryPoint to be set.

HTTP-Redirect binding — build a URL and redirect to it:

const url = await saml.getAuthorizeUrlAsync(relayState, options);
res.redirect(url);

relayState is echoed back by the IdP and is omitted from the request when it is an empty string. options is an AuthOptions, whose additionalParams override anything set by additionalParams/additionalAuthorizeParams in the constructor.

The URL never carries an empty RelayState, because identity providers differ on whether a signature covers one. A RelayState of "" in additionalParams means "send none", even when entryPoint has one of its own. From JavaScript, where the types do not stop it, null or undefined there means none was given, like an empty relayState argument, and one in entryPoint is still sent. The next major version reads an empty relayState argument as "send none" too, and takes null or undefined to mean none was given.

All three of these methods also accept a deprecated host argument between relayState and options. It is ignored, and it is removed in the next major version, so pass options directly:

await saml.getAuthorizeUrlAsync(relayState, host, options); // deprecated
await saml.getAuthorizeUrlAsync(relayState, options); // use this

If you subclass SAML and override one of these, migrate the override at the same time: a two-argument call reaches it directly, so an override written for the old signature receives options as host.

HTTP-POST binding — return a self-submitting form:

const html = await saml.getAuthorizeFormAsync(relayState, options);
res.send(html);

This returns a complete HTML document that posts to entryPoint on load, with a <noscript> fallback button for browsers without JavaScript.

If you would rather build the form yourself, getAuthorizeMessageAsync(relayState, options) returns the message as a plain object of form fields (SAMLRequest plus any additional parameters).

Validate the response

The IdP posts the response back to your callbackUrl as a form-encoded SAMLResponse field, so the route needs a body parser.

app.post("/login/callback", express.urlencoded({ extended: false }), async (req, res, next) => {
  try {
    const { profile, loggedOut } = await saml.validatePostResponseAsync(req.body);
    // profile is the authenticated user; see "The profile" below
  } catch (err) {
    next(err);
  }
});

validatePostResponseAsync rejects with an Error on anything it cannot vouch for, and the message says what failed — an invalid signature, a mismatched audience, an expired assertion, and a missing decryption key are all distinguishable. Nothing is returned for a document that did not verify. If something between Node-SAML and you hides that message, run with NODE_DEBUG=node-saml to have it logged; see Troubleshooting.

Two cases resolve without a profile:

  • The IdP returned a LogoutResponse rather than an authentication response: { profile: null, loggedOut: true }. One whose status is not Success, or whose Issuer is not idpIssuer when that is set, is rejected with an Error, such as Bad status code: …, as it is over the Redirect binding.
  • A passive request could not be satisfied without user interaction (a NoPassive status on a validly signed response): { profile: null, loggedOut: false }.

When the IdP reports a non-Success status in a Response, the rejection is a SamlStatusError whose xmlStatus property carries the Status element as XML, so you can surface the IdP's own reason to the user. Be aware that the signature requirement is applied first: many identity providers do not sign their error responses, and under the default wantAuthnResponseSigned: true such a response is rejected for the missing signature before its status is read.

The profile

profile is a Profile: the fields the library understands, plus every AttributeValue in the assertion keyed by its Name. What is populated depends on the message, so check for the fields you rely on rather than assuming they are all present.

| Field | Description | | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | issuer | The assertion's Issuer. | | nameID, nameIDFormat | The subject's name identifier and its format. | | nameQualifier, spNameQualifier | Name qualifiers, when the assertion carries them. | | sessionIndex | The AuthnStatement's SessionIndex; you need it to build a logout request. | | inResponseTo | The InResponseTo a signature covers: the Response's if signed, else the SubjectConfirmationData's. | | mail, email | Convenience aliases. mail falls back to urn:oid:0.9.2342.19200300.100.1.3, and email falls back to mail. | | attributes | Every attribute as a Name → value map. Single-valued attributes are strings; repeated ones are arrays. | | getAssertionXml() | The assertion XML that the signature covers. This is the trustworthy copy. | | getAssertion() | The same assertion, parsed into a JavaScript object. | | getSamlResponseXml() | Deprecated. The response XML as received, not as verified. See the warning below. |

Attributes are also copied onto profile at the top level for convenience, but an attribute never overwrites a field the library set itself.

The profile returned for a LogoutRequest by validatePostRequestAsync and validateRedirectAsync is a smaller thing: ID (the logout request's own ID), issuer, nameID, nameIDFormat, and sessionIndex. It carries no attributes and none of the getters, since there is no assertion.

Warning: getSamlResponseXml() returns the response document as it arrived. When the IdP signs only the assertion, nothing around it — the response's Issuer, Status, and timestamps — is covered by any signature, and the method does not tell you whether that was the case. Never treat what you read from it as authenticated.

getSamlResponseXml() is deprecated and is removed in the next major version; calling it logs a warning under NODE_DEBUG=node-saml. Read the verified assertion instead:

profile.getSamlResponseXml(); // deprecated
profile.getAssertionXml(); // use this, or getAssertion() for the parsed form

Attributes with no value

An attribute sent without a usable value reaches the profile in one of two ways:

<Attribute Name="roles"/>                                            <!-- left out -->
<Attribute Name="team"><AttributeValue/></Attribute>                 <!-- undefined -->
<Attribute Name="team"><AttributeValue xsi:nil="true"/></Attribute>  <!-- undefined -->

The first is left out, so it looks the same as an attribute the identity provider did not send. The others are present with the value undefined, which JSON.stringify drops and code commonly treats as absent.

The next major version keeps the first with a null value, and represents an empty AttributeValue as the empty string and one marked xsi:nil as null, which is what they mean in SAML core §2.7.3.1.1. Run with NODE_DEBUG=node-saml to be told which attributes in a response you received are affected.

Code that tests for a missing value with == null, and reads a value that may be empty with ?? "", works with both versions:

// an attribute with no AttributeValue, or one marked xsi:nil, is undefined now and null in the
// next major version, so this test stops matching it
if (profile.roles === undefined) {
  /* ... */
}

// this test matches it in both
if (profile.roles == null) {
  /* ... */
}

// an empty AttributeValue is undefined now and "" in the next major version; `team` is "" in both
const team = profile.team ?? "";

Single logout (SLO)

Node-SAML supports SP-initiated and IdP-initiated logout, over both the Redirect and POST bindings, including signature validation and decryption of encrypted name identifiers.

SP-initiated. Build a LogoutRequest URL for a user you previously authenticated. The profile you pass needs at least nameID, nameIDFormat, and — if the IdP expects it — sessionIndex:

const url = await saml.getLogoutUrlAsync(profile, relayState, options);
res.redirect(url);

The request goes to logoutUrl, which defaults to entryPoint.

IdP-initiated over POST. Validate the incoming LogoutRequest, then answer it:

const { profile } = await saml.validatePostRequestAsync(req.body);
const url = await saml.getLogoutResponseUrlAsync(profile, relayState, options, true);
res.redirect(url);

getLogoutResponseUrl(profile, relayState, options, success, callback) is the callback-style equivalent of getLogoutResponseUrlAsync.

validatePostRequestAsync also accepts a deprecated second argument, an object of injected dependencies — _parseDomFromString, _parseXml2JsFromString and _validateSignature. It is ignored: nothing passed there can substitute signature verification, which the last of those used to do. The argument is removed in the next major version, and calling it that way logs a warning under NODE_DEBUG=node-saml:

await saml.validatePostRequestAsync(req.body, { _validateSignature }); // deprecated, and ignored
await saml.validatePostRequestAsync(req.body); // use this

Over the Redirect binding. A Redirect-binding signature covers the SAMLRequest or SAMLResponse, the RelayState if there is one, and SigAlg, byte for byte as they arrived. Nothing else in the query string is signed. Hand the raw query string through unchanged, without its leading ?:

const originalQuery = req.url.slice(req.url.indexOf("?") + 1);
const { profile, loggedOut, relayState } = await saml.validateRedirectAsync(originalQuery);

Node-SAML reads the SAMLRequest or SAMLResponse, RelayState, SigAlg and Signature from that string, requires the signature to verify, and returns the RelayState that was signed. Use that relayState, and not the one in a parsed query object such as req.query: a query parser can read a parameter that the signature does not cover as RelayState. A query string with more than one of any of those five parameters, or with both a SAMLRequest and a SAMLResponse, is rejected, and so is a message with no Signature.

validateRedirectAsync also accepts a deprecated first argument, the parsed query object, before the query string. That form is removed in the next major version, and calling it logs a warning under NODE_DEBUG=node-saml:

await saml.validateRedirectAsync(req.query, originalQuery); // deprecated
await saml.validateRedirectAsync(originalQuery); // use this, and the `relayState` it returns

The deprecated form returns no relayState. A signed message is read from originalQuery there too, and is treated as signed when either argument has a Signature, even an empty one. Because its caller goes on reading the parsed object, it also rejects a signed query string that names one of the five parameters in bracket notation, such as RelayState[] or [RelayState], which the qs parser reads as RelayState. It rejects a signed message, too, when the parsed object holds a RelayState that is not the one in originalQuery, as it does when the parser splits or nests the query string by other rules: one that also splits on ; reads x=1;RelayState=... as a RelayState.

Note: the deprecated form still accepts a message with no Signature parameter, because the binding makes signing optional. Such a message is accepted with none of its contents authenticated — the issuer and the timestamps are read from the same unsigned bytes, so idpIssuer only checks a value the sender wrote and does not show who sent it. Run with NODE_DEBUG=node-saml to be told when this happens. Configure your IdP to sign its logout messages before you move to validateRedirectAsync(originalQuery), which rejects unsigned ones, as the next major version does for every call. The POST binding is unaffected: validatePostRequestAsync always requires a valid signature.

On the POST binding the profile is built only from the bytes that signature covers. The signature has to envelope the message the way SAML core §5.4 and the protocol schema require — a ds:Signature that is a child of the LogoutRequest element it references — so a request whose signature sits elsewhere in the document is rejected even though that signature verifies.

Service provider metadata

Most identity providers will take a metadata document instead of asking you to type the same values into a form.

const metadata = saml.generateServiceProviderMetadata(decryptionCert, publicCerts);
  • decryptionCert — the public certificate matching decryptionPvk. Required if the instance was configured with decryptionPvk; pass null otherwise.
  • publicCerts — the public certificate matching privateKey. Required if the instance was configured with privateKey. Pass an array to support certificate rotation: the first entry must match the current privateKey, and later entries publish upcoming certificates to the IdP before you switch over.

Both are read by the rules described under privateKey, as PEM or as Base64, and published as a single line of Base64. decryptionCert, and each entry of publicCerts, must hold exactly one certificate; a value holding several, or holding a public key rather than a certificate, is refused with an error naming it.

The underlying function is also exported directly, for generating metadata without constructing a SAML instance:

const { generateServiceProviderMetadata } = require("@node-saml/node-saml");

const metadata = generateServiceProviderMetadata({
  issuer: "https://sp.example.com/metadata",
  callbackUrl: "https://sp.example.com/login/callback",
});

It accepts issuer and callbackUrl plus the metadata-relevant options from the configuration tables below: logoutCallbackUrl, identifierFormat, wantAssertionsSigned, decryptionPvk, decryptionCert, privateKey, publicCerts, signatureAlgorithm, digestAlgorithm, xmlSignatureTransforms, signMetadata, metadataContactPerson, metadataOrganization, metadataAttributeConsumingServices, and generateUniqueId.

Called directly, it signs the metadata when signMetadata is true and privateKey is set. Choose both algorithms when it does: signatureAlgorithm has no default here, so omitting it is an error, while an omitted digestAlgorithm selects sha1, or is an error too when signatureAlgorithm is "sha256-mgf1". The note under signatureAlgorithm applies to this function too, and it logs the same warnings under NODE_DEBUG=node-saml.

Config parameter details

Required

| Option | Type | Description | | ------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | callbackUrl | string | The SP endpoint the IdP posts the response back to; becomes the AssertionConsumerServiceURL. | | issuer | string | The issuer string identifying this service provider to the IdP. | | idpCert | string \| string[] \| IdpCertCallback | The IdP's signing certificate(s) or public key(s), used to validate incoming signatures. See Security and signatures. |

The next major version checks received responses against callbackUrl: an assertion has to carry a bearer SubjectConfirmation whose Recipient is callbackUrl (SAML profiles §4.1.4.3), and a signed Response has to carry a Destination that is callbackUrl (SAML bindings §3.5.5.2). URLs are compared after parsing, so the case of the scheme and host and an explicit default port don't count, but the path does, trailing slash included. Until then a response that fails either check is accepted. Run with NODE_DEBUG=node-saml to be told when one arrives, and set callbackUrl to the URL your IdP actually posts to.

Core

| Option | Default | Description | | ------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | entryPoint | — | The IdP's SSO endpoint. Required to generate any authentication request, and required by the specification when the request is signed. | | audience | issuer | Expected Audience in the response. Set to false to skip the check — which removes a security control; see the note under Security and signatures. | | privateKey | — | SP private key in PEM format, used to sign outgoing messages. See Security and signatures. | | publicCert | — | SP public signing certificate, embedded in the AuthnRequest so the IdP can verify it. Must match privateKey. | | decryptionPvk | — | Private key used to decrypt encrypted assertions and encrypted name identifiers. See Configuration option decryptionPvk. | | signatureAlgorithm | "sha1" | "sha1", "sha256", "sha256-mgf1", or "sha512". Set this explicitly if you set privateKey; see Configuration option signatureAlgorithm. | | digestAlgorithm | "sha1" | Digest algorithm for the signed data object: "sha1", "sha256", or "sha512". Same advice as above. Required with "sha256-mgf1". | | xmlSignatureTransforms | enveloped-signature + exc-c14n | Signature transforms used in HTTP-POST signatures. The default is ["http://www.w3.org/2000/09/xmldsig#enveloped-signature", "http://www.w3.org/2001/10/xml-exc-c14n#"]. | | generateUniqueId | built-in | Function returning the unique IDs used for outgoing SAML messages. |

Response validation

| Option | Default | Description | | -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | wantAssertionsSigned | true | Require the assertion itself to be signed, and advertise WantAssertionsSigned="true" in the metadata. | | wantAuthnResponseSigned | true | Require the response to be signed at the top level, not only at the assertion. | | acceptedClockSkewMs | 0 | Tolerance in milliseconds when checking NotBefore and NotOnOrAfter. -1 disables those checks entirely. | | maxAssertionAgeMs | 0 | Reject an assertion older than this, measured from its IssueInstant. 0 means no limit beyond NotOnOrAfter. When set and stricter than NotOnOrAfter, this wins. | | rejectReplayedAssertions | false | Reject an assertion whose ID has been received before. See Replayed assertions. | | idpIssuer | — | If set, it must match the Issuer of the assertion, of logout messages, and of a signed Response that has one. For ADFS this looks like https://acme_tools.windows.net/deadbeef. |

Turning both wantAssertionsSigned and wantAuthnResponseSigned off does not turn signature checking off: either the response or the assertion still has to carry a valid signature, or the document is rejected.

AuthnRequest content

| Option | Default | Description | | -------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | identifierFormat | urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress | NameID format to request. Set to null to leave the Format attribute off the NameIDPolicy and the NameIDFormat element out of the metadata. | | allowCreate | true | Let the IdP create a new subject identifier. | | spNameQualifier | — | Request that the subject identifier be returned or created in another SP's namespace, or in that of an affiliation of service providers. | | authnContext | ["urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"] | Requested authentication context classes. Must be an array, even for a single value. | | racComparison | "exact" | How the IdP should compare the requested context: "exact", "minimum", "maximum", or "better". | | disableRequestedAuthnContext | false | Omit RequestedAuthnContext entirely. | | forceAuthn | false | Ask the IdP to re-authenticate the user even if they hold a valid session. | | passive | false | Ask the IdP not to take visible control of the user interface. See the NoPassive case in Validate the response. | | providerName | — | Human-readable name of the requester, for the presenter's user agent or the IdP. | | attributeConsumingServiceIndex | — | Tells the IdP which attribute set to attach to the response (background). | | disableRequestAcsUrl | false | Omit the optional AssertionConsumerServiceURL from the request. | | skipRequestCompression | false | Send the request uncompressed instead of DEFLATE-compressed. | | authnRequestBinding | "HTTP-Redirect" | Recorded on the instance for consumers such as passport-saml to act on. Within this library the binding follows from the method you call — getAuthorizeUrlAsync for Redirect, getAuthorizeFormAsync for POST. | | additionalParams | {} | Query parameters added to every outgoing request. | | additionalAuthorizeParams | {} | Query parameters added to authorize requests only. | | scoping | — | Scoping element contents; see below. |

scoping implements SAML core §3.4.1.2, <Scoping>:

scoping: {
  idpList: [ // optional
    {
      entries: [ // required
        {
          providerId: "yourProviderId", // required for each entry
          name: "yourName", // optional
          loc: "yourLoc", // optional
        },
      ],
      getComplete: "URI to your complete IDP list", // optional
    },
  ],
  proxyCount: 2, // optional
  requesterId: "requesterId", // optional; a string or an array of strings
}

InResponseTo

| Option | Default | Description | | ----------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | validateInResponseTo | "never" | "always", "ifPresent", or "never". Set this explicitly; the trade-offs are below. The ValidateInResponseTo enum is exported for this. | | requestIdExpirationPeriodMs | 28800000 (8h) | How long a generated request ID stays valid for matching against an incoming InResponseTo. | | cacheProvider | in-memory | Where request IDs are stored. See Cache provider. |

Set validateInResponseTo explicitly. It defaults to "never" today, and the next major version requires it; until then, leaving it unset logs a warning under NODE_DEBUG=node-saml. Which value fits depends on how logins reach you:

  • "always" accepts only a response that answers a request Node-SAML recorded, and removes that request ID as it accepts the response, so a response cannot be delivered unsolicited or presented again later. When the cache provider consumes IDs atomically with consumeAsync, as the built-in one does, two copies arriving at the same moment cannot both be accepted either. It rejects IdP-initiated logins, and on more than one server or process it needs a shared cache provider.
  • "ifPresent" validates InResponseTo when a response carries one and accepts a response that does not. IdP-initiated login keeps working, and an unsolicited response is accepted and can be replayed until its timestamps expire, unless you set rejectReplayedAssertions.
  • "never" skips the check, so any captured response can be replayed until its timestamps expire, unless you set rejectReplayedAssertions.

See InResponseTo validation below for what this protects against and how the IDs are consumed.

Logout

| Option | Default | Description | | ------------------------ | ------------ | ------------------------------------------------------------------------------------------------- | | logoutUrl | entryPoint | Address to send logout requests to. | | additionalLogoutParams | {} | Query parameters added to logout requests only. | | logoutCallbackUrl | — | The Location for the SingleLogoutService elements in the generated service provider metadata. |

The next major version also compares the Destination of a signed LogoutRequest or LogoutResponse with logoutCallbackUrl, in the same way as for callbackUrl above. Until then such a message is accepted. With logoutCallbackUrl set, run with NODE_DEBUG=node-saml to be told when a Destination differs from it or is missing.

Metadata

| Option | Default | Description | | ------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | signMetadata | false | Sign the generated service provider metadata. Requires privateKey. | | metadataContactPerson | — | ContactPerson entries to include in the generated metadata. An array, since metadata may carry several. | | metadataOrganization | — | Organization details to include in the generated metadata. | | metadataAttributeConsumingServices | — | AttributeConsumingService entries to include in the generated metadata, each listing the attributes this service provider asks the IdP for. |

metadataContactPerson: [
  {
    "@contactType": "support", // "technical" | "support" | "administrative" | "billing" | "other"
    GivenName: "test",
    EmailAddress: ["mailto:test@node-saml"], // an array of `mailto:` URIs
  },
],
metadataOrganization: {
  OrganizationName: [{ "@xml:lang": "en", "#text": "node-saml" }],
  OrganizationDisplayName: [{ "@xml:lang": "en", "#text": "node-saml" }],
  OrganizationURL: [{ "@xml:lang": "en", "#text": "https://github.com/node-saml/node-saml" }],
},
metadataAttributeConsumingServices: [
  {
    "@index": "0",
    "@isDefault": true,
    ServiceName: [{ "@xml:lang": "en", "#text": "My Service" }],
    ServiceDescription: [{ "@xml:lang": "en", "#text": "Needs the user's name and email" }],
    RequestedAttribute: [
      {
        "@Name": "urn:oid:2.5.4.42",
        "@NameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
        "@FriendlyName": "givenName",
        "@isRequired": true,
      },
      {
        "@Name": "urn:oid:0.9.2342.19200300.100.1.3",
        "@NameFormat": "urn:oasis:names:tc:SAML:2.0:attrname-format:uri",
        "@FriendlyName": "mail",
        "@isRequired": false,
      },
    ],
  },
],

Extensions, on a contact or on the organization, takes namespace-qualified elements as an xmlbuilder object, like the options under Extensions below. A string there cannot produce valid metadata. Both also take namespace-qualified attributes, each written with its prefix next to the @xmlns: declaration of that prefix. The elements and the attributes come from a namespace that SAML does not define: not the SAML 2.0 metadata, assertion or protocol namespace. A REFEDS security contact is marked that way:

metadataContactPerson: [
  {
    "@contactType": "other",
    "@xmlns:remd": "http://refeds.org/metadata",
    "@remd:contactType": "http://refeds.org/metadata/contactType/security",
    EmailAddress: ["mailto:[email protected]"],
  },
],

The SamlOptions types do not declare such attributes until the next major version. In TypeScript, build the entry outside the config literal, or cast it.

Each metadataAttributeConsumingServices entry needs an @index from "0" to "65535" that no other entry uses, at least one ServiceName and at least one RequestedAttribute, and at most one entry may set @isDefault to true. An entry that breaks these rules, carries a key not shown above, or holds a malformed value such as an @xml:lang that is not a language tag, is rejected with a TypeError when the SAML is constructed or the metadata is generated. Set attributeConsumingServiceIndex to have an AuthnRequest select one of them by its @index.

The full shapes are in the SamlOptions type definitions, which your editor will complete for you. metadataContactPerson and metadataOrganization are not rejected yet: either one in any other shape is still written into the metadata as given, which can leave the metadata invalid. Run with NODE_DEBUG=node-saml to be told when that happens. The next major version rejects it.

Extensions

samlAuthnRequestExtensions and samlLogoutRequestExtensions add an Extensions element to the generated AuthnRequest and LogoutRequest. They are useful for things like the requested attributes protocol extension, and take an xmlbuilder object of namespace-qualified elements.

samlAuthnRequestExtensions: {
  "req-attr:RequestedAttributes": {
    "@xmlns:req-attr": "urn:oasis:names:tc:SAML:protocol:ext:req-attr",
    "md:RequestedAttribute": {
      "@xmlns:md": "urn:oasis:names:tc:SAML:2.0:metadata",
      "@isRequired": "true",
      "@Name": "LastName",
    },
  },
  vetuma: {
    "@xmlns": "urn:vetuma:SAML:2.0:extensions",
    LG: { "#text": "sv" },
  },
},

samlLogoutRequestExtensions: {
  vetuma: {
    "@xmlns": "urn:vetuma:SAML:2.0:extensions",
    LG: { "#text": "sv" },
  },
},

Each element directly inside Extensions comes from a namespace that SAML does not define: not the SAML 2.0 assertion, protocol or metadata namespace. That is why the requested attributes above sit inside the extension's own RequestedAttributes element, and not directly in Extensions. Neither option is rejected yet: one in any other shape is still written into the request as given. Run with NODE_DEBUG=node-saml to be told when that happens. The next major version rejects it.

Security and signatures

Node-SAML uses the HTTP-Redirect binding for its AuthnRequests (unless you call getAuthorizeFormAsync for HTTP-POST) and expects the messages back over the HTTP-POST binding.

Three properties hold throughout response validation, and they are worth knowing because they explain rejections that might otherwise look overly strict:

  • Only signed bytes are trusted. Verification returns the content the signature actually covers, and that is the content the library goes on to process. The original document is never re-read after verification, because an attacker controls the difference between the two — that is the whole of an XML signature wrapping attack.
  • Ambiguity is rejected, not resolved. A response with more than one assertion, an assertion, a logout message or a signed Response with more than one Issuer, more than one signature on an element, an ID resolving to more than one element, a reference pointing anywhere other than its own parent, or more than two transforms is refused. The library does not pick a reading, and it does not pick the reading that happens to verify. One case is not refused yet: a signature with more than one reference is ignored, so its element counts as unsigned. That matters only for a Response under wantAuthnResponseSigned: false, which is then accepted on the strength of its separately signed assertion. The next major version rejects such a message; until then it logs a warning under NODE_DEBUG=node-saml. Configure the identity provider to sign with a single reference, to the element signed, as SAML core §5.4.2 requires.
  • Validation fails closed. Decrypted content is not trusted content: an EncryptedAssertion is decrypted and then still has to have its signature verified. Timestamps, audience, issuer, and InResponseTo are security controls rather than conveniences — an option that switches one off (audience: false, acceptedClockSkewMs: -1) is removing a control, so make that choice deliberately.

Subclassing SAML

Overriding a protected method of SAML that validates an incoming message is deprecated, and the next major version makes those methods private: an override can switch off a check that the properties above depend on. That is every protected method except signRequest and generateAuthorizeRequestAsync. Check what the public method returns instead, and relax a check only through its option under Response validation:

// deprecated
class TenantSaml extends SAML {
  async processValidlySignedAssertionAsync(...args) {
    const result = await super.processValidlySignedAssertionAsync(...args);
    assertKnownTenant(result.profile);
    return result;
  }
}

// use this instead
const { profile } = await saml.validatePostResponseAsync(req.body);
if (profile) {
  assertKnownTenant(profile);
}

To choose the certificates a signature is verified with at run time, pass a function as idpCert rather than overriding getKeyInfosAsPem. To check the Issuer of a logout message, set idpIssuer rather than overriding verifyIssuer: a LogoutResponse resolves with profile: null, so its result has no issuer to check.

Verifying a signature outside SAML

lib/xml exports getVerifiedXml(), which upholds the first property above, and the deprecated validateSignature(), which doesn't. Like everything under lib/, neither is part of the API: the next major version closes the path to both, and removes validateSignature().

To validate a SAML message, use the SAML methods above, which apply every check in this section. To verify other signed XML, use xml-crypto directly, and read the signed content from getSignedReferences() rather than from the document you passed in, as its Verifying Xml documents section shows. validateSignature() reports only that a signature verified, which leaves you to find the signed content somewhere else, and an attacker controls the difference between what verified and what you then read. That is an XML signature wrapping attack. xml-crypto doesn't apply the SAML-specific checks listed above, such as requiring a signature's reference to point at the signature's own parent, so add any your documents need:

// deprecated: `dom` is the document as received, not the part the signature covered
if (validateSignature(xml, dom.documentElement, pemFiles)) {
  readTheProfileFrom(dom);
}

// use this instead
const sig = new SignedXml({ publicCert });
sig.loadSignature(signatureElement);
if (!sig.checkSignature(xml)) {
  throw new Error("Invalid signature");
}

readTheProfileFrom(new DOMParser().parseFromString(sig.getSignedReferences()[0], "text/xml"));

Configuration option signatureAlgorithm

Requests sent by Node-SAML can be signed using RSA with SHA-1, SHA-256, or SHA-512.

signatureAlgorithm: "sha256"; // preferred — your IdP should support it; if not, consider upgrading the IdP
signatureAlgorithm: "sha512"; // strongest — check that your IdP supports it
signatureAlgorithm: "sha256-mgf1"; // SHA-256 with RSASSA-PSS padding — only if your IdP asks for it
signatureAlgorithm: "sha1"; // legacy; SHA-1 is no longer considered collision-resistant

"sha1", "sha256" and "sha512" sign with PKCS #1 v1.5 padding, which is what most IdPs expect. "sha256-mgf1" signs with RSASSA-PSS instead and names itself http://www.w3.org/2007/05/xmldsig-more#sha256-rsa-MGF1 (RFC 9231 §2.3.10), with a salt the length of the digest. Choose it when your IdP requires PSS signatures; an IdP that does not know the identifier will reject the request. It applies to everything Node-SAML signs: Redirect-binding requests and logout responses, POST-binding AuthnRequests, and metadata.

What your IdP signs is a separate matter and needs no option: a message it signs with sha256-rsa-MGF1 is verified on both bindings.

On the Redirect binding, the next major version verifies a signature only when SigAlg is exactly one of these identifiers:

  • http://www.w3.org/2000/09/xmldsig#rsa-sha1
  • http://www.w3.org/2001/04/xmldsig-more#rsa-sha256
  • http://www.w3.org/2001/04/xmldsig-more#rsa-sha384
  • http://www.w3.org/2001/04/xmldsig-more#rsa-sha512
  • http://www.w3.org/2007/05/xmldsig-more#sha256-rsa-MGF1

Other values that verify today, such as http://www.w3.org/2001/04/xmldsig-more#rsa-sha224 or a bare RSA-SHA256, are rejected then. Run with NODE_DEBUG=node-saml to be told when your IdP sends one.

digestAlgorithm takes "sha1", "sha256", or "sha512" and controls the digest over the signed data object. It does not take "sha256-mgf1", which names a signature padding rather than a digest; pair that with digestAlgorithm: "sha256".

Set both explicitly if you sign, and check the spelling. With privateKey set, leaving signatureAlgorithm or digestAlgorithm unset selects sha1, which is no longer considered safe for signatures; the next major version requires both whenever privateKey is set. A value that is not one of those listed above — including a casing difference such as "SHA256" — is not an error today either: it falls through to SHA-1, so a typo silently downgrades the signature you asked for. The next major version rejects it instead. digestAlgorithm is typed as a plain string, so TypeScript does not catch a typo in it. Run with NODE_DEBUG=node-saml to be told when any of this happens, whether the options go to SAML or straight to generateServiceProviderMetadata.

The exception is signatureAlgorithm: "sha256-mgf1": with it, a digestAlgorithm that is unset or not one of those listed is already an error.

Configuration option privateKey

To sign authentication requests, provide the private key in PEM format via privateKey. Node-SAML reads it with xml-crypto's toPem(), and uses what that returns: canonical RFC 7468 PEM, with \n line endings, lines of 64 characters, and one message after another.

What it accepts is more liberal than RFC 7468's stricttextualmsg:

  • whitespace surrounding the value, and a leading UTF-8 byte order mark, whether the value arrives as a string or a Buffer;
  • any of the three line-ending conventions;
  • encoded data wrapped at any width, or not wrapped at all, with spaces or tabs anywhere in it;
  • a blank line after the -----BEGIN ...----- boundary;
  • several PEM messages concatenated in one value, optionally separated by blank lines. Signing and verification each take only one key from such a value, so it suits a private key stored alongside its certificate, but not a set of keys: to trust several IdP certificates, give idpCert an array.

It rejects, with an error naming the option and giving the reason:

  • encoded data that is not valid Base64 as RFC 4648 section 4 defines it — = padding away from the end, or a last group that is incomplete — rather than decoding as much of it as it can;
  • a message whose -----BEGIN and -----END labels disagree;
  • a CERTIFICATE whose data is not exactly one X.509 certificate;
  • a PRIVATE KEY or RSA PRIVATE KEY whose data Node cannot read as a private key under that label, such as PKCS #1 data labelled PRIVATE KEY;
  • text before, after or between the messages, and a boundary sharing its line with other text.

xml-crypto documents the complete rules under What the parser accepts. A Buffer is read as the text of a PEM or Base64 file, just as a string is; DER is not accepted, so convert it to PEM first as shown under idpCert.

privateKey: fs.readFileSync("./privateKey.pem", "latin1");

Accepted formats:

  1. RFC 7468 PEM, with either label:

    -----BEGIN PRIVATE KEY-----
    <private key contents here delimited at 64 characters per row>
    -----END PRIVATE KEY-----
    -----BEGIN RSA PRIVATE KEY-----
    <private key contents here delimited at 64 characters per row>
    -----END RSA PRIVATE KEY-----
  2. A single-line or multi-line private key in Base64, without the delimiter lines. See the single-line private key used in the tests. Base64 carries no label, so it is read as a PKCS #8 key (PRIVATE KEY) or, failing that, as a PKCS #1 RSA key (RSA PRIVATE KEY). Base64 that is neither, such as a certificate or a public key, is refused with an error naming privateKey.

Configuration option decryptionPvk

To decrypt encrypted assertions and encrypted name identifiers, provide the SP's private key via decryptionPvk. Its certificate is decryptionCert, which the IdP encrypts to; see Service provider metadata.

decryptionPvk: "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXJP47MTKaFpkU ... ==";

Accepted formats:

  1. PEM, handed to Node's crypto as given. That is how decryptionPvk has always been read, so Node's rules apply rather than the ones listed under privateKey. In particular, text before or after the message, such as the Bag Attributes lines that openssl pkcs12 writes, is passed over rather than refused.
  2. A single-line or multi-line private key in Base64, without the delimiter lines, read exactly as for privateKey: PKCS #8 or PKCS #1, whichever the data holds. Base64 that is neither is refused with an error naming decryptionPvk, before any decryption is attempted.

Preparing for the next major version

Version 5.2 continues to decrypt the legacy forms below, but the next major version will not. Set NODE_DEBUG=node-saml to see warnings after successful decryption of an EncryptedAssertion or EncryptedID that uses one of them. These warnings do not change whether a message is accepted.

  • Content encryption: aes128-cbc, aes256-cbc, and tripledes-cbc will be refused. Configure the identity provider to use aes128-gcm or aes256-gcm instead.
  • Key transport: rsa-1_5 will be refused. It already fails to decrypt on Node 18 and Node 20; other runtimes may still accept it in 5.x. Configure the identity provider to wrap encryption keys using rsa-oaep-mgf1p with its default SHA-1 digest instead.
  • OAEP digest: An rsa-oaep-mgf1p key with a DigestMethod of http://www.w3.org/2001/04/xmlenc#sha256, http://www.w3.org/2001/04/xmlenc#sha512, http://www.w3.org/2000/09/xmldsig#sha256 or http://www.w3.org/2000/09/xmldsig#sha512 will no longer decrypt. The next major version uses that digest for OAEP and SHA-1 for MGF1, as XML Encryption 1.1 §5.5.2 requires, and a key that 5.2 decrypts with one of these was wrapped another way. Configure the identity provider to use SHA-1 (the default) with rsa-oaep-mgf1p for now. Both versions decrypt that form. After the upgrade, the identity provider can switch to SHA-256 with http://www.w3.org/2001/04/xmlenc#sha256, provided it uses SHA-1 for MGF1.

If your identity provider cannot use AES-GCM or rsa-oaep-mgf1p, and your deployment does not require assertion confidentiality, disable assertion encryption at the identity provider. An unencrypted assertion is accepted even when decryptionPvk is configured, and node-saml still verifies the required signatures. Signatures protect integrity, not confidentiality: the browser and any other system that handles the response can read an unencrypted assertion's NameID and attributes. The same holds for an encrypted logout NameID: disable NameID encryption at the identity provider only if the NameID need not be kept confidential. Logout signature validation still applies.

Configuration option idpCert

Validating the signatures on incoming responses is the point of this library, and idpCert is what it validates them against. Provide the IdP's public X.509 signing certificate(s) or public key(s). The same normalization, tolerances and rejections described under privateKey apply here.

idpCert: "MIICizCCAfQCCQCY8tKaMc0BMjANBgkqh ... W==";

If the IdP has several valid signing certificates or public keys — during a rollover, for instance, when responses signed with either key are valid — pass an array:

idpCert: ["MIICizCCAfQCCQCY8tKaMc0BMjANBgkqh ... W==", "MIIEOTCCAyGgAwIBAgIJAKZgJdKdCdL6M ... g="];

idpCert can also be a function taking a node-style callback, which lets you poll the IdP for its current keys so a rotation is picked up without a restart. The result is not cached, so the function is called on every validation:

idpCert: (callback) => {
  callback(null, polledCertificates);
};

Accepted formats:

  1. RFC 7468 PEM, as a certificate or a bare public key:

    -----BEGIN CERTIFICATE-----
    <certificate contents here delimited at 64 characters per row>
    -----END CERTIFICATE-----
    -----BEGIN PUBLIC KEY-----
    <public key contents here delimited at 64 characters per row>
    -----END PUBLIC KEY-----
  2. A single-line or multi-line certificate in Base64, without the delimiter lines.

If the certificate is in the binary DER encoding

Convert it to PEM:

openssl x509 -inform der -in my_certificate.cer -out my_certificate.pem

Configuration option publicCert

Some identity providers require the SP's public signing certificate to be embedded in the AuthnRequest, so they can verify the request, match the subject DN, and confirm the certificate was signed. Pass it as publicCert; it must match privateKey, and it must hold at least one certificate: a public key alone is refused, because it would leave KeyInfo out of the signature. The same two formats are accepted:

-----BEGIN CERTIFICATE-----
<X.509 certificate contents here delimited at 64 characters per row>
-----END CERTIFICATE-----

or

publicCert: "MIICizCCAfQCCQCY8tKaMc0BMjANBgkqh ... W==";

Response validation timestamps

When a response carries NotBefore or NotOnOrAfter, Node-SAML validates them against the current time plus or minus acceptedClockSkewMs, which accounts for drift between your server's clock and the IdP's. The default skew is 0.

Both attributes are honored on the SubjectConfirmation element and within Assertion/Conditions. maxAssertionAgeMs adds an independent limit measured from the assertion's IssueInstant, and applies when it is stricter than NotOnOrAfter. An assertion whose SubjectConfirmation elements are all outside their window is rejected whatever validateInResponseTo is set to.

InResponseTo validation

InResponseTo ties a response back to a request you actually made, which is what stops a response captured elsewhere from being replayed at your callback. Turn it on with validateInResponseTo: "always".

Node-SAML then records the ID of every request it generates, and a response validates only if its InResponseTo matches one of them. It is checked as an attribute of the top-level Response or LogoutResponse element, and within SubjectConfirmation.

Only a signature makes the top-level attribute trustworthy, so when the IdP signs the assertion but not the Response, "always" requires one of the assertion's SubjectConfirmationData elements, within its validity window, to carry InResponseTo, and rejects the response otherwise. A conforming IdP already puts it there (SAML profiles §4.1.4.2). If yours does not, have it sign the Response.

Recorded IDs expire after requestIdExpirationPeriodMs (8 hours by default). A response arriving with an expired — or unrecognized — InResponseTo is rejected. Accepting a response removes its request ID, so presenting the same response again later fails. Two copies arriving at the same moment can both be accepted unless the cache provider removes the ID atomically, which the built-in one does and a custom one does if it implements consumeAsync, described below.

While InResponseTo is validated — always under "always", and under "ifPresent" when the Response or LogoutResponse element carries one — a request ID is removed when a verified signature covers an InResponseTo naming it, on that element or on a SubjectConfirmationData in the assertion, whether the response is then accepted or rejected. The exception is a copy rejected as a replayed assertion, which leaves the ID to the first copy, since that copy may still be validating. Nothing else removes one. Anyone who learns a pending request ID can put it in a response, so an unsigned InResponseTo is still checked against the recorded IDs but cannot by itself retire one, and the IdP's genuine response validates after it. That covers a rejected response in which nothing signed names the request, an unsigned Response accepted under "ifPresent" on the strength of its assertion alone, and an unsigned Redirect-binding LogoutResponse. None of these can be presented again with more effect than before: an unsigned InResponseTo can be deleted, making the response unsolicited, and an unsigned logout message can be forged outright.

A signature shows that the IdP answered a request, not that whoever presents the response started it. Someone with an account at your IdP who gets it to answer a request carrying another user's pending ID receives a genuinely signed response, and presenting it retires that user's request. When AuthnRequests are unsigned, knowing the ID is enough to build such a request. Signing them (set privateKey, and have the IdP require signed requests) leaves only a replay of that user's own AuthnRequest, which some IdPs reject.

Replayed assertions

InResponseTo validation cannot stop an unsolicited response from being presented twice: it carries no InResponseTo, and an unsigned one can be deleted. Set rejectReplayedAssertions: true to have Node-SAML remember the ID of each assertion it receives, in the cache provider, and reject one whose ID it has seen (SAML profiles §4.1.4.5). The second copy fails with The assertion <ID> has already been received. It gives IdP-initiated lo