In this article
October 8, 2026
October 8, 2026

How to model multi-tenant B2B apps with WorkOS organizations

Map WorkOS organizations to your customers, keep workspaces in your own database, and handle org switching, cross-tenant guests and internal admins in a Next.js app.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Every B2B app starts with a simple tenancy model: one customer, one organization. Then real customers show up and the questions start:

  • "Our customer has one Okta tenant, but wants separate workspaces for each department. Do we create an organization per workspace?"
  • "A contractor needs access to two of our customers. Which organization do they belong to?"
  • "Our support team needs to see data across every customer. How do we give them a role that works everywhere?"
  • "The current organization lives in our URL. Why does sign-in still ask users to pick one?"

All four have the same root cause: using a WorkOS organization for two different jobs.

In this tutorial, a workspace means whatever your app calls the container inside a customer account: a project, a team, a department, a board. WorkOS has no workspace object. Workspaces live in your own database, and the rest of this post shows how they relate to organizations. This tutorial separates those jobs and shows the code for each case, using Next.js and @workos-inc/authkit-nextjs.

What you will build

  • A Next.js app where the organization lives in the URL (/o/[orgId]/w/[workspace])
  • Workspaces stored in your database and scoped to an organization
  • Org switching that respects each organization's SSO and MFA rules
  • A shared route for guests from other organizations
  • An internal admin check that works no matter which organization a staff member is in

Starting from scratch? Run npx workos@latest integrate in a new Next.js project. The WorkOS CLI installs @workos-inc/authkit-nextjs, adds the callback route, the proxy and the provider, and configures your redirect URI and CORS origins. Everything below builds on that setup. Building a React SPA instead? Start with Ship a React SPA with AuthKit to production.

The one rule: Organizations hold identity, workspaces hold data

A WorkOS organization is where identity configuration lives:

  • The SSO connection (a connection belongs to exactly one organization)
  • Directory Sync
  • Verified domains and the policies that apply to them
  • Roles, including roles scoped to that organization
  • Memberships, which decide who can sign in to it

A workspace is a container for data inside your product: a project, a team, a department, a board. It has no authentication settings of its own. It belongs in your database, with a foreign key to the organization.

So when do you create a new organization, and when do you create a workspace? Ask the customer's IT team one question: would you set up a separate SSO app for this?

  • If yes, it is a separate tenant. Create an organization with its own connection.
  • If that sounds silly to them, it is a workspace inside one tenant. Create a row in your database.
When to create an organization and when to create a workspace
Signal Create an organization Create a workspace
Has its own identity provider or SSO app Yes No
Has its own IT admin and security policies Yes No
Owns an email domain Yes No
Is billed separately Usually Sometimes
Users from it collaborate with each other Within the tenant Within the workspace
Example Acme Corp, Globex Acme Marketing, Acme Finance

‍

Most confusion goes away once you apply this. A customer with one Okta tenant and five departments is one organization with five workspaces, not five organizations that try to share a connection.

Diagram with two panels. Left, WorkOS organizations, which hold identity: Acme Corp (Okta SSO, verified domain acme.com with SSO required, Directory Sync, admin and member roles), Contractor Co (Google Workspace SSO, domain contractor.co), and YourCo staff (your own identity provider and an internal-admin role that can only be assigned in that organization). Right, your database, which holds data: Acme's Marketing, Finance and Ops workspaces, each with its own members and linked to Acme by organization_id; a workspace_guests row giving dana@contractor.co view access to the Finance workspace only, linked to Contractor Co by home_organization_id; and admin routes that check membership in YourCo staff on the server and write every view to Acme's audit log. Footer: a new SSO app means a new organization; anything else is a workspace.

Step 1: Model workspaces in your database

Three tables cover every case in this tutorial. Users and organizations stay in WorkOS. You store only their IDs.

  
-- One row per workspace. organization_id is the WorkOS organization (the tenant).
create table workspaces (
  id              text primary key,
  organization_id text not null,
  slug            text not null,
  name            text not null,
  unique (organization_id, slug)
);

-- Members of the organization who can access a workspace.
create table workspace_members (
  workspace_id text not null references workspaces(id),
  user_id      text not null,  -- WorkOS user ID
  role         text not null check (role in ('viewer', 'editor', 'owner')),
  primary key (workspace_id, user_id)
);

-- People from other organizations who were invited to one workspace.
create table workspace_guests (
  workspace_id         text not null references workspaces(id),
  user_id              text not null,  -- WorkOS user ID
  home_organization_id text not null,  -- the guest's own tenant
  role                 text not null check (role in ('viewer', 'editor')),
  expires_at           timestamptz,
  primary key (workspace_id, user_id)
);
  

Two levels of roles now work together:

  • Organization roles live in WorkOS and arrive in the session (role, permissions). Use them for tenant-wide rights, such as "admins can see every workspace" or "billing managers can change the plan."
  • Workspace roles live in workspace_members. Use them for access to one workspace.

You will also need a WorkOS client on the server for membership lookups:

  
// lib/workos.ts
import { getWorkOS } from "@workos-inc/authkit-nextjs";

export const workos = getWorkOS();
  

Step 2: Put the organization in the URL

AuthKit sessions are organization-scoped: the session has one active organization, and the access token carries its org_id, role and permissions. Your URL also names an organization. The job of the org layout is to keep the two in sync.

  
// lib/memberships.ts
import { workos } from "./workos";

export async function isActiveMember(userId: string, organizationId: string) {
  const { data } = await workos.userManagement.listOrganizationMemberships({
    userId,
    organizationId,
  });
  // Only active memberships are returned by default.
  return data.length > 0;
}
  
  
// app/o/[orgId]/layout.tsx
import { withAuth } from "@workos-inc/authkit-nextjs";
import { notFound, redirect } from "next/navigation";
import { isActiveMember } from "@/lib/memberships";

export default async function OrgLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ orgId: string }>;
}) {
  const { orgId } = await params;
  const { user, organizationId } = await withAuth({ ensureSignedIn: true });

  if (organizationId !== orgId) {
    // Never switch into an organization the user does not belong to.
    if (!(await isActiveMember(user.id, orgId))) notFound();

    const to = encodeURIComponent(`/o/${orgId}`);
    redirect(`/switch?org=${orgId}&to=${to}`);
  }

  return <>{children}</>;
}
  

The switch happens in a route handler, where the SDK can write the new session cookie:

  
// app/switch/route.ts
import { switchToOrganization, withAuth } from "@workos-inc/authkit-nextjs";
import { NextResponse, type NextRequest } from "next/server";
import { isActiveMember } from "@/lib/memberships";

export async function GET(request: NextRequest) {
  const orgId = request.nextUrl.searchParams.get("org") ?? "";
  const to = request.nextUrl.searchParams.get("to") ?? "/";

  // Only allow same-origin paths, never full URLs.
  const returnTo = to.startsWith("/") && !to.startsWith("//") ? to : "/";

  const { user } = await withAuth({ ensureSignedIn: true });
  if (!(await isActiveMember(user.id, orgId))) {
    return NextResponse.redirect(new URL("/", request.url));
  }

  // Refreshes the session into the new organization. If that organization
  // requires SSO or MFA the user has not completed, the SDK redirects them
  // to AuthKit to finish it.
  await switchToOrganization(orgId, { returnTo, revalidationStrategy: "none" });

  return NextResponse.redirect(new URL(returnTo, request.url));
}
  

Three behaviors to know about:

  • Switching re-applies the target organization's rules. If Acme requires SSO and the user last signed in with a password to Globex, switching to Acme sends them through Acme's SSO first. switchToOrganization handles that redirect for you. This is a feature: an organization's security policy follows its data.
  • Membership is a hard requirement. You cannot switch into, or sign in to, an organization the user is not a member of. That is why the layout checks membership first and returns a 404 otherwise, so the URL does not leak whether an organization exists.
  • One session, one active organization. If a user opens two tabs on two organizations, each navigation switches the shared session. For most apps that is fine, because each page load checks and switches. If your users routinely work in several organizations at once, read the next section.

For the switcher UI itself, list the user's memberships on the server and render links to /o/[orgId], or drop in the Organization Switcher widget.

Org-scoped or account-scoped sessions

Some apps, like many developer dashboards, treat the session as belonging to the person, not to an organization. The URL is the only source of truth, and every request is authorized against it. You can build that on AuthKit too, but you take on more of the work.

Org-scoped and account-scoped sessions compared
Org-scoped session (default) Account-scoped session
Source of truth for the current org Session (org_id in the token) The URL
How you authorize a request Read role and permissions from the session Look up the user's membership and role for the URL's org on every request (cache it)
Two tabs on two orgs Each navigation switches the session Works with no switching
Per-org SSO and MFA on switch Enforced by AuthKit You enforce it yourself
Tokens for WorkOS Connect and other org_id-scoped integrations Already scoped You still need to switch before issuing them
Best for Most B2B apps Apps where users live in many orgs at once

‍

If you are not sure, start org-scoped. It keeps each organization's security policy in force with no extra code.

Sending users straight into an organization at sign-in

Users who belong to several organizations see an organization picker after they sign in. You can skip it by passing the organization when you build the sign-in URL:

  
const signInUrl = await getSignInUrl({ organizationId: "org_123" });
  

Only do this when you know the user is a member, for example from a "last used organization" cookie you set after a successful sign-in on that device. If the user is not a member, sign-in stops on the AuthKit page with an invalid_authorization_state error and never reaches your callback. When you are not sure, let AuthKit show the picker.

Step 3: Scope every workspace query to the organization

With the session and URL in sync, workspace access is a database question. Always filter by the organization from the session, never by an organization ID sent by the client.

  
// lib/workspaces.ts
import { db } from "./db";

type Access = { workspaceId: string; name: string; role: "viewer" | "editor" | "owner" };

export async function getWorkspaceAccess(opts: {
  organizationId: string;
  slug: string;
  userId: string;
  orgPermissions: string[];
}): Promise<Access | null> {
  const workspace = await db.workspaces.findUnique({
    where: { organizationId_slug: { organizationId: opts.organizationId, slug: opts.slug } },
  });
  if (!workspace) return null;

  // Organization admins can open every workspace in their organization.
  if (opts.orgPermissions.includes("workspaces:manage")) {
    return { workspaceId: workspace.id, name: workspace.name, role: "owner" };
  }

  const member = await db.workspaceMembers.findUnique({
    where: { workspaceId_userId: { workspaceId: workspace.id, userId: opts.userId } },
  });
  return member ? { workspaceId: workspace.id, name: workspace.name, role: member.role } : null;
}
  
  
// app/o/[orgId]/w/[slug]/page.tsx
import { withAuth } from "@workos-inc/authkit-nextjs";
import { notFound } from "next/navigation";
import { getWorkspaceAccess } from "@/lib/workspaces";

export default async function WorkspacePage({
  params,
}: {
  params: Promise<{ orgId: string; slug: string }>;
}) {
  const { orgId, slug } = await params;
  const { user, organizationId, permissions } = await withAuth({ ensureSignedIn: true });

  // The org layout has already switched the session, so these must match.
  if (organizationId !== orgId) notFound();

  const access = await getWorkspaceAccess({
    organizationId,
    slug,
    userId: user.id,
    orgPermissions: permissions ?? [],
  });
  if (!access) notFound();

  return <h1>{access.name}</h1>;
}
  

Create workspaces:manage as a permission in the WorkOS dashboard and attach it to your organization admin role. Checking a permission instead of a role name lets you rename or split roles later without touching this code.

Step 4: One SSO connection, many workspaces

This is the case from the first question: one customer, one identity provider, many departments.

  1. Create one organization for the customer and attach their SSO connection to it.
  2. Verify their email domain and set the domain policy to require SSO. Everyone on acme.com now signs in through Acme's identity provider, whichever organization they pick.
  3. Turn on JIT provisioning (or Directory Sync), so employees become members of the organization when they first sign in.
  4. Create one workspace row per department, all with the same organization_id.
  5. Decide workspace access in workspace_members. You can fill it from your own invite flow, or from directory groups if the customer manages access in their identity provider.

There is no step where a connection is shared between organizations, because you never need it. If you catch yourself trying to add the same users to several organizations just so they can use one connection, those organizations are workspaces.

Step 5: Guests from other organizations

A contractor from Contractor Co needs access to Acme's Finance workspace. There are two ways to model that, and they behave differently.

Two ways to give a guest from another organization access
Guest membership in the host org Grant in your database
How Add the contractor as a member of Acme with a limited role Keep the contractor in Contractor Co, add a row to workspace_guests
Who controls sign-in Acme's organization policy applies when they switch to Acme Their own organization's policies only
Can they reach other Acme workspaces? Only if your code allows it, so restrict the role No, the grant names one workspace
Shows in Acme's member list Yes No, unless you show it
Best for Long-term collaborators the customer manages Short-term or narrow access you manage

‍

Watch the first option closely. If Acme's organization policy requires SSO through Acme's connection, a contractor without an Acme identity will not be able to switch into Acme.

For the second option, guests never switch organizations. Give them a route that authorizes against the grant and the user ID alone:

  
// app/shared/[workspaceId]/page.tsx
import { withAuth } from "@workos-inc/authkit-nextjs";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";

export default async function SharedWorkspacePage({
  params,
}: {
  params: Promise<{ workspaceId: string }>;
}) {
  const { workspaceId } = await params;
  const { user } = await withAuth({ ensureSignedIn: true });

  const grant = await db.workspaceGuests.findUnique({
    where: { workspaceId_userId: { workspaceId, userId: user.id } },
    include: { workspace: true },
  });

  const expired = grant?.expiresAt && grant.expiresAt < new Date();
  if (!grant || expired) notFound();

  return <h1>{grant.workspace.name} (shared with you)</h1>;
}
  

The guest stays in their own organization, under their own company's SSO, and sees exactly one workspace.

Step 6: Internal admins across every organization

Your support team needs to see data in any customer's organization. WorkOS roles always belong to an organization membership, so there is no global role. A support engineer who switches into a customer organization would also lose any role they had elsewhere.

The pattern that works:

  1. Create an internal organization for your company, for example "YourCo staff", with your own SSO connection.
  2. In that organization, create an organization-scoped role called internal-admin. Organization-scoped roles can only be assigned to memberships in that organization, so nobody in a customer organization can ever be given it, even by mistake.
  3. On the server, check the user's membership in the internal organization directly, instead of reading the role from the session.
  
// lib/internal-admin.ts
import { workos } from "./workos";

const INTERNAL_ORG_ID = process.env.INTERNAL_ORG_ID!;

export async function isInternalAdmin(userId: string): Promise<boolean> {
  try {
    const { data } = await workos.userManagement.listOrganizationMemberships({
      userId,
      organizationId: INTERNAL_ORG_ID,
    });
    return data.some((m) => m.roles?.some((r) => r.slug === "internal-admin"));
  } catch {
    // Fail closed: no lookup, no admin access.
    return false;
  }
}
  

Do not use the role or permissions from the session for this check. They describe the organization the session is in right now, so they change every time a support engineer opens a customer's workspace.

Use it in an admin-only route, and record every access in the customer's audit log:

  
// app/admin/orgs/[orgId]/page.tsx (excerpt)
const { user } = await withAuth({ ensureSignedIn: true });
if (!(await isInternalAdmin(user.id))) notFound();

const workspaces = await db.workspaces.findMany({ where: { organizationId: orgId } });

await workos.auditLogs.createEvent(orgId, {
  action: "internal_admin.workspaces.viewed",
  occurredAt: new Date(),
  actor: { type: "user", id: user.id, name: user.email },
  targets: [{ type: "organization", id: orgId }],
  context: { location: "0.0.0.0" }, // pass the request IP here
});
  

Writing staff access to the customer's own audit log is good practice: their admins can see when your team looked at their data, and why.

If your support team needs to see the product exactly as a specific user does, use impersonation instead. It is started from the WorkOS dashboard, requires a reason, expires after 60 minutes, and adds an act claim to the access token so your app can show a clear banner.

What is not possible yet

A few things in this space are still on the roadmap. Each has a workaround.

What is not possible yet, and the workarounds
Limitation Workaround
Hosted AuthKit always shows the organization picker to users with several memberships Pass organizationId at sign-in when you know the user is a member
An organizationId for an org the user is not a member of fails sign-in Create the membership first, or let the picker run
Roles cannot be assigned at the user level Use an organization-scoped role on an internal organization (step 6)
An SSO connection cannot be shared across organizations Use one organization per tenant and workspaces inside it (step 4)

Checklist

  • Each organization maps to one customer tenant with its own identity configuration.
  • Departments, teams and projects are workspaces in your database, keyed by organization_id.
  • Every request compares the URL's organization with the session's, and switches only after a membership check.
  • Every workspace query filters by the organization from the session.
  • Organization-wide rights use permissions, not hard-coded role names.
  • Guests either have a restricted membership in the host organization or a scoped grant in your database, never both by accident.
  • Internal admin checks look up the internal organization membership on the server and fail closed.
  • Staff access to customer data is written to the customer's audit log.

Wrapping up

Most multi-tenant headaches come from asking an organization to be both an identity boundary and a data container. Keep organizations for what only they can do (SSO, directories, domain policies and roles), keep workspaces in your database, and the hard cases become ordinary authorization code.

Next steps: