TL;DR: Bypass the expensive "SSO tax" of third-party identity brokers by building a native, multi-tenant SAML 2.0 and SCIM 2.0 integration in Node.js. This guide demonstrates how to architect Express.js routes to handle both XML-based authentication and RESTful automated provisioning, backed by a Prisma schema that dynamically loads unique IdP configurations per enterprise customer.
⚡ Key Takeaways
- Isolate your identity architecture in Express.js by separating SAML authentication (handling URL-encoded XML POSTs) from SCIM provisioning (handling standard JSON payloads).
- Secure SCIM endpoints using a static Bearer token generated per tenant, and validate it using constant-time string comparison to prevent timing attacks.
- Protect your SCIM provisioning routes with strict rate limiting and IP whitelisting, as these endpoints allow direct mutation of your user database.
- Design a multi-tenant database schema in Prisma that maps specific IdP configurations (EntryPoint URL, Issuer string, and Base64-encoded X.509 Certificate) to individual customer tenants.
- Track federated users by storing the
NameIDfrom the SAML assertion as anssoId, and maintain anisActiveboolean on the User model to support automated SCIM deprovisioning.
Picture this: you are staring down a $120,000 Annual Contract Value (ACV) deal. The product demo was flawless, the VP of Engineering is heavily bought in, and the procurement team has approved the budget. Then, the vendor security questionnaire arrives from the CISO's desk with two non-negotiable requirements:
- Your SaaS must integrate with their Okta instance via SAML 2.0 (Security Assertion Markup Language) for authentication.
- It must support automated employee onboarding and offboarding via SCIM 2.0 (System for Cross-domain Identity Management).
If you rely on default email and password authentication, you fail. If you require their IT team to manually invite users to your platform, you fail.
Looking into third-party identity providers, you realize that upgrading to the "Enterprise" tiers of Auth0, Clerk, or WorkOS just to enable SAML and SCIM will cost upwards of $800 to $1,500 per month—or worse, a massive per-connection fee that destroys your margins. You are being penalized for moving upmarket. This is the infamous "SSO Tax."
The solution is to build this capability natively into your backend. In this technical blueprint, we will architect a multi-tenant, production-ready SAML and SCIM pipeline in Node.js, empowering you to pass strict infosec reviews without bleeding revenue to an identity broker.
The Architecture of Enterprise Identity (SAML + SCIM)
Enterprise identity is split into two distinct operational flows: Authentication (SAML) and Provisioning (SCIM).
In the SAML flow, the customer's Okta or Azure AD acts as the Identity Provider (IdP), while your SaaS acts as the Service Provider (SP). When a user attempts to log in, they are redirected to the IdP, authenticated, and POSTed back to your server with a cryptographically signed XML payload.
In the SCIM flow, the IdP acts as an API client. It makes RESTful HTTP requests directly to your backend to create, update, or deactivate users in real time based on HR system changes.
To handle this securely and cleanly, we must isolate these domain boundaries in our Express.js application.
// src/app.ts
import express from 'express';
import { samlRouter } from './routes/saml.routes';
import { scimRouter } from './routes/scim.routes';
import { authenticateScimBearer } from './middleware/scimAuth';
const app = express();
// 1. Standard application parsers
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 2. SAML routes (Authentication)
// Must handle URL-encoded form POSTs containing XML assertions
app.use('/api/auth/sso', samlRouter);
// 3. SCIM routes (Provisioning)
// Secured via a static Bearer token generated per tenant
// Accepts standard SCIM JSON payloads
app.use('/scim/v2', authenticateScimBearer, scimRouter);
export default app;
Production Note: Never expose your SCIM endpoints without strict rate limiting and, where possible, IP whitelisting. Because SCIM endpoints allow complete mutation of your user database, ensure
authenticateScimBeareruses constant-time string comparison to prevent timing attacks.
Multi-Tenant Configuration Management
Standard SAML implementations often assume a single identity provider. However, in a B2B SaaS, every enterprise customer has their own unique IdP configuration: a specific EntryPoint URL, an Issuer string, and an X.509 Certificate.
We need a database schema capable of mapping an incoming SAML request to the correct tenant configuration. Here is how we define this relationship using Prisma:
// schema.prisma
model Tenant {
id String @id @default(uuid())
slug String @unique // e.g., "acme-corp"
name String
users User[]
// Enterprise SSO Configuration
samlConfig SamlConfig?
// Token for authenticating incoming SCIM requests
scimBearerToken String? @unique
}
model SamlConfig {
id String @id @default(uuid())
tenantId String @unique
tenant Tenant @relation(fields: [tenantId], references: [id])
// Provided by the Customer's Okta/Azure AD
entryPoint String // The IdP SSO Login URL
issuer String // The IdP Entity ID
cert String // Base64-encoded X.509 public certificate
isActive Boolean @default(true)
}
model User {
id String @id @default(uuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
email String @unique
firstName String?
lastName String?
// Identity Federation tracking
ssoId String? // The NameID from the SAML assertion
isActive Boolean @default(true) // Crucial for SCIM deprovisioning
}
This schema ensures that user lifecycles are strictly bound to their parent tenant, and it allows us to dynamically load the correct cryptographic material to verify the SAML payload when a user signs in.
Implementing the SAML 2.0 ACS (Assertion Consumer Service)
To process SAML logic without reinventing complex XML cryptographic validation, we leverage @node-saml/passport-saml. Because our SaaS is multi-tenant, the standard SamlStrategy won't suffice. Instead, we use MultiSamlStrategy, which resolves the IdP configuration asynchronously based on the incoming request.
When building complex identity federation features, you need a robust underlying API architecture. This dynamic resolution pattern is a common requirement in the backend development services we provide for scaling SaaS platforms.
// src/auth/samlStrategy.ts
import { MultiSamlStrategy } from '@node-saml/passport-saml';
import { PrismaClient } from '@prisma/client';
import { Request } from 'express';
const prisma = new PrismaClient();
export const multiSamlStrategy = new MultiSamlStrategy(
{
passReqToCallback: true,
getSamlOptions: async (req: Request, done: any) => {
try {
// Extract tenant identifier from the callback URL
// e.g., /api/auth/sso/:tenantSlug/callback
const tenantSlug = req.params.tenantSlug;
const tenant = await prisma.tenant.findUnique({
where: { slug: tenantSlug },
include: { samlConfig: true }
});
if (!tenant || !tenant.samlConfig || !tenant.samlConfig.isActive) {
return done(new Error('SAML configuration not found or inactive'));
}
return done(null, {
path: `/api/auth/sso/${tenantSlug}/callback`,
entryPoint: tenant.samlConfig.entryPoint,
issuer: 'https://yoursaas.com', // Your SP Entity ID
cert: tenant.samlConfig.cert, // The IdP's public key
audience: 'https://yoursaas.com',
signatureAlgorithm: 'sha256'
});
} catch (error) {
return done(error);
}
}
},
(req, profile, done) => {
// Pass the parsed XML profile to the Express route handler
return done(null, profile);
}
);
Parsing and Validating the SAML Response
After the IdP authenticates the user, it redirects their browser to send a POST request to your Assertion Consumer Service (ACS) URL. This payload contains the base64-encoded XML assertion.
The MultiSamlStrategy intercepts this, verifies the XML signature using the tenant's public certificate, and outputs a normalized JSON profile. Our route handler then maps these claims to our database and issues a standard JWT session token for the frontend application.
// src/routes/saml.routes.ts
import { Router } from 'express';
import passport from 'passport';
import jwt from 'jsonwebtoken';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
// Initial login redirect triggered by the user
router.get('/:tenantSlug/login',
passport.authenticate('saml', { failureRedirect: '/login', failureFlash: true })
);
// The ACS Endpoint receiving the POST from Okta/Azure AD
router.post('/:tenantSlug/callback',
passport.authenticate('saml', { session: false }),
async (req, res) => {
const profile = req.user as any;
const tenantSlug = req.params.tenantSlug;
// Standard SAML claims map URIs to values (Claim URIs vary by IdP)
const email = profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || profile.nameID;
const firstName = profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname'] || '';
// Find the tenant and the mapped user
const tenant = await prisma.tenant.findUnique({ where: { slug: tenantSlug } });
if (!tenant) return res.status(403).send('Tenant not found');
let user = await prisma.user.findFirst({
where: { email, tenantId: tenant.id }
});
// JIT (Just-In-Time) Provisioning fallback
if (!user) {
user = await prisma.user.create({
data: {
email,
firstName,
tenantId: tenant.id,
ssoId: profile.nameID,
isActive: true
}
});
}
if (!user.isActive) return res.status(403).send('Account deactivated via SCIM');
// Issue internal SaaS session
const token = jwt.sign(
{ userId: user.id, tenantId: tenant.id },
process.env.JWT_SECRET!,
{ expiresIn: '8h' }
);
res.redirect(`https://app.yoursaas.com/auth/callback?token=${token}`);
}
);
export { router as samlRouter };
Security Warning: SAML is highly susceptible to XML Signature Wrapping (XSW) attacks and replay attacks. Ensure
@node-saml/passport-samlis kept strictly up-to-date. Configure your IdP integration to reject assertions older than 5 minutes to mitigate replay vulnerabilities.
The Build vs. Buy Debate: Avoiding the SSO Tax
Founders frequently ask: "Shouldn't we just pay Auth0 or WorkOS to handle this?"
If you are closing a single enterprise deal, maybe. But as you scale, identity providers charge massive premiums for SAML connections. If you look closely at our pricing models for custom development versus the recurring OpEx of managed identity, the math heavily favors self-hosting once you exceed 3 to 5 enterprise clients.
To prove how accessible building this in-house is, you don't even need a real enterprise Okta account to test your implementation. You can spin up a local IdP using Docker:
# docker-compose.yml for local SAML testing
version: '3.8'
services:
saml-idp:
image: kristophjunge/test-saml-idp
ports:
- "8080:8080"
environment:
- SIMPLESAMLPHP_SP_ENTITY_ID=https://yoursaas.com
- SIMPLESAMLPHP_SP_ASSERTION_CONSUMER_SERVICE=http://localhost:3000/api/auth/sso/acme-corp/callback
- SIMPLESAMLPHP_SP_SINGLE_LOGOUT_SERVICE=http://localhost:3000/api/auth/sso/acme-corp/logout
volumes:
# Inject test users
- ./users.php:/var/www/simplesamlphp/config/authsources.php
With one command, you have a fully functional Identity Provider testing your Node.js backend. The "magic" of enterprise SSO is merely routing and cryptographic validation.
Automating Onboarding with SCIM 2.0 Provisioning
SAML handles logging people in, but enterprise IT departments do not want to manually create users in your application. They want to assign an employee to your app in Okta, and have Okta instantly create the account in your database.
This requires implementing a SCIM API. SCIM expects strict adherence to the RFC 7643 schema. When an admin assigns a user, the IdP sends a POST request to your /scim/v2/Users endpoint.
// src/routes/scim.routes.ts
import { Router, Request, Response } from 'express';
import { PrismaClient } from '@prisma/client';
const router = Router();
const prisma = new PrismaClient();
router.post('/Users', async (req: Request, res: Response) => {
// The tenant context is injected by the SCIM Bearer token middleware
const tenantId = (req as any).tenantId;
const scimUser = req.body;
try {
// Extract standard SCIM fields
const email = scimUser.emails?.find((e: any) => e.primary)?.value || scimUser.userName;
const firstName = scimUser.name?.givenName || '';
const lastName = scimUser.name?.familyName || '';
const externalId = scimUser.externalId; // Okta's internal user ID
// Provision the user in your database
const user = await prisma.user.create({
data: {
tenantId,
email,
firstName,
lastName,
ssoId: externalId,
isActive: scimUser.active ?? true
}
});
// SCIM requires a specific JSON response format
const scimResponse = {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
id: user.id, // Your internal ID
externalId: externalId,
userName: user.email,
name: {
givenName: user.firstName,
familyName: user.lastName
},
emails: [{ primary: true, value: user.email }],
active: user.isActive,
meta: {
resourceType: "User",
created: new Date().toISOString() // Must be a valid ISO DateTime string
}
};
res.status(201).json(scimResponse);
} catch (error) {
res.status(500).json({
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
detail: "Internal Server Error"
});
}
});
export { router as scimRouter };
By supporting this endpoint, you guarantee that users are provisioned in your system the exact second they are granted access in the corporate directory.
Security and Deprovisioning: Handling SCIM DELETE
Arguably the most critical compliance requirement for B2B SaaS is the immediate revocation of access when an employee leaves the company. If an employee is terminated, IT will disable their account in Azure AD, which will instantly fire a DELETE or PATCH request to your SCIM API.
You must handle this gracefully. Hard-deleting the user will likely break foreign key constraints on the data they created (documents, logs, projects). Instead, we perform a soft-delete by toggling isActive.
// src/routes/scim.routes.ts (continued)
router.delete('/Users/:id', async (req: Request, res: Response) => {
const tenantId = (req as any).tenantId;
const userId = req.params.id;
try {
// 1. Verify the user belongs to the authenticated tenant
const user = await prisma.user.findFirst({
where: { id: userId, tenantId }
});
if (!user) {
return res.status(404).json({ detail: "User not found" });
}
// 2. Soft-delete the user to preserve relational data
await prisma.user.update({
where: { id: userId },
data: { isActive: false }
});
// 3. Optional but highly recommended: Invalidate all active JWT sessions
// await redisCache.del(`session:${userId}`);
// SCIM spec requires a 204 No Content for successful deletions
res.status(204).send();
} catch (error) {
res.status(500).json({ detail: "Failed to deprovision user" });
}
});
When you toggle isActive: false, your system must guarantee that any subsequent API calls using an existing JWT from this user are immediately rejected. This satisfies the strict offboarding requirements of enterprise SOC2 and ISO 27001 audits.
By architecting your own SAML strategy and SCIM schema parser in Node.js, you convert an expensive recurring OpEx cost into a one-time engineering task. You regain control over your identity data, protect your SaaS margins, and clear the path to close six-figure enterprise contracts without hesitation.
If you are planning to move your SaaS upmarket and need expert guidance on implementing custom IdP integrations, preparing for security audits, or bypassing technical debt, book a free architecture review with our team. We specialize in unblocking enterprise pipelines.
Work With Us
Need help building this in production? SoftwareCrafting is a full-stack dev agency — we ship React, Next.js, Node.js, React Native, & Flutter apps for global clients.
Frequently Asked Questions
Why am I seeing [object Object] instead of my actual data in JavaScript?
This happens when you try to implicitly convert a JavaScript object to a string, such as concatenating it with a string or rendering it directly in the UI. JavaScript uses the default toString() method, which outputs the literal string [object Object]. To fix this, you need to properly serialize the data using JSON.stringify().
How can I correctly log or display an object's contents?
Use console.log(myObject) without string concatenation to view the interactive object in your browser's developer tools. If you need to render it in the UI or a text log, use JSON.stringify(myObject, null, 2) to format it as a readable JSON string.
How does SoftwareCrafting help teams prevent common JavaScript errors like [object Object]?
SoftwareCrafting provides comprehensive code review and technical auditing services to catch serialization and type coercion bugs before they reach production. Our expert developers help establish robust TypeScript configurations and linting rules that prevent these common JavaScript pitfalls entirely.
Can I customize what toString() outputs for my custom objects?
Yes, you can override the default toString() method on your custom classes or objects. By defining a custom toString() function on your object, you can control exactly what string representation is returned when the object is coerced into a string.
Why does my API request payload show up as [object Object] on the server?
This typically occurs when you pass a raw JavaScript object to a fetch request body without serializing it first, or if your request headers are misconfigured. Always ensure you wrap your payload in JSON.stringify() and set the Content-Type header to application/json.
How can SoftwareCrafting services assist in debugging complex API payload issues?
If your team is struggling with persistent data serialization or API communication errors, SoftwareCrafting offers dedicated debugging and architecture consulting. We can help you implement end-to-end type safety and automated testing to ensure your data payloads are always formatted correctly across your stack.
📎 Full Code on GitHub Gist: The complete
debug-n8n-workflow.jsfrom this post is available as a standalone GitHub Gist — copy, fork, or embed it directly.
