WorkOS vs Stytch vs FusionAuth: Enterprise Identity and SSO Compared (2026)
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.
Magic Link Authentication
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
| Feature | WorkOS | Stytch | FusionAuth |
|---|---|---|---|
| Deployment | Cloud only | Cloud only | Self-hosted or cloud |
| Primary Focus | Enterprise B2B SSO | Passwordless auth | Full 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 Management | Basic | ✅ (advanced) | ✅ (JWT + refresh) |
| Multi-Tenancy | Organizations | Organizations | ✅ (full isolation) |
| Admin Portal | ✅ (hosted for IT admins) | ❌ | ✅ (self-hosted UI) |
| Audit Logs | ✅ (enterprise) | Basic | ✅ (detailed) |
| Custom Login UI | Your own UI | Your own UI + prebuilt | Themed hosted pages |
| Lambdas/Hooks | Webhooks | Webhooks | ✅ (JS lambdas + webhooks) |
| User Search | Directory API | User search API | ✅ (Elasticsearch-backed) |
| MFA | Via IdP | ✅ (TOTP, SMS) | ✅ (TOTP, SMS, email) |
| Pricing | Per SSO connection | Per user (MAU) | Free self-hosted / paid cloud |
| SDK Languages | Node, Python, Ruby, Go, .NET | Node, Python, Ruby, Go | Node, Java, Go, Python, .NET |
| Best For | B2B enterprise SSO | Modern passwordless apps | Self-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).