Skip to main content

WorkOS vs Stytch vs FusionAuth: Enterprise Identity and SSO Compared (2026)

·PkgPulse Team

TL;DR: WorkOS is the enterprise-ready identity platform — SAML/OIDC SSO, SCIM directory sync, admin portal, and audit logs purpose-built for B2B SaaS selling to enterprises. Stytch is the passwordless-first auth platform — magic links, OTPs, OAuth, session management, and a flexible API that makes modern authentication easy for any app. FusionAuth is the self-hosted identity server — full CIAM features, multi-tenant, customizable login flows, and no per-user pricing. In 2026: WorkOS for B2B SaaS with enterprise SSO requirements, Stytch for modern passwordless auth, FusionAuth for self-hosted identity with full control.

Key Takeaways

  • WorkOS: Cloud-only, enterprise B2B focused. SAML + OIDC SSO, SCIM directory sync, admin portal for IT admins, audit logs. Best for SaaS products that need to support enterprise customers' identity providers
  • Stytch: Cloud-only, developer-first. Magic links, OTPs, OAuth, WebAuthn/passkeys, session management. Best for apps wanting modern passwordless authentication with flexible building blocks
  • FusionAuth: Self-hosted or cloud, full CIAM. Multi-tenant, customizable themes, connectors, lambdas, webhooks. Best for teams needing complete identity control without per-user pricing surprises

WorkOS — Enterprise Identity for B2B SaaS

WorkOS provides the enterprise features your B2B customers demand — SSO, directory sync, and admin portal — so you don't build them yourself.

SSO Integration

import { WorkOS } from "@workos-inc/node";

const workos = new WorkOS(process.env.WORKOS_API_KEY!);

// Step 1: Generate an authorization URL for SSO
function getAuthorizationUrl(organizationId: string): string {
  return workos.sso.getAuthorizationUrl({
    organization: organizationId, // Each customer org has SSO configured
    clientId: process.env.WORKOS_CLIENT_ID!,
    redirectUri: "https://app.yourproduct.com/auth/callback",
  });
}

// Step 2: Handle the callback
app.get("/auth/callback", async (req, res) => {
  const { code } = req.query;

  // Exchange code for user profile
  const { profile } = await workos.sso.getProfileAndToken({
    code: code as string,
    clientId: process.env.WORKOS_CLIENT_ID!,
  });

  // profile contains:
  // - id: WorkOS user ID
  // - email: user@customer.com
  // - firstName, lastName
  // - organizationId: which customer org
  // - connectionId: which SSO connection
  // - connectionType: "SAML" | "OIDC" | "GoogleOAuth" | etc.

  const user = await findOrCreateUser({
    email: profile.email,
    name: `${profile.firstName} ${profile.lastName}`,
    organizationId: profile.organizationId,
  });

  const session = await createSession(user.id);
  res.cookie("session", session.token).redirect("/dashboard");
});

Directory Sync (SCIM)

// Automatically sync users from customer's identity provider
// WorkOS handles the SCIM protocol — you just listen to webhooks

app.post("/webhooks/workos", async (req, res) => {
  const payload = workos.webhooks.constructEvent({
    payload: req.body,
    sigHeader: req.headers["workos-signature"] as string,
    secret: process.env.WORKOS_WEBHOOK_SECRET!,
  });

  switch (payload.event) {
    case "dsync.user.created": {
      // New employee added in customer's IdP
      const { directoryUser } = payload.data;
      await createUser({
        email: directoryUser.emails[0].value,
        name: `${directoryUser.firstName} ${directoryUser.lastName}`,
        organizationId: directoryUser.organizationId,
        role: mapGroupsToRole(directoryUser.groups),
      });
      break;
    }

    case "dsync.user.updated": {
      const { directoryUser } = payload.data;
      await updateUser(directoryUser.emails[0].value, {
        name: `${directoryUser.firstName} ${directoryUser.lastName}`,
        role: mapGroupsToRole(directoryUser.groups),
        active: directoryUser.state === "active",
      });
      break;
    }

    case "dsync.user.deleted": {
      // Employee offboarded — deactivate immediately
      const { directoryUser } = payload.data;
      await deactivateUser(directoryUser.emails[0].value);
      break;
    }

    case "dsync.group.created":
    case "dsync.group.updated": {
      // Sync group → role mappings
      const { directoryGroup } = payload.data;
      await syncGroupRoles(directoryGroup);
      break;
    }
  }

  res.status(200).send("OK");
});

Admin Portal

// Generate a link for customer IT admins to configure SSO + SCIM
const portal = await workos.portal.generateLink({
  organization: organizationId,
  intent: "sso", // "sso" | "dsync" | "audit_logs" | "log_streams"
  returnUrl: "https://app.yourproduct.com/settings",
});

// Redirect customer admin to the portal
// They can configure SAML/OIDC connection, directory sync,
// and test the integration — all without your involvement
res.redirect(portal.link);

// List organizations
const orgs = await workos.organizations.listOrganizations({
  limit: 10,
});

// Create an organization for a new enterprise customer
const org = await workos.organizations.createOrganization({
  name: "Acme Corp",
  domains: ["acme.com"], // Domain verification for SSO
});

Audit Logs

// Send audit events — required for enterprise compliance
await workos.auditLogs.createEvent({
  organizationId: "org_...",
  event: {
    action: "document.updated",
    occurredAt: new Date().toISOString(),
    actor: {
      type: "user",
      id: userId,
      name: "Jane Doe",
    },
    targets: [
      {
        type: "document",
        id: documentId,
        name: "Q4 Report",
      },
    ],
    context: {
      location: "198.51.100.1",
      userAgent: req.headers["user-agent"],
    },
  },
});

// Enterprise customers export audit logs via Admin Portal
// Or query via API:
const events = await workos.auditLogs.listEvents({
  organizationId: "org_...",
  rangeStart: "2026-03-01T00:00:00Z",
  rangeEnd: "2026-03-09T00:00:00Z",
});

Stytch — Passwordless-First Authentication

Stytch provides modern authentication primitives — magic links, OTPs, OAuth, passkeys — with a flexible API that lets you build exactly the auth flow you want.

import * as stytch from "stytch";

const client = new stytch.Client({
  project_id: process.env.STYTCH_PROJECT_ID!,
  secret: process.env.STYTCH_SECRET!,
  env: stytch.envs.live,
});

// Send a magic link
app.post("/auth/magic-link", async (req, res) => {
  const { email } = req.body;

  await client.magicLinks.email.loginOrCreate({
    email,
    login_magic_link_url: "https://app.yourproduct.com/auth/verify",
    signup_magic_link_url: "https://app.yourproduct.com/auth/verify",
    login_expiration_minutes: 30,
  });

  res.json({ message: "Check your email for a login link" });
});

// Verify the magic link token
app.get("/auth/verify", async (req, res) => {
  const { token } = req.query;

  const response = await client.magicLinks.authenticate({
    token: token as string,
    session_duration_minutes: 60 * 24 * 7, // 7 days
  });

  // response contains:
  // - user: { user_id, emails, name, ... }
  // - session: { session_id, session_token, session_jwt }

  res.cookie("stytch_session", response.session_token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
  }).redirect("/dashboard");
});

OTP (One-Time Passcode)

// Send OTP via email or SMS
app.post("/auth/otp/send", async (req, res) => {
  const { email, phone } = req.body;

  if (email) {
    await client.otps.email.loginOrCreate({
      email,
      expiration_minutes: 10,
    });
  } else if (phone) {
    await client.otps.sms.loginOrCreate({
      phone_number: phone,
      expiration_minutes: 10,
    });
  }

  res.json({ message: "Code sent" });
});

// Verify OTP
app.post("/auth/otp/verify", async (req, res) => {
  const { methodId, code } = req.body;

  const response = await client.otps.authenticate({
    method_id: methodId,
    code,
    session_duration_minutes: 60 * 24 * 7,
  });

  res.cookie("stytch_session", response.session_token).redirect("/dashboard");
});

OAuth and Social Login

// Start OAuth flow
app.get("/auth/oauth/:provider", (req, res) => {
  const { provider } = req.params; // google, github, microsoft, etc.

  const url = client.oauth.getAuthorizationUrl({
    provider: provider as stytch.OAuthProvider,
    login_redirect_url: "https://app.yourproduct.com/auth/oauth/callback",
    signup_redirect_url: "https://app.yourproduct.com/auth/oauth/callback",
  });

  res.redirect(url);
});

// Handle OAuth callback
app.get("/auth/oauth/callback", async (req, res) => {
  const { token } = req.query;

  const response = await client.oauth.authenticate({
    token: token as string,
    session_duration_minutes: 60 * 24 * 7,
  });

  // response.user contains profile info from OAuth provider
  // response.session contains session tokens
  // response.provider_values contains access_token for API calls

  res.cookie("stytch_session", response.session_token).redirect("/dashboard");
});

Session Management

// Authenticate a session on every request
async function authenticateSession(req: Request, res: Response, next: NextFunction) {
  const sessionToken = req.cookies.stytch_session;
  if (!sessionToken) return res.status(401).json({ error: "Unauthorized" });

  try {
    const response = await client.sessions.authenticate({
      session_token: sessionToken,
    });

    // Rotate session token on each request for security
    res.cookie("stytch_session", response.session_token, {
      httpOnly: true,
      secure: true,
    });

    req.user = response.user;
    req.session = response.session;
    next();
  } catch {
    res.status(401).json({ error: "Session expired" });
  }
}

// Revoke session on logout
app.post("/auth/logout", async (req, res) => {
  await client.sessions.revoke({
    session_token: req.cookies.stytch_session,
  });
  res.clearCookie("stytch_session").redirect("/");
});

Passkeys (WebAuthn)

// Register a passkey
app.post("/auth/passkey/register/start", async (req, res) => {
  const response = await client.webauthn.registerStart({
    user_id: req.user.userId,
    domain: "app.yourproduct.com",
    authenticator_type: "platform", // built-in (Touch ID, Windows Hello)
  });

  res.json(response); // Send to browser for navigator.credentials.create()
});

app.post("/auth/passkey/register/complete", async (req, res) => {
  await client.webauthn.register({
    user_id: req.user.userId,
    public_key_credential: JSON.stringify(req.body.credential),
  });

  res.json({ success: true });
});

// Authenticate with passkey
app.post("/auth/passkey/login/start", async (req, res) => {
  const response = await client.webauthn.authenticateStart({
    domain: "app.yourproduct.com",
  });

  res.json(response); // Send to browser for navigator.credentials.get()
});

app.post("/auth/passkey/login/complete", async (req, res) => {
  const response = await client.webauthn.authenticate({
    public_key_credential: JSON.stringify(req.body.credential),
    session_duration_minutes: 60 * 24 * 7,
  });

  res.cookie("stytch_session", response.session_token).redirect("/dashboard");
});

FusionAuth — Self-Hosted Identity Server

FusionAuth is a full-featured identity platform you can self-host — multi-tenant, customizable login flows, connectors, and no per-user pricing.

Docker Setup

# docker-compose.yml
version: "3"
services:
  fusionauth:
    image: fusionauth/fusionauth-app:latest
    depends_on:
      - postgres
      - opensearch
    environment:
      DATABASE_URL: jdbc:postgresql://postgres:5432/fusionauth
      DATABASE_USERNAME: fusionauth
      DATABASE_PASSWORD: ${DB_PASSWORD}
      FUSIONAUTH_APP_MEMORY: 512M
      OPENSEARCH_JAVA_OPTS: "-Xms256m -Xmx256m"
      FUSIONAUTH_APP_URL: http://fusionauth:9011
    ports:
      - "9011:9011"
    volumes:
      - fusionauth_config:/usr/local/fusionauth/config

  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: fusionauth
      POSTGRES_USER: fusionauth
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pg_data:/var/lib/postgresql/data

  opensearch:
    image: opensearchproject/opensearch:2
    environment:
      - discovery.type=single-node
      - plugins.security.disabled=true
    volumes:
      - os_data:/usr/local/opensearch/data

volumes:
  fusionauth_config:
  pg_data:
  os_data:

OAuth/OIDC Integration

import { FusionAuthClient } from "@fusionauth/typescript-client";

const client = new FusionAuthClient(
  process.env.FUSIONAUTH_API_KEY!,
  "http://localhost:9011"
);

// Start OAuth login — redirect to FusionAuth hosted login
app.get("/auth/login", (req, res) => {
  const authUrl = new URL("http://localhost:9011/oauth2/authorize");
  authUrl.searchParams.set("client_id", process.env.FUSIONAUTH_CLIENT_ID!);
  authUrl.searchParams.set("redirect_uri", "http://localhost:3000/auth/callback");
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("scope", "openid email profile");
  authUrl.searchParams.set("tenantId", tenantId); // Multi-tenant

  res.redirect(authUrl.toString());
});

// Handle callback — exchange code for tokens
app.get("/auth/callback", async (req, res) => {
  const { code } = req.query;

  const tokenResponse = await fetch("http://localhost:9011/oauth2/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code as string,
      client_id: process.env.FUSIONAUTH_CLIENT_ID!,
      client_secret: process.env.FUSIONAUTH_CLIENT_SECRET!,
      redirect_uri: "http://localhost:3000/auth/callback",
    }),
  });

  const tokens = await tokenResponse.json();
  // tokens: { access_token, refresh_token, id_token, expires_in }

  // Decode id_token for user info (or use /oauth2/userinfo)
  const user = decodeJwt(tokens.id_token);

  res.cookie("access_token", tokens.access_token, { httpOnly: true })
    .cookie("refresh_token", tokens.refresh_token, { httpOnly: true })
    .redirect("/dashboard");
});

User Management API

// Register a user
const registration = await client.register(undefined, {
  user: {
    email: "jane@acme.com",
    password: "securePassword123!",
    firstName: "Jane",
    lastName: "Doe",
    data: {
      // Custom user data — stored as JSON
      company: "Acme Corp",
      plan: "enterprise",
    },
  },
  registration: {
    applicationId: process.env.FUSIONAUTH_APP_ID!,
    roles: ["admin"],
  },
});

const userId = registration.response.user.id;

// Search users
const search = await client.searchUsersByQuery({
  search: {
    queryString: "email:*@acme.com AND registration.roles:admin",
    numberOfResults: 25,
    startRow: 0,
    sortFields: [{ name: "email", order: "asc" }],
  },
});

// Update user
await client.patchUser(userId, {
  user: {
    data: { plan: "enterprise-plus" },
  },
});

// Deactivate user
await client.deactivateUser(userId);

// Delete user (GDPR)
await client.deleteUser(userId);

Multi-Tenancy

// Create a tenant for each customer
const tenant = await client.createTenant(null, {
  tenant: {
    name: "Acme Corp",
    issuer: "https://auth.acme.yourproduct.com",
    emailConfiguration: {
      host: "smtp.sendgrid.net",
      port: 587,
      username: "apikey",
      password: process.env.SENDGRID_KEY!,
    },
    jwtConfiguration: {
      accessTokenKeyId: keyId,
      timeToLiveInSeconds: 3600,
      refreshTokenTimeToLiveInMinutes: 43200, // 30 days
    },
    passwordValidationRules: {
      minLength: 12,
      requireMixedCase: true,
      requireNumber: true,
      requireSpecialCharacter: true,
    },
  },
});

// Each tenant gets isolated:
// - Users and registrations
// - Login themes and branding
// - Email templates
// - Password policies
// - JWT signing keys
// - MFA configuration

// Create application per tenant
await client.createApplication(null, {
  application: {
    name: "Acme Dashboard",
    tenantId: tenant.response.tenant.id,
    oauthConfiguration: {
      clientId: crypto.randomUUID(),
      clientSecret: generateSecret(),
      authorizedRedirectURLs: ["https://acme.yourproduct.com/auth/callback"],
      logoutURL: "https://acme.yourproduct.com",
    },
    registrationConfiguration: {
      enabled: true,
      type: "basic",
    },
  },
});

Lambdas (Custom Logic)

// FusionAuth Lambdas — customize tokens, reconcile users, validate
// Defined via API or admin UI

// JWT populate lambda — add custom claims to access tokens
await client.createLambda(null, {
  lambda: {
    name: "JWT Populate",
    type: "JWTPopulate",
    body: `
      function populate(jwt, user, registration) {
        // Add custom claims
        jwt.roles = registration.roles;
        jwt.org_id = user.data.organizationId;
        jwt.plan = user.data.plan;
        jwt.permissions = getPermissions(registration.roles);
      }

      function getPermissions(roles) {
        const permMap = {
          admin: ['read', 'write', 'delete', 'manage'],
          editor: ['read', 'write'],
          viewer: ['read'],
        };
        return [...new Set(roles.flatMap(r => permMap[r] || []))];
      }
    `,
    enabled: true,
  },
});

// SAML response populate lambda — for SAML SSO
await client.createLambda(null, {
  lambda: {
    name: "SAML Populate",
    type: "SAMLv2Populate",
    body: `
      function populate(samlResponse, user, registration) {
        samlResponse.setAttribute('email', user.email);
        samlResponse.setAttribute('roles', registration.roles);
        samlResponse.setAttribute('org', user.data.organizationId);
      }
    `,
    enabled: true,
  },
});

Webhooks

// FusionAuth sends webhooks for all auth events
app.post("/webhooks/fusionauth", (req, res) => {
  const event = req.body;

  switch (event.type) {
    case "user.create":
      onboardUser(event.user);
      break;

    case "user.loginSuccess":
      trackLogin(event.user, event.ipAddress);
      break;

    case "user.loginFailed":
      if (event.user) {
        checkBruteForce(event.user.email, event.ipAddress);
      }
      break;

    case "user.deactivate":
      revokeAllSessions(event.user.id);
      cleanupUserResources(event.user.id);
      break;

    case "user.registration.create":
      assignDefaultResources(event.user, event.registration);
      break;

    case "jwt.refresh-token.revoke":
      invalidateCache(event.userId);
      break;
  }

  res.status(200).send("OK");
});

Feature Comparison

FeatureWorkOSStytchFusionAuth
DeploymentCloud onlyCloud onlySelf-hosted or cloud
Primary FocusEnterprise B2B SSOPasswordless authFull CIAM
SSO (SAML/OIDC)✅ (core feature)✅ (B2B product)
Directory Sync (SCIM)✅ (core feature)✅ (B2B product)✅ (via connectors)
Magic Links✅ (core feature)
OTP (Email/SMS)✅ (core feature)
Passkeys/WebAuthn✅ (core feature)
OAuth Social Login✅ (limited)✅ (30+ providers)✅ (configurable)
Session ManagementBasic✅ (advanced)✅ (JWT + refresh)
Multi-TenancyOrganizationsOrganizations✅ (full isolation)
Admin Portal✅ (hosted for IT admins)✅ (self-hosted UI)
Audit Logs✅ (enterprise)Basic✅ (detailed)
Custom Login UIYour own UIYour own UI + prebuiltThemed hosted pages
Lambdas/HooksWebhooksWebhooks✅ (JS lambdas + webhooks)
User SearchDirectory APIUser search API✅ (Elasticsearch-backed)
MFAVia IdP✅ (TOTP, SMS)✅ (TOTP, SMS, email)
PricingPer SSO connectionPer user (MAU)Free self-hosted / paid cloud
SDK LanguagesNode, Python, Ruby, Go, .NETNode, Python, Ruby, GoNode, Java, Go, Python, .NET
Best ForB2B enterprise SSOModern passwordless appsSelf-hosted full CIAM

When to Use Each

Choose WorkOS if:

  • You're a B2B SaaS product and enterprise customers need SAML/OIDC SSO
  • SCIM directory sync for automatic user provisioning/deprovisioning is required
  • You want an admin portal that customer IT admins use to self-serve SSO setup
  • Audit logs and compliance features are enterprise deal requirements
  • You want to add enterprise features without rebuilding your auth system

Choose Stytch if:

  • You want passwordless authentication (magic links, OTPs, passkeys) as the primary flow
  • Building a modern consumer or B2C app where passwords are a bad experience
  • You need flexible auth building blocks to compose custom flows
  • Session management with automatic token rotation matters
  • You want to add multiple auth methods (email, phone, social, passkeys) incrementally

Choose FusionAuth if:

  • Self-hosting for data residency, compliance, or cost control is important
  • You need full multi-tenancy with isolated users, themes, and configs per tenant
  • No per-user pricing — you host it, you control costs
  • Custom token logic (lambdas) for adding claims, transforming data, or validating
  • You want a single identity server covering SSO, MFA, social login, and user management

Methodology

Feature comparison based on WorkOS, Stytch, and FusionAuth documentation as of March 2026. WorkOS evaluated on SSO, directory sync, and admin portal. Stytch evaluated on passwordless flows, session management, and API flexibility. FusionAuth evaluated on self-hosted deployment, multi-tenancy, and customization. Code examples use official SDKs (WorkOS Node, Stytch Node, FusionAuth TypeScript client).

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.