I was recently tasked with adding a login system to a web application for a large organization. They didn’t want another username and password for users to remember. They wanted employees to use their existing company credentials. This is a common request in the business world, and the solution is a standard called SAML. It’s the backbone of Single Sign-On (SSO) that you use daily at work. Today, I want to show you how to build this yourself with Node.js. It’s simpler than it sounds, and by the end, you’ll have a production-ready system. If you find this guide helpful, please consider liking, sharing, or leaving a comment with your thoughts.
Let’s start with the basics. SAML, or Security Assertion Markup Language, is a way for two systems to talk about who a user is. One system, called the Identity Provider (like your company’s Okta or Microsoft login page), confirms your identity. The other system, your application (called the Service Provider), trusts that confirmation. The user clicks “Login,” gets sent to their company login page, and then comes back to your app, already logged in. No new passwords are created.
Why would you use SAML over something like OAuth? SAML is older, XML-based, and very common inside organizations for employee-facing apps. OAuth is often better for consumer apps or granting API access. If your client says “we need SSO,” they often mean SAML.
Setting up the project is straightforward. We’ll use Express, Passport.js (a popular authentication library), and a strategy called passport-saml. First, create a new directory and initialize it.
mkdir saml-sso-app
cd saml-sso-app
npm init -y
Now, install the core dependencies. We need Passport, the SAML strategy, a way to manage user sessions, and some security helpers.
npm install express passport passport-saml express-session
npm install helmet cors dotenv
For development, TypeScript is helpful for catching errors early.
npm install -D typescript @types/express @types/passport @types/express-session ts-node nodemon
The heart of SAML security is certificates. Your app and the identity provider use these to sign and verify messages, proving they are who they say they are. You can generate a test certificate for your app (the Service Provider) using OpenSSL.
openssl req -newkey rsa:2048 -nodes -keyout sp-key.pem -x509 -days 365 -out sp-cert.pem
Store these .pem files in a certs folder. Never commit your private key (sp-key.pem) to a public repository. The identity provider will give you their certificate.
How does your app know where to send users for login? That’s where configuration comes in. Create a .env file to keep settings out of your code.
# Your Application (Service Provider)
SP_ENTITY_ID=http://localhost:3000
SP_CALLBACK_URL=http://localhost:3000/auth/saml/callback
SP_LOGOUT_URL=http://localhost:3000/auth/logout/callback
# Your Company's Login (Identity Provider - Okta example)
IDP_ENTRY_POINT=https://your-company.okta.com/app/appname/sso/saml
IDP_ISSUER=http://www.okta.com/your-app-id
IDP_CERT_PATH=./certs/idp-cert.pem
# App Security
SESSION_SECRET=your-long-random-string-here
Now, let’s look at the code. We start by configuring Passport. It acts as a traffic director for authentication. The passport-saml strategy handles the complex SAML protocol details for us.
// config/passport.js
const passport = require('passport');
const SamlStrategy = require('passport-saml').Strategy;
const fs = require('fs');
passport.use(new SamlStrategy(
{
// Where to send users to log in
entryPoint: process.env.IDP_ENTRY_POINT,
// The ID of YOUR application
issuer: process.env.SP_ENTITY_ID,
// Where the IdP sends the user back to
callbackUrl: process.env.SP_CALLBACK_URL,
// The IdP's certificate to verify their signatures
cert: fs.readFileSync(process.env.IDP_CERT_PATH, 'utf-8'),
// Your app's private key (for signing/decryption if needed)
privateKey: fs.readFileSync('./certs/sp-key.pem', 'utf-8')
},
function(profile, done) {
// This runs after a successful SAML login.
// 'profile' contains user data from the IdP.
// Find or create a user in your database here.
const user = {
id: profile.nameID,
email: profile.nameID,
firstName: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'],
lastName: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname']
};
return done(null, user);
}
));
// Simple serialization for sessions
passport.serializeUser((user, done) => done(null, user.id));
passport.deserializeUser((id, done) => {
// In a real app, fetch user from DB by 'id'
done(null, { id: id, email: id });
});
See the function that receives the profile? That’s your golden ticket. The identity provider sends back user attributes—like email, first name, and groups—inside the SAML response. You use this to create a session for the user in your app.
Next, we set up our Express server with the necessary middleware. Security headers, session management, and initializing Passport are key.
// app.js
const express = require('express');
const session = require('express-session');
const passport = require('passport');
const helmet = require('helmet');
require('dotenv').config();
require('./config/passport'); // Load the strategy we just made
const app = express();
// Security first
app.use(helmet());
// Sessions are required for Passport to keep users logged in.
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production' } // Use HTTPS in production
}));
app.use(passport.initialize());
app.use(passport.session()); // Persistent login sessions
// Routes
app.get('/', (req, res) => {
res.send(req.isAuthenticated() ? `Hello ${req.user.email}` : 'Hello Guest');
});
// Start the SAML login process
app.get('/login',
passport.authenticate('saml', { failureRedirect: '/', failureFlash: true })
);
// The IdP redirects the user back here after login
app.post('/auth/saml/callback',
passport.authenticate('saml', { failureRedirect: '/', failureFlash: true }),
function(req, res) {
res.redirect('/');
}
);
app.get('/logout', (req, res) => {
req.logout(() => {
res.redirect('/');
});
});
app.listen(3000, () => console.log('App running on port 3000'));
When a user visits /login, Passport creates a SAML authentication request and redirects them to the IDP_ENTRY_POINT. The user logs in there. Then, the identity provider creates a signed SAML response and posts it back to our /auth/saml/callback URL. Passport validates the signature using the IdP’s certificate, and if all is good, calls our function with the user’s profile.
But what if you’re building a service for multiple companies? Each will have its own identity provider settings. You can’t hardcode one .env file. This is where dynamic configuration becomes critical. You need to look up the correct settings based on the user’s email domain or a tenant ID in the URL.
// A more advanced strategy configuration
passport.use(new SamlStrategy(
{
// Make config dynamic
passReqToCallback: true,
},
function(req, profile, done) {
// Extract tenant ID from request (e.g., subdomain: tenant1.yourapp.com)
const tenantId = req.hostname.split('.')[0];
// Fetch SAML config for this tenant from a database
getTenantConfig(tenantId).then(config => {
// Dynamically configure the strategy for this request
this._saml.options.entryPoint = config.idpEntryPoint;
this._saml.options.issuer = config.spIssuer;
this._saml.options.cert = config.idpCert;
// Now verify the SAML response with the correct tenant's settings
const originalValidate = this._saml.validate;
this._saml.validate = function(...args) {
this.options = { ...this.options, cert: config.idpCert };
return originalValidate.apply(this, args);
};
// Proceed to find/create user
findOrCreateUser(tenantId, profile).then(user => done(null, user));
}).catch(err => done(err));
}
));
This approach is more complex but is essential for software-as-a-service applications. You store each client’s SAML metadata—their login URL, issuer string, and certificate—in your database.
Talking about metadata, it’s the standard way identity providers and service providers share their configuration. It’s an XML file. Your identity provider will ask for your app’s metadata. You can generate it dynamically.
app.get('/metadata', function(req, res) {
const strategy = passport._strategy('saml');
res.type('application/xml');
res.send(strategy.generateServiceProviderMetadata(
fs.readFileSync('./certs/sp-cert.pem', 'utf-8'),
fs.readFileSync('./certs/sp-cert.pem', 'utf-8') // Use your public cert here
));
});
You give this /metadata URL to your client’s IT team. They feed it into their identity provider (like Okta), which automatically configures the connection on their end. It’s a huge time-saver.
Testing all this before going live is crucial. You can use a tool like saml-tracer (a browser extension) to inspect the SAML requests and responses. Look for the signature elements and the user attributes. In production, you must consider certificate expiration. Set a calendar reminder to renew your service provider certificate yearly and be prepared to update certificates from identity providers.
A common problem? The dreaded “SAML Response signature invalid” error. This usually means the certificate in your configuration doesn’t match the one used by the identity provider to sign the message. Always double-check you have the correct, up-to-date certificate from your client.
Another pitfall is the audience restriction. The SAML response specifies which service provider it’s intended for (your SP_ENTITY_ID). If this doesn’t match exactly, validation will fail. No trailing slashes allowed if you didn’t configure one.
Building SAML SSO connects your application to the enterprise world. It removes friction for users and centralizes security management for IT admins. While the protocol seems dense, libraries like passport-saml handle the heavy lifting. Start with a simple, single-tenant setup. Once that works, layer on the complexity of multiple tenants and dynamic metadata.
What surprised you most about how SAML works? Was it the certificate exchange or the redirect flow? I encourage you to take this foundation, try it with a free Okta developer account, and see the pieces click into place. If this guide helped you connect the dots, please share it with a colleague who might be facing the same integration challenge. I’d also love to hear about your experience in the comments below—what hurdles did you overcome?
As a best-selling author, I invite you to explore my books on Amazon. Don’t forget to follow me on Medium and show your support. Thank you! Your support means the world!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva