Implement SAML SSO for Enterprise Customers in Node.js
Wire samlify into Express to accept IdP-initiated logins from Okta and Entra ID, no auth vendor required.
What you'll build / learn
A Node.js/Express service provider (SP) that accepts IdP-initiated SAML logins from Okta and Microsoft Entra ID using samlify — no Auth0, WorkOS, or other auth vendor in the loop. Users click your app's tile in their company portal and land in an authenticated session.
Prerequisites
- Node.js 20+ (verified on 20.19.6; Express 5 requires ≥18)
- Verified package versions:
samlify2.13.1,express5.2.1,express-session1.19.0,@authenio/samlify-node-xmllint2.0.0 - Admin access to an Okta org or a Microsoft Entra ID tenant (free developer tiers work for both)
- Any OS; commands below assume a POSIX shell
You can test entirely on localhost — with the HTTP-POST binding, the SAML response travels through the user's browser, not server-to-server, so your SP doesn't need to be publicly reachable. One caveat: Entra requires HTTPS reply URLs except for http://localhost.
1. Scaffold the project
mkdir saml-sso && cd saml-sso
npm init -y
npm install express express-session samlify @authenio/samlify-node-xmllint
samlify deliberately refuses to parse anything until you register an XML schema validator — schema validation is one of the defenses against signature-wrapping attacks. @authenio/samlify-node-xmllint is WASM-based, so there's no Java or native-build dependency.
2. Configure the service provider
Create server.js. The SP needs exactly two things an IdP will ask about: an entity ID (a unique name for your app) and an Assertion Consumer Service (ACS) endpoint where the IdP posts the signed response.
const express = require("express");
const session = require("express-session");
const samlify = require("samlify");
const validator = require("@authenio/samlify-node-xmllint");
samlify.setSchemaValidator(validator);
const BASE_URL = process.env.BASE_URL || "http://localhost:3000";
const IDP_METADATA_URL = process.env.IDP_METADATA_URL;
const sp = samlify.ServiceProvider({
entityID: `${BASE_URL}/saml/metadata`,
wantAssertionsSigned: true,
assertionConsumerService: [
{
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
Location: `${BASE_URL}/saml/acs`,
},
],
});
let idp;
wantAssertionsSigned: true makes samlify reject unsigned assertions — Okta and Entra both sign assertions by default, so this costs nothing and closes the most obvious forgery hole.
3. Add the metadata and ACS routes
Append to server.js. The /saml/metadata route serves your SP's XML descriptor (handy for IdP setup); /saml/acs receives the login. On a valid response, samlify has already verified the signature against the IdP's published certificate, checked the issuer matches the metadata, and enforced the assertion's validity window — you just mint a session.
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(
session({
secret: process.env.SESSION_SECRET || "change-me",
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: "lax" },
})
);
app.get("/saml/metadata", (req, res) => {
res.type("application/xml").send(sp.getMetadata());
});
app.post("/saml/acs", async (req, res) => {
try {
const { extract } = await sp.parseLoginResponse(idp, "post", req);
req.session.user = {
nameID: extract.nameID,
attributes: extract.attributes,
};
res.redirect("/profile");
} catch (err) {
console.error("SAML validation failed:", err);
res.status(401).send("SAML login failed");
}
});
app.get("/profile", (req, res) => {
if (!req.session.user) return res.status(401).send("Not logged in");
res.json(req.session.user);
});
async function start() {
const metadataXml = await (await fetch(IDP_METADATA_URL)).text();
idp = samlify.IdentityProvider({ metadata: metadataXml });
app.listen(3000, () => console.log(`SP listening on ${BASE_URL}`));
}
start();
Fetching IdP metadata from a URL at startup means certificate rotations on the IdP side get picked up on your next deploy instead of breaking logins.
4. Register the app in Okta
- In the Okta Admin Console, go to Applications → Applications → Create App Integration and pick SAML 2.0.
- On General Settings, name it and click Next.
- On Configure SAML, set Single sign-on URL to
http://localhost:3000/saml/acsand Audience URI (SP Entity ID) tohttp://localhost:3000/saml/metadata. Leave Name ID format asEmailAddress. Finish the wizard (choose "internal app" on the feedback step). - On the app's Sign On tab, copy the Metadata URL.
- On Assignments, assign yourself to the app — unassigned users get a 403 from Okta, not a SAML error.
5. Register the app in Microsoft Entra ID
- In the Entra admin center, go to Entra ID → Enterprise applications → New application → Create your own application, pick "Integrate any other application you don't find in the gallery (Non-gallery)", and create it.
- Under Manage → Single sign-on, choose SAML. In Basic SAML Configuration, set Identifier (Entity ID) to
http://localhost:3000/saml/metadataand Reply URL (Assertion Consumer Service URL) tohttp://localhost:3000/saml/acs. Save. - In the SAML Certificates card, copy the App Federation Metadata Url.
- Under Users and groups, assign yourself.
Note Entra sends attributes keyed by full claim URIs like http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress — map them accordingly in your user-provisioning code.
Verify it works
Start the SP with the metadata URL you copied:
IDP_METADATA_URL="<your metadata url>" node server.js
You should see SP listening on http://localhost:3000. Confirm the SP descriptor:
curl http://localhost:3000/saml/metadata
Expected output (abridged):
<EntityDescriptor entityID="http://localhost:3000/saml/metadata" ...>
<SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" ...>
<AssertionConsumerService index="0"
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
Location="http://localhost:3000/saml/acs"/>
</SPSSODescriptor>
</EntityDescriptor>
Now trigger the IdP-initiated flow: click the app tile in your Okta End-User Dashboard, or in Entra use Test single sign-on (or the My Apps portal). Your browser posts the signed response to /saml/acs and redirects to /profile, which returns:
{"nameID":"jane@example.com","attributes":{}}
attributes fills in once you configure attribute statements (Okta) or claims (Entra).
Troubleshooting
Your application is potentially vulnerable because no validation function found— you skippedsamlify.setSchemaValidator(validator), or called it after constructing entities. Register the validator immediately after importing samlify.ERR_EXCEPTION_VALIDATE_XML, with a log line likethis is not a valid saml response with errors: ... attribute 'InResponseTo': '' is not a valid value of the atomic type 'xs:NCName'— the response failed schema validation. Common causes: a homegrown test IdP emitting emptyInResponseToattributes on unsolicited responses (real Okta/Entra responses omit the attribute), or a mangledSAMLResponsebody — confirmexpress.urlencoded()is mounted before the ACS route.ERR_UNMATCH_ISSUER— the response's issuer doesn't match the entity ID in the metadata you loaded. You pointedIDP_METADATA_URLat the wrong app or tenant; re-copy it from the exact app registration.ERR_SUBJECT_UNCONFIRMED/ERR_EXPIRED_SESSION— the assertion's validity window failed, almost always server clock skew. Fix your clock (NTP), or as a last resort passclockDrifts: [-60000, 60000]in theServiceProviderconfig to tolerate ±60s.
Next steps
- Add SP-initiated login:
sp.createLoginRequest(idp, "redirect")returns a context URL to send unauthenticated users to the IdP, closing the loop for users who start at your app. - Support multiple customers by keying
IdentityProviderinstances per tenant (metadata URL per customer) and routing on a per-tenant ACS path. - Turn on assertion encryption if assertions carry sensitive attributes: set
isAssertionEncrypted: trueon theIdentityProviderconfig and give theServiceProvideranencPrivateKey. - Read the samlify docs on single logout (SLO) — enterprise security reviews frequently ask for it alongside SSO.
Sources & further reading
- samlify - Highly configurable Node.js SAML 2.0 library — github.com
- samlify documentation — samlify.js.org
- Create SAML app integrations — developer.okta.com
- Enable SAML single sign-on for an enterprise application — learn.microsoft.com
- samlify 2.13.1 on npm — registry.npmjs.org
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 0
No comments yet
Be the first to weigh in.