Web Engineering ·

Implementing Enterprise SSO for Your SaaS: SAML, OIDC, SCIM, and Directory Sync for Multi-Tenant Applications

A complete engineering guide to SAML 2.0 SP-initiated and IdP-initiated flows, OIDC for enterprise, SCIM 2.0 directory sync, multi-tenant identity routing, and production considerations including certificate rotation and just-in-time provisioning.

Implementing Enterprise SSO for Your SaaS: SAML, OIDC, SCIM, and Directory Sync for Multi-Tenant Applications

Every SaaS company reaches a point where an enterprise prospect sends a security questionnaire with the line “Does your product support SSO?” and the answer determines whether the deal closes. SSO is not a nice-to-have for enterprise buyers. It is a hard requirement. Their IT team needs centralized authentication, their security team needs audit trails, and their HR team needs automated deprovisioning the moment someone leaves the company.

This guide covers the full stack: SAML 2.0 flows with real assertion parsing, OIDC for enterprise use cases (which differs from consumer OAuth in meaningful ways), SCIM 2.0 for directory sync, multi-tenant identity routing patterns, the build-versus-buy decision, and the production details that most tutorials skip.

Why Enterprise SSO is Different from Consumer Auth

Consumer authentication is user-controlled. A person creates an account, sets a password or links a social provider, and manages their own credentials. Enterprise authentication is IT-controlled. Identities live in a corporate directory (Active Directory, Okta, Azure AD, Google Workspace), and the employer controls who has access to what. When an employee is terminated, access across all connected applications must be revoked automatically, ideally within minutes.

Enterprise buyers expect:

  • SAML 2.0 or OIDC for federated login
  • SCIM 2.0 for automated user provisioning and deprovisioning
  • Per-tenant IdP configuration (each customer connects their own identity provider)
  • Audit logs showing every login event, provisioning event, and deprovisioning event
  • Just-in-time (JIT) provisioning for users who exist in the IdP but not yet in your app

SAML 2.0: SP-Initiated and IdP-Initiated Flows

SAML 2.0 remains the dominant standard at large enterprises. It is XML-based, verbose, and occasionally painful to implement, but it is what most corporate IdPs (Okta, Azure AD, PingFederate, OneLogin) speak natively.

SP-Initiated Flow

In the SP-initiated flow, the user starts at your application (the Service Provider). Your app redirects them to their IdP with a signed AuthnRequest. The IdP authenticates the user and POSTs a signed SAMLResponse back to your Assertion Consumer Service (ACS) URL.

import { createSign } from "crypto";
import { deflateRaw } from "zlib";
import { promisify } from "util";

const deflate = promisify(deflateRaw);

interface SAMLConnection {
  tenantId: string;
  idpEntityId: string;
  idpSsoUrl: string;
  idpCertificate: string;
  spEntityId: string;
  acsUrl: string;
}

async function buildAuthnRequest(
  connection: SAMLConnection,
  requestId: string
): Promise<string> {
  const now = new Date().toISOString();
  const xml = `
    <samlp:AuthnRequest
      xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
      xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
      ID="${requestId}"
      Version="2.0"
      IssueInstant="${now}"
      AssertionConsumerServiceURL="${connection.acsUrl}"
      Destination="${connection.idpSsoUrl}">
      <saml:Issuer>${connection.spEntityId}</saml:Issuer>
      <samlp:NameIDPolicy
        Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
        AllowCreate="true"/>
    </samlp:AuthnRequest>
  `.trim();

  const compressed = await deflate(Buffer.from(xml));
  const encoded = compressed.toString("base64");
  return encodeURIComponent(encoded);
}

function buildRedirectUrl(
  connection: SAMLConnection,
  samlRequest: string,
  relayState: string,
  privateKey: string
): string {
  const params = new URLSearchParams({
    SAMLRequest: samlRequest,
    RelayState: relayState,
    SigAlg: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
  });

  const sign = createSign("SHA256");
  sign.update(params.toString());
  const signature = sign.sign(privateKey, "base64");

  params.set("Signature", signature);
  return `${connection.idpSsoUrl}?${params.toString()}`;
}

Parsing the SAMLResponse

The SAMLResponse arrives at your ACS endpoint as a base64-encoded, XML-signed document. You need to verify the signature using the IdP’s certificate, then extract the assertion attributes.

import { DOMParser } from "@xmldom/xmldom";
import { SignedXml } from "xml-crypto";
import * as forge from "node-forge";

interface ParsedAssertion {
  nameId: string;
  email: string;
  firstName?: string;
  lastName?: string;
  groups?: string[];
  sessionIndex?: string;
  notBefore: Date;
  notOnOrAfter: Date;
}

function verifySAMLResponse(
  rawResponse: string,
  idpCertificate: string
): ParsedAssertion {
  const xml = Buffer.from(rawResponse, "base64").toString("utf8");
  const doc = new DOMParser().parseFromString(xml, "text/xml");

  // Verify signature
  const sig = new SignedXml();
  sig.addReference(
    "//*[local-name(.)='Assertion']",
    ["http://www.w3.org/2000/09/xmldsig#enveloped-signature"],
    "http://www.w3.org/2001/04/xmlenc#sha256"
  );
  sig.keyInfoProvider = {
    getKeyInfo: () => "",
    getKey: () => Buffer.from(
      `-----BEGIN CERTIFICATE-----\n${idpCertificate}\n-----END CERTIFICATE-----`
    ),
  };

  const verified = sig.checkSignature(xml);
  if (!verified) {
    throw new Error(`SAML signature verification failed: ${sig.validationErrors.join(", ")}`);
  }

  // Extract assertion data
  const assertion = doc.getElementsByTagNameNS(
    "urn:oasis:names:tc:SAML:2.0:assertion",
    "Assertion"
  )[0];

  const nameId = assertion
    .getElementsByTagNameNS("urn:oasis:names:tc:SAML:2.0:assertion", "NameID")[0]
    ?.textContent ?? "";

  const conditions = assertion
    .getElementsByTagNameNS("urn:oasis:names:tc:SAML:2.0:assertion", "Conditions")[0];

  const notBefore = new Date(conditions.getAttribute("NotBefore") ?? "");
  const notOnOrAfter = new Date(conditions.getAttribute("NotOnOrAfter") ?? "");

  const now = new Date();
  if (now < notBefore || now > notOnOrAfter) {
    throw new Error("SAML assertion is outside its valid time window");
  }

  // Extract attributes
  const attributes: Record<string, string[]> = {};
  const attrStatements = assertion.getElementsByTagNameNS(
    "urn:oasis:names:tc:SAML:2.0:assertion",
    "AttributeStatement"
  );

  for (const stmt of Array.from(attrStatements)) {
    const attrs = stmt.getElementsByTagNameNS(
      "urn:oasis:names:tc:SAML:2.0:assertion",
      "Attribute"
    );
    for (const attr of Array.from(attrs)) {
      const name = attr.getAttribute("Name") ?? "";
      const values = Array.from(
        attr.getElementsByTagNameNS("urn:oasis:names:tc:SAML:2.0:assertion", "AttributeValue")
      ).map((v) => v.textContent ?? "");
      attributes[name] = values;
    }
  }

  return {
    nameId,
    email: (
      attributes["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"]?.[0] ??
      attributes["email"]?.[0] ??
      nameId
    ),
    firstName:
      attributes["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"]?.[0] ??
      attributes["firstName"]?.[0],
    lastName:
      attributes["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"]?.[0] ??
      attributes["lastName"]?.[0],
    groups: attributes["http://schemas.microsoft.com/ws/2008/06/identity/claims/groups"] ??
      attributes["groups"],
    notBefore,
    notOnOrAfter,
  };
}

IdP-Initiated Flow

In the IdP-initiated flow, the user clicks a tile in their IdP dashboard (Okta, Azure portal). The IdP POSTs a SAMLResponse to your ACS URL without a preceding AuthnRequest. You cannot validate a RequestID here, which makes it slightly less secure. You should still verify the assertion’s time window and signature.

The same verifySAMLResponse function handles both flows. The difference is that in IdP-initiated mode, the InResponseTo attribute is absent from the response, so skip that check rather than failing.

OIDC for Enterprise: Not the Same as Consumer OAuth

OIDC is increasingly common at enterprises, particularly those using Azure AD or Okta. The protocol is the same OIDC you use for “Log in with Google,” but enterprise deployments differ in a few important ways:

  • The issuer is tenant-specific (e.g., https://login.microsoftonline.com/{tenantId}/v2.0). You cannot use a single static issuer URL.
  • Tokens often include enterprise-specific claims: department, cost center, manager, group memberships.
  • Enterprises use the Authorization Code flow with PKCE. Device flow and client credentials are for M2M. Implicit flow is dead.
  • The IdP certificate rotates on a schedule. You must use the IdP’s JWKS endpoint dynamically, not pin a static key.
import { createRemoteJWKSet, jwtVerify, JWTPayload } from "jose";

interface OIDCConnection {
  tenantId: string;
  clientId: string;
  clientSecret: string;
  issuer: string;
  jwksUri: string;
  authorizationEndpoint: string;
  tokenEndpoint: string;
}

interface EnterpriseTokenClaims extends JWTPayload {
  email?: string;
  given_name?: string;
  family_name?: string;
  groups?: string[];
  tid?: string; // Azure AD tenant ID
  oid?: string; // Azure AD object ID
}

async function verifyEnterpriseIdToken(
  idToken: string,
  connection: OIDCConnection
): Promise<EnterpriseTokenClaims> {
  const JWKS = createRemoteJWKSet(new URL(connection.jwksUri));

  const { payload } = await jwtVerify<EnterpriseTokenClaims>(idToken, JWKS, {
    issuer: connection.issuer,
    audience: connection.clientId,
    clockTolerance: "30s",
  });

  // Verify the token belongs to this tenant (Azure AD multi-tenant gotcha)
  if (payload.tid && payload.tid !== extractAzureTenantId(connection.issuer)) {
    throw new Error(`Token tenant ${payload.tid} does not match expected issuer`);
  }

  return payload;
}

function extractAzureTenantId(issuer: string): string | null {
  const match = issuer.match(/login\.microsoftonline\.com\/([^/]+)\//);
  return match?.[1] ?? null;
}

Multi-Tenant Identity Architecture

Your application supports many enterprise customers. Each has its own IdP configuration. You need a routing layer that maps an incoming authentication request to the correct tenant’s SSO connection.

Connection-Per-Tenant Model

Each tenant has one or more identity connections stored in your database. When a user initiates login, you route them to their tenant’s connection.

interface IdentityConnection {
  id: string;
  tenantId: string;
  protocol: "saml" | "oidc";
  domains: string[];     // ["acme.com", "acme-corp.com"]
  status: "active" | "pending" | "disabled";
  samlConfig?: SAMLConnection;
  oidcConfig?: OIDCConnection;
  createdAt: Date;
  updatedAt: Date;
}

class ConnectionRouter {
  constructor(private db: Database) {}

  async resolveByEmail(email: string): Promise<IdentityConnection | null> {
    const domain = email.split("@")[1];
    if (!domain) return null;

    return this.db.identityConnections.findFirst({
      where: {
        domains: { has: domain },
        status: "active",
      },
    });
  }

  async resolveByTenantSlug(slug: string): Promise<IdentityConnection | null> {
    const tenant = await this.db.tenants.findUnique({ where: { slug } });
    if (!tenant) return null;

    return this.db.identityConnections.findFirst({
      where: { tenantId: tenant.id, status: "active" },
    });
  }
}

On the login page, you either show a domain-capture field (“Enter your work email to sign in”) or use a tenant-specific login URL (app.yourproduct.com/login/acme). Both patterns are common. The tenant-specific URL is cleaner operationally.

Shared IdP Routing

Some enterprises use a single IdP (Okta) to manage access to multiple of your tenants. This is less common but happens in holding companies and enterprise groups. Handle it by allowing the same IdP entity ID to map to multiple tenant connections, with the specific tenant determined by a claim in the assertion (typically a custom attribute the customer’s IT team sets).

SCIM 2.0: Automated Provisioning and Deprovisioning

SCIM (System for Cross-domain Identity Management) is how the enterprise’s IdP pushes user changes into your application. When someone joins the company, the IdP creates them in your app. When they leave, the IdP deprovisions them. You build the SCIM server; the IdP is the client.

import { Hono } from "hono";
import { z } from "zod";

const ScimUserSchema = z.object({
  schemas: z.array(z.string()),
  userName: z.string().email(),
  name: z
    .object({
      givenName: z.string().optional(),
      familyName: z.string().optional(),
    })
    .optional(),
  emails: z
    .array(z.object({ value: z.string().email(), primary: z.boolean().optional() }))
    .optional(),
  active: z.boolean().default(true),
  externalId: z.string().optional(),
});

const scim = new Hono();

// SCIM bearer token validation middleware
scim.use("*", async (c, next) => {
  const auth = c.req.header("Authorization");
  const token = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
  if (!token || !(await validateScimToken(token, c))) {
    return c.json({ schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], status: 401, detail: "Unauthorized" }, 401);
  }
  await next();
});

// Create user
scim.post("/Users", async (c) => {
  const body = await c.req.json();
  const parsed = ScimUserSchema.safeParse(body);
  if (!parsed.success) {
    return c.json(scimError(400, parsed.error.message), 400);
  }

  const tenantId = c.get("tenantId") as string;
  const data = parsed.data;

  const existing = await db.users.findFirst({
    where: { email: data.userName, tenantId },
  });

  if (existing) {
    // Idempotent: return existing user if already provisioned
    return c.json(toScimUser(existing), 200);
  }

  const user = await db.users.create({
    data: {
      email: data.userName,
      firstName: data.name?.givenName,
      lastName: data.name?.familyName,
      tenantId,
      externalId: data.externalId,
      active: data.active,
      provisionedVia: "scim",
    },
  });

  await auditLog({ tenantId, event: "user.provisioned", userId: user.id, source: "scim" });
  return c.json(toScimUser(user), 201);
});

// Deprovision user (PATCH active=false is the SCIM-standard deprovisioning signal)
scim.patch("/Users/:id", async (c) => {
  const { id } = c.req.param();
  const body = await c.req.json();
  const tenantId = c.get("tenantId") as string;

  const operations = body.Operations as Array<{ op: string; path?: string; value: unknown }>;
  const deactivateOp = operations.find(
    (op) => op.op === "Replace" && op.path === "active" && op.value === false
  );

  if (deactivateOp) {
    await db.users.update({
      where: { id, tenantId },
      data: { active: false, deactivatedAt: new Date() },
    });
    // Revoke all active sessions immediately
    await sessionStore.revokeAllForUser(id);
    await auditLog({ tenantId, event: "user.deprovisioned", userId: id, source: "scim" });
  }

  const user = await db.users.findUniqueOrThrow({ where: { id, tenantId } });
  return c.json(toScimUser(user));
});

function scimError(status: number, detail: string) {
  return {
    schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
    status,
    detail,
  };
}

SCIM endpoints your server must implement for Okta and Azure AD compatibility:

EndpointMethodPurpose
/scim/v2/UsersGETList/filter users
/scim/v2/UsersPOSTCreate user
/scim/v2/Users/:idGETGet user
/scim/v2/Users/:idPUTReplace user
/scim/v2/Users/:idPATCHPartial update (deprovisioning)
/scim/v2/GroupsGETList groups
/scim/v2/GroupsPOSTCreate group
/scim/v2/Groups/:idPATCHUpdate group members

Build vs. Buy vs. Open Source

Before investing engineering time, assess what you actually need.

OptionBest ForTradeoffs
WorkOSWell-funded startups needing fastest time-to-market$125-$750/mo + per-connection pricing; excellent DX; hides protocol complexity entirely
BoxyHQ JacksonCost-sensitive teams; self-hosted requirementOpen source SAML/OIDC adapter; good for GDPR-strict environments; you own operations
Auth0 EnterpriseTeams already on Auth0Enterprise Plan required; expensive at scale; some SCIM limitations
Custom implementationSpecific compliance needs; unusual IdP requirements; cost at scaleFull control; 4-8 weeks for a production-grade implementation; ongoing maintenance burden

The honest calculus: if you have fewer than 10 enterprise customers and limited engineering bandwidth, a managed service saves months. Above roughly 50 enterprise connections, the per-connection pricing of managed services often exceeds the cost of maintaining your own SAML/SCIM stack.

Just-in-Time Provisioning

JIT provisioning creates a user account in your application the first time they log in via SSO, even if they were never pre-provisioned via SCIM. This is common at smaller enterprises that do not want to manage SCIM.

async function handleSSOLogin(
  assertion: ParsedAssertion,
  connection: IdentityConnection
): Promise<User> {
  let user = await db.users.findFirst({
    where: { email: assertion.email, tenantId: connection.tenantId },
  });

  if (!user) {
    // JIT provisioning
    user = await db.users.create({
      data: {
        email: assertion.email,
        firstName: assertion.firstName,
        lastName: assertion.lastName,
        tenantId: connection.tenantId,
        active: true,
        provisionedVia: "jit",
        provisionedAt: new Date(),
      },
    });
    await auditLog({
      tenantId: connection.tenantId,
      event: "user.jit_provisioned",
      userId: user.id,
      email: assertion.email,
    });
  } else if (!user.active) {
    // User was deprovisioned via SCIM but is logging in via SSO
    // This is a policy decision: fail or reactivate?
    throw new Error("Account is deactivated. Contact your IT administrator.");
  } else {
    // Update profile from IdP attributes on every login
    await db.users.update({
      where: { id: user.id },
      data: {
        firstName: assertion.firstName ?? user.firstName,
        lastName: assertion.lastName ?? user.lastName,
        lastSsoLoginAt: new Date(),
      },
    });
  }

  return user;
}

Production Considerations

Certificate Rotation

IdP certificates expire, usually every 2-3 years, but sometimes administrators rotate them early. Store multiple certificates per connection and try each in order until one validates. When the old certificate is retired, remove it. Never hardcode a certificate as the sole trust anchor.

async function verifySAMLWithRotation(
  xml: string,
  connection: SAMLConnection & { certificates: string[] }
): Promise<boolean> {
  for (const cert of connection.certificates) {
    try {
      await verifySAMLSignature(xml, cert);
      return true;
    } catch {
      continue;
    }
  }
  return false;
}

Alert your team when a certificate is within 60 days of expiry. Some IdPs (Okta) send advance notice; others (Ping) do not.

Session Management

Enterprise users expect SSO sessions to be bounded by their IdP’s session policy. An Okta session set to 8 hours means your application session should expire no later than 8 hours. SAML SessionNotOnOrAfter communicates this. Respect it. Do not issue a 30-day “remember me” cookie to an enterprise user whose organization mandates 8-hour sessions.

Audit Logging

Enterprise security reviews will ask for logs of every authentication event, every provisioning event, and every privilege change. Write these to an append-only table. Include: tenant ID, user ID, event type, source (saml/oidc/scim), IP address, timestamp, and the connection ID used.

Testing IdP Connections

When a customer configures their IdP, give them a test flow they can run before going live. Redirect to a /sso/test route that runs the full SAML exchange and displays the parsed assertion attributes, the time window validity, and the user that would be created or matched. This saves hours of back-and-forth with enterprise IT teams.

SAML vs. OIDC Decision Guide

ConsiderationSAML 2.0OIDC
Enterprise IdP compatibilityUniversal (every legacy IdP)Modern IdPs; Azure AD, Okta, Google Workspace
Implementation complexityHigh (XML, signatures, deflate encoding)Moderate (JSON, JWTs, standard libraries)
Mobile / SPA usePoor (POST bindings, browser-only)Excellent (PKCE, native app support)
Attribute richnessHigh (arbitrary XML attributes)Good (standard + custom claims)
Token formatXML assertionJWT ID token
DebuggingPainful (base64-encoded XML)Easier (readable JWT claims)

In practice, support both. Large financial institutions and government contractors often mandate SAML. Modern SaaS companies and tech startups prefer OIDC. If you only implement one, implement SAML first because it unblocks the larger deal sizes.


Enterprise SSO is one of those features that looks straightforward from the outside but conceals real implementation depth: clock skew in assertion validation, multi-certificate rotation, SCIM idempotency, IdP-initiated flows without a request correlation ID, and the policy questions around JIT versus SCIM provisioning. Getting it right the first time means enterprise prospects do not hit a snag during their security review, and your own engineering team does not spend Q3 firefighting a certificate expiry they did not know was coming.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.