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
- Installation
- Usage
- Config parameter details
- Security and signatures
- Response validation timestamps
- InResponseTo validation
- Cache provider
- Node support policy
- Contributing
- Changelog
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.
npm install @node-saml/node-samlTypeScript type definitions ship with the package; there is no separate @types package to install.
See the Node support policy for supported runtimes.
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",
// 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, along with Profile, CacheProvider, CacheItem,
ValidateInResponseTo, RacComparison, SignatureAlgorithm, SamlScopingConfig,
SamlIDPListConfig, SamlIDPEntryConfig, IdpCertCallback, AuthOptions, MandatorySamlOptions,
and SamlStatusError.
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.
All three of these methods also accept a deprecated host argument between relayState and
options. It has never been read, and it is removed in the next major version
(#367), so pass options directly:
await saml.getAuthorizeUrlAsync(relayState, host, options); // deprecated
await saml.getAuthorizeUrlAsync(relayState, options); // use thisIf 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).
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.
Two cases resolve without a profile:
- The IdP returned a
LogoutResponserather than an authentication response:{ profile: null, loggedOut: true }. - A
passiverequest could not be satisfied without user interaction (aNoPassivestatus on a validly signed response):{ profile: null, loggedOut: false }.
When the IdP reports a non-Success status, 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.
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 response's InResponseTo, when it carries one. |
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() |
The raw response XML, unsigned and unverified. 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, including parts no signature covers. Never make a trust decision from it. UsegetAssertionXml(),getAssertion(), or the profile fields, all of which come from the verified content. This method exists for backward compatibility and is a candidate for removal in a future major version.
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.
Over the Redirect binding. Redirect-binding signatures are computed over the exact bytes of the query string, so you must hand the raw query string through unchanged — not a re-serialized copy of the parsed object:
const originalQuery = req.url.slice(req.url.indexOf("?") + 1);
const { profile, loggedOut } = await saml.validateRedirectAsync(req.query, originalQuery);Note: on the Redirect binding, a signature is only checked when the message carries a
Signaturequery parameter, because the binding makes signing optional. A message arriving without one is accepted with none of its contents authenticated — the issuer and the timestamps are read from the same unsigned bytes, soidpIssuerdoes not constrain it either. Run withNODE_DEBUG=node-samlto be told when this happens. Configure your IdP to sign its logout messages; a future major version will reject unsigned ones (#419). The POST binding is unaffected:validatePostRequestAsyncalways requires a valid signature.
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 matchingdecryptionPvk. Required if the instance was configured withdecryptionPvk; passnullotherwise.publicCerts— the public certificate matchingprivateKey. Required if the instance was configured withprivateKey. Pass an array to support certificate rotation: the first entry must match the currentprivateKey, and later entries publish upcoming certificates to the IdP before you switch over.
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, and generateUniqueId.
| 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. |
| 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. |
signatureAlgorithm |
"sha1" |
"sha1", "sha256", or "sha512". Set this explicitly; see Security and signatures. |
digestAlgorithm |
"sha1" |
Digest algorithm for the signed data object: "sha1", "sha256", or "sha512". Same advice as above. |
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. |
| 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. |
idpIssuer |
— | If set, the Issuer on incoming logout requests and responses must match it. 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.
| 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
}| Option | Default | Description |
|---|---|---|
validateInResponseTo |
"never" |
"always" validates InResponseTo on every response, "ifPresent" validates it only when the response carries one, "never" skips the check. 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. |
See InResponseTo validation below for what this protects against and how the IDs are consumed.
| 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. |
| 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. |
metadataContactPerson: [
{
"@contactType": "support", // "technical" | "support" | "administrative" | "billing" | "other"
GivenName: "test",
EmailAddress: ["test@node-saml"], // note: an array
},
],
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" }],
},The full shapes are in the SamlOptions type definitions, which your editor will complete for you.
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 accept any xmlbuilder object, so any element is
expressible.
samlAuthnRequestExtensions: {
"md:RequestedAttribute": {
"@isRequired": "true",
"@Name": "LastName",
"@xmlns:md": "urn:oasis:names:tc:SAML:2.0:metadata",
},
vetuma: {
"@xmlns": "urn:vetuma:SAML:2.0:extensions",
LG: { "#text": "sv" },
},
},
samlLogoutRequestExtensions: {
vetuma: {
"@xmlns": "urn:vetuma:SAML:2.0:extensions",
LG: { "#text": "sv" },
},
},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, more than one
signature on an element, an
IDresolving 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. - Validation fails closed. Decrypted content is not trusted content: an
EncryptedAssertionis decrypted and then still has to have its signature verified. Timestamps, audience, issuer, andInResponseToare security controls rather than conveniences — an option that switches one off (audience: false,acceptedClockSkewMs: -1) is removing a control, so make that choice deliberately.
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: "sha1"; // legacy; SHA-1 is no longer considered collision-resistantdigestAlgorithm takes the same three values and controls the digest over the signed data object.
Set both explicitly.
signatureAlgorithmanddigestAlgorithmcurrently default to"sha1", and an unrecognized value falls back to SHA-1 rather than throwing — so a typo silently downgrades you. Both behaviors are retained for backward compatibility, both are wrong, and both are slated for removal in a future major version, after which naming your algorithms will be required. Naming them now costs one line and makes that upgrade a no-op.
To sign authentication requests, provide the private key in PEM format via privateKey. Node-SAML
enforces the RFC 7468 stricttextualmsg format for PEM
files.
privateKey: fs.readFileSync("./privateKey.pem", "latin1");Accepted formats:
-
RFC 7468
stricttextualmsgPEM, 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----- -
A single-line or multi-line private key in Base64, without the delimiter lines. See the single-line private key used in the tests.
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).
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:
-
RFC 7468
stricttextualmsgPEM, 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----- -
A single-line or multi-line certificate in Base64, without the delimiter lines.
Convert it to PEM:
openssl x509 -inform der -in my_certificate.cer -out my_certificate.pemSome 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. 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==";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.
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 both as an attribute of the top-level Response
element and within SubjectConfirmation.
Recorded IDs expire after requestIdExpirationPeriodMs (8 hours by default). A response arriving
with an expired — or unrecognized — InResponseTo is rejected. The ID is consumed on validation, so
the same response cannot be presented twice.
With InResponseTo validation on, the generated request IDs have to be stored somewhere. That is
the cacheProvider's job.
The default is a simple in-memory provider. It is not sufficient across multiple servers or processes: the instance that generated the request ID may not be the one that handles the response, and validation then fails for legitimate logins. For those deployments, back the cache with something shared — Redis, a database, your session store — by implementing:
interface CacheProvider {
/** Store an item in the cache, using the specified key and value. */
saveAsync(key: string, value: string): Promise<CacheItem | null>;
/** Returns the value of the specified key in the cache. */
getAsync(key: string): Promise<string | null>;
/** Removes an item from the cache if the key exists. */
removeAsync(key: string | null): Promise<string | null>;
}CacheProvider and CacheItem are exported from the package root.
We only support Long-Term Support versions of Node.
We specifically limit our support to LTS versions of Node, not because this package won't work on other versions, but because we have a limited amount of time, and supporting LTS offers the greatest return on that investment.
It's possible this package will work correctly on newer versions of Node. It may even be possible to use this package on older versions of Node, though that's more unlikely as we'll make every effort to take advantage of features available in the oldest LTS version we support.
The engines field in package.json is the authoritative statement of what we support. As each Node LTS version reaches its end-of-life we will remove that version from it. Removing a Node version is considered a breaking change and will entail the publishing of a new major version of this package. We will not accept any requests to support an end-of-life version of Node. Any merge requests or issues supporting an end-of-life version of Node will be closed.
We will accept code that allows this package to run on newer, non-LTS, versions of Node.
Issues and pull requests are welcome. A change that touches how a document is accepted, rejected, or
trusted needs a test that fails without it; AGENTS.md documents the standards this
repository holds itself to, and the pull request template lists
what a review looks for. For questions rather than bugs, start in
Discussions.
When a change follows the SAML specification, link the relevant part. Start from the OASIS SAML 2.0 standards.
See CHANGELOG.md.
