In this article
July 17, 2026
July 17, 2026

How to add multi-tenant authentication to your Next.js SaaS

Most multi-tenancy guides cover the database. This one covers the auth layer: org-scoped sessions, invitation flows, organization switching, per-tenant SSO, and how to scope every Server Action to the right tenant.

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

Building a SaaS that serves multiple organizations is not just a data architecture problem. It is an auth architecture problem.

Most multi-tenant guides focus on the database layer: shared tables with an organizationId column, separate schemas per tenant, or Postgres row-level security. That layer matters, but it depends on the auth layer getting the tenant context right first. If your session does not carry the correct organization, your database queries filter on the wrong tenant. If your middleware does not enforce organization membership, cross-tenant data access becomes a matter of URL manipulation.

This guide covers the auth layer specifically: how organization context flows through Next.js App Router sessions, how to build an invitation flow that converts an email into an authenticated org member, how org switching works when different tenants have different SSO requirements, and how to scope Server Actions so that mutations always run against the authenticated tenant.

The core concept: org-scoped sessions

In a single-tenant app, a session carries a user identity. In a multi-tenant app, a session carries a user identity and an active organization. Every authenticated request answers two questions: who is this user, and which organization are they acting as a member of right now.

The active organization determines:

  • Which data the user can see
  • Which role and permissions they have (a user can be admin in one org and viewer in another)
  • Which authentication requirements apply (one org might require SSO, another allows social login)
  • Which MFA policy is enforced

Getting this right in Next.js App Router means the session token carries both the user ID and the active organization ID, and every Server Component, Server Action, and Route Handler that accesses tenant data reads organization context from the session rather than from URL parameters or request bodies.

Reading org context from the URL (/org/acme/dashboard) is a common pattern but not sufficient as a security boundary. A user who changes the URL segment can potentially view another organization's data if Server Actions and Route Handlers do not independently validate that the authenticated session belongs to the requested org. The session is the source of truth.

Setting up WorkOS AuthKit for Next.js

WorkOS AuthKit handles the multi-tenant auth layer. Organizations, org membership, invitations, org-scoped roles and permissions, per-tenant SSO configuration, and org-aware session management are all built in. Your Next.js application reads organization context from the session and enforces it consistently across Server Components, Server Actions, and Route Handlers.

Option 1: The AI installer

The fastest path is the WorkOS CLI. One command detects your Next.js setup, installs the SDK, configures your WorkOS dashboard, creates the callback and sign-in routes, writes environment variables, and validates the build:

  
npx workos@latest install
  

Here is what it looks like running against a Next.js App Router project:

  
◆  Detected Next.js (App Router)
│
◇  Opening browser for WorkOS authentication...
│  Authenticated as you@example.com
│
◇  Configuring your WorkOS dashboard...
│  ✓ Redirect URI set to http://localhost:3000/callback
│  ✓ Sign-in endpoint set to http://localhost:3000/sign-in
│  ✓ Homepage URL set to http://localhost:3000
│
◇  Installing @workos-inc/authkit-nextjs...
│  ✓ Package installed
│
◇  Analyzing project structure...
│  ✓ Created app/sign-in/route.ts
│  ✓ Created app/sign-up/route.ts
│  ✓ Created app/callback/route.ts
│  ✓ Created app/sign-out/route.ts
│  ✓ Created middleware.ts
│  ✓ Updated app/layout.tsx
│  ✓ Created .env.local
│
◇  Validating integration...
│  ✓ Build completed successfully
│
◆  AuthKit is ready. Run `npm run dev` to get started.
  

That's it. The sections below explain how to do this manually and how to extend it for multi-tenancy.

Option 2: Manual setup

If you prefer to set things up yourself or are adding WorkOS to an existing project:

  
npm install @workos-inc/authkit-nextjs
  
  
# .env.local
WORKOS_CLIENT_ID="client_..."
WORKOS_API_KEY="sk_test_..."
WORKOS_REDIRECT_URI="http://localhost:3000/callback"
WORKOS_COOKIE_PASSWORD="<generate-a-32-char-random-string>"
  

Middleware

The AuthKit middleware handles session validation on every request. Configure it to run on all paths that require authentication:

  
// middleware.ts
import { authkitMiddleware } from '@workos-inc/authkit-nextjs'

export default authkitMiddleware()

export const config = {
  matcher: [
    // Run on all paths except static files and Next.js internals
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}
  

A common mistake is using a catch-all matcher ('/(.*)') that includes _next/static paths. This breaks CSS and static asset loading because the middleware intercepts requests that should bypass auth entirely. The pattern above excludes known static paths correctly.

Reading the session in Server Components

  
// app/dashboard/page.tsx
import { withAuth } from '@workos-inc/authkit-nextjs'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const { user, organizationId, role, permissions } = await withAuth({
    ensureSignedIn: true,
  })

  // organizationId is the active tenant - use it to scope all data queries
  const projects = await db.projects.findMany({
    where: { organizationId },
  })

  return (
    <div>
      <h1>Dashboard</h1>
      <p>{user.firstName} - {role} in org {organizationId}</p>
      {/* render projects */}
    </div>
  )
}
  

withAuth returns user, sessionId, organizationId, role, roles, permissions, accessToken, and impersonator. The organizationId is always the session-verified active organization, never something you read from the URL.

The sign-in and sign-up routes

  
// app/sign-in/route.ts
import { getSignInUrl } from '@workos-inc/authkit-nextjs'
import { redirect } from 'next/navigation'

export const GET = async (request: Request) => {
  const { searchParams } = new URL(request.url)
  const returnTo = searchParams.get('returnTo') ?? undefined
  const signInUrl = await getSignInUrl({ returnTo })
  return redirect(signInUrl)
}
  
  
// app/sign-up/route.ts
import { getSignUpUrl } from '@workos-inc/authkit-nextjs'
import { redirect } from 'next/navigation'

export const GET = async () => {
  const signUpUrl = await getSignUpUrl()
  return redirect(signUpUrl)
}
  

Set the sign-in endpoint in your WorkOS dashboard under Redirects to http://localhost:3000/sign-in (or your production equivalent). This is required for impersonation and WorkOS-initiated flows to work.

Sign-out

  
// app/sign-out/route.ts
import { signOut } from '@workos-inc/authkit-nextjs'

export const GET = async () => {
  return signOut()
}
  
  
// In any Server Component or Client Component
import { SignOutButton } from './sign-out-button'

// sign-out-button.tsx (client component)
'use client'
import { useRouter } from 'next/navigation'

export function SignOutButton() {
  return (
    <form action="/sign-out">
      <button type="submit">Sign out</button>
    </form>
  )
}
  

The data model: organizations and memberships

Before building the auth flows, establish the data model. WorkOS manages the authoritative record of organizations and memberships. Your database mirrors what you need for your product (project ownership, billing, settings) while WorkOS handles the auth-facing records.

  
-- Your application tables
CREATE TABLE organizations (
  id          TEXT PRIMARY KEY,   -- matches WorkOS organization ID
  name        TEXT NOT NULL,
  slug        TEXT UNIQUE NOT NULL,
  plan        TEXT NOT NULL DEFAULT 'free',
  created_at  TIMESTAMP DEFAULT NOW()
);

CREATE TABLE projects (
  id              TEXT PRIMARY KEY,
  organization_id TEXT NOT NULL REFERENCES organizations(id),
  name            TEXT NOT NULL,
  created_at      TIMESTAMP DEFAULT NOW()
);

-- You do not need a memberships table in your database.
-- WorkOS manages memberships. You read them from the session (role, permissions)
-- or from the WorkOS API when you need the full member list.
  

When a user creates an organization, create it in both WorkOS and your database:

  
// app/actions/create-org.ts
'use server'

import { withAuth } from '@workos-inc/authkit-nextjs'
import { WorkOS } from '@workos-inc/node'
import { db } from '@/lib/db'
import { redirect } from 'next/navigation'

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

export async function createOrganization(formData: FormData) {
  const { user } = await withAuth({ ensureSignedIn: true })

  const name = String(formData.get('name'))
  const slug = name.toLowerCase().replace(/[^a-z0-9]/g, '-')

  // Create in WorkOS
  const org = await workos.organizations.createOrganization({ name })

  // Add the creating user as admin
  await workos.userManagement.createOrganizationMembership({
    organizationId: org.id,
    userId: user.id,
    roleSlug: 'admin',
  })

  // Mirror in your database
  await db.organizations.create({
    data: {
      id: org.id,
      name,
      slug,
    },
  })

  // Switch the session to the new org and redirect to the dashboard
  redirect(`/dashboard?switchTo=${org.id}`)
}
  

Invitation flow

The invitation flow converts a prospective team member's email into an authenticated organization member. It has four steps: an admin sends an invite, WorkOS sends an email with a link, the invitee clicks the link and authenticates, and WorkOS converts the invitation into an organization membership.

Sending invitations

  
// app/actions/invite-member.ts
'use server'

import { withAuth } from '@workos-inc/authkit-nextjs'
import { WorkOS } from '@workos-inc/node'

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

export async function inviteMember(formData: FormData) {
  const { user, organizationId, permissions } = await withAuth({
    ensureSignedIn: true,
  })

  // Only members with invite permission can send invitations
  if (!permissions?.includes('org:members:invite')) {
    throw new Error('Forbidden')
  }

  const email = String(formData.get('email'))
  const roleSlug = String(formData.get('role')) || 'member'

  await workos.userManagement.sendInvitation({
    email,
    organizationId,
    roleSlug,
    // invitedByUserId lets WorkOS show who sent the invite in the email
    invitedByUserId: user.id,
    // expiresInDays defaults to 5 days if not specified
    expiresInDays: 7,
  })

  return { success: true }
}
  

WorkOS sends the invitation email with a secure link. The link contains a token that is valid for the configured number of days. When the invitee clicks the link, they land on AuthKit's hosted UI to authenticate (or create an account if they do not have one). After authentication, WorkOS automatically creates the organization membership with the specified role and redirects to your callback route.

Listing pending invitations

  
// app/actions/list-invitations.ts
'use server'

import { withAuth } from '@workos-inc/authkit-nextjs'
import { WorkOS } from '@workos-inc/node'

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

export async function listPendingInvitations() {
  const { organizationId, permissions } = await withAuth({
    ensureSignedIn: true,
  })

  if (!permissions?.includes('org:members:invite')) {
    throw new Error('Forbidden')
  }

  const { data: invitations } = await workos.userManagement.listInvitations({
    organizationId,
  })

  return invitations.filter((inv) => inv.state === 'pending')
}
  

Revoking an invitation

  
// app/actions/revoke-invitation.ts
'use server'

import { withAuth } from '@workos-inc/authkit-nextjs'
import { WorkOS } from '@workos-inc/node'

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

export async function revokeInvitation(invitationId: string) {
  const { permissions } = await withAuth({ ensureSignedIn: true })

  if (!permissions?.includes('org:members:invite')) {
    throw new Error('Forbidden')
  }

  await workos.userManagement.revokeInvitation({ invitationId })
  return { success: true }
}
  

The members page

  
// app/settings/members/page.tsx
import { withAuth } from '@workos-inc/authkit-nextjs'
import { WorkOS } from '@workos-inc/node'
import { InviteForm } from './invite-form'

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

export default async function MembersPage() {
  const { organizationId, permissions } = await withAuth({
    ensureSignedIn: true,
  })

  const canInvite = permissions?.includes('org:members:invite') ?? false

  const [{ data: memberships }, { data: invitations }] = await Promise.all([
    workos.userManagement.listOrganizationMemberships({ organizationId }),
    workos.userManagement.listInvitations({ organizationId }),
  ])

  const pendingInvitations = invitations.filter((i) => i.state === 'pending')

  return (
    <div>
      <h1>Team members</h1>

      <ul>
        {memberships.map((membership) => (
          <li key={membership.id}>
            {membership.userId} - {membership.role?.slug}
          </li>
        ))}
      </ul>

      {pendingInvitations.length > 0 && (
        <div>
          <h2>Pending invitations</h2>
          <ul>
            {pendingInvitations.map((invitation) => (
              <li key={invitation.id}>
                {invitation.email} - expires{' '}
                {new Date(invitation.expiresAt).toLocaleDateString()}
              </li>
            ))}
          </ul>
        </div>
      )}

      {canInvite && <InviteForm />}
    </div>
  )
}
  

Organization switching

Users who belong to multiple organizations need a way to change their active org context. refreshSession with an organizationId parameter switches the session to the new org and updates the role and permissions claims to reflect the user's membership in that org.

  
// app/actions/switch-org.ts
'use server'

import { refreshSession } from '@workos-inc/authkit-nextjs'
import { redirect } from 'next/navigation'

export async function switchOrganization(organizationId: string) {
  // refreshSession with organizationId switches the active org context.
  // If the target org requires SSO and the user has not authenticated
  // through that provider, they are redirected to do so automatically.
  // If the org requires MFA and the user has not enrolled, they are
  // redirected to complete enrollment.
  await refreshSession({ organizationId, ensureSignedIn: true })

  // After switching, redirect to the dashboard for the new org.
  // The session now carries the new organizationId, role, and permissions.
  redirect('/dashboard')
}
  

The org switcher component:

  
// app/components/org-switcher.tsx
'use client'

import { useTransition } from 'react'
import { switchOrganization } from '@/app/actions/switch-org'

type Org = { id: string; name: string }

export function OrgSwitcher({
  organizations,
  currentOrgId,
}: {
  organizations: Org[]
  currentOrgId: string
}) {
  const [isPending, startTransition] = useTransition()

  return (
    <select
      value={currentOrgId}
      disabled={isPending}
      onChange={(e) => {
        startTransition(async () => {
          await switchOrganization(e.target.value)
        })
      }}
    >
      {organizations.map((org) => (
        <option key={org.id} value={org.id}>
          {org.name}
        </option>
      ))}
    </select>
  )
}
  

Populate the switcher from the current user's memberships:

  
// In a Server Component layout
import { withAuth } from '@workos-inc/authkit-nextjs'
import { WorkOS } from '@workos-inc/node'
import { OrgSwitcher } from './org-switcher'

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

export default async function AppLayout({ children }: { children: React.ReactNode }) {
  const { user, organizationId } = await withAuth({ ensureSignedIn: true })

  const { data: memberships } =
    await workos.userManagement.listOrganizationMemberships({
      userId: user.id,
    })

  const organizations = memberships.map((m) => ({
    id: m.organizationId,
    name: m.organizationId, // replace with org name from your database
  }))

  return (
    <div>
      <nav>
        <OrgSwitcher
          organizations={organizations}
          currentOrgId={organizationId ?? ''}
        />
      </nav>
      {children}
    </div>
  )
}
  

One important behavior: if the target organization requires SSO and the user's current session was not established through that org's SSO provider, refreshSession redirects them to authenticate through the correct provider automatically. You do not need to check this yourself. The session that comes back after the redirect is guaranteed to have passed the org's authentication requirements.

Per-tenant SSO enforcement

For enterprise customers who want their entire team to sign in through their corporate identity provider, WorkOS lets you configure SSO on a per-organization basis. Once configured, members of that organization who try to sign in with email and password or social login are redirected to the org's SSO provider instead.

You configure the SSO connection in the WorkOS dashboard under the organization's settings, or programmatically via the API. The customer's IT admin can manage the IdP configuration through the WorkOS Admin Portal without your team being involved.

For domain-based SSO routing (where users from acme.com are automatically routed to Acme's SSO provider), enable domain verification for the organization:

  
const domain = await workos.organizations.createOrganizationDomain({
  organizationId: 'org_...',
  domain: 'acme.com',
})

// After DNS verification, users with @acme.com email addresses
// are automatically routed to this org's SSO provider
  

From your application's perspective, SSO users and password users look identical. The user object from withAuth has the same shape regardless of how the user authenticated. The organizationId in the session is always the org the user authenticated into.

Scoping Server Actions to the active tenant

Every Server Action that reads or writes tenant data must validate that the resource belongs to the authenticated organization. Reading organizationId from the session is the correct pattern. Reading it from a form field or URL parameter is not, because those values can be manipulated.

  
// app/actions/projects.ts
'use server'

import { withAuth } from '@workos-inc/authkit-nextjs'
import { db } from '@/lib/db'

export async function createProject(formData: FormData) {
  // organizationId comes from the session, not from the form
  const { organizationId, permissions } = await withAuth({
    ensureSignedIn: true,
  })

  if (!permissions?.includes('projects:write')) {
    throw new Error('Forbidden')
  }

  const name = String(formData.get('name'))

  return await db.projects.create({
    data: {
      organizationId, // always from session, never from input
      name,
    },
  })
}

export async function deleteProject(projectId: string) {
  const { organizationId, permissions } = await withAuth({
    ensureSignedIn: true,
  })

  if (!permissions?.includes('projects:delete')) {
    throw new Error('Forbidden')
  }

  const project = await db.projects.findUnique({ where: { id: projectId } })

  // Validate the resource belongs to the active org
  // before operating on it
  if (!project || project.organizationId !== organizationId) {
    throw new Error('Not found')
  }

  await db.projects.delete({ where: { id: projectId } })
  return { success: true }
}
  

Return Not found rather than Forbidden when the resource exists but belongs to a different organization. Forbidden confirms the resource exists, which leaks information about other tenants.

Route-level org enforcement with middleware

For routes that should be scoped to a specific organization (for example, a subdomain-based setup where acme.yoursaas.com/dashboard should only be accessible to Acme members), add org validation to your middleware:

  
// middleware.ts
import { authkitMiddleware } from '@workos-inc/authkit-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export default authkitMiddleware({
  middlewareAuth: {
    enabled: true,
    unauthenticatedPaths: ['/sign-in', '/sign-up', '/', '/pricing'],
  },
})

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}
  

For subdomain-based tenant routing, read the subdomain in the middleware and compare it to the session's organizationId:

  
// middleware.ts - subdomain org routing
import { authkitMiddleware } from '@workos-inc/authkit-nextjs'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export default function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') ?? ''
  const subdomain = hostname.split('.')[0]

  // Skip org enforcement for the root domain and auth routes
  if (!subdomain || subdomain === 'www' || subdomain === 'app') {
    return authkitMiddleware()(request, {} as any)
  }

  // After auth, the session organizationId should match the subdomain's org
  // This is a hint for the UX - the actual enforcement happens in Server Actions
  const url = request.nextUrl.clone()
  url.searchParams.set('tenant', subdomain)

  return authkitMiddleware()(request, {} as any)
}
  

A note on middleware and CVE-2025-29927: a 2025 vulnerability in Next.js middleware (CVSS 9.1) allowed attackers to bypass middleware auth checks entirely by manipulating the x-middleware-subrequest header. The fix is in Next.js v12.3.5, v13.5.9, v14.2.25, and v15.2.3 and above. More importantly, the correct pattern is to enforce auth in the data access layer - in Server Actions and Route Handlers - not to rely on middleware as the sole security boundary. Middleware handles the user experience (redirecting unauthenticated users to the login page). Server Actions handle the actual security enforcement.

Org-scoped RBAC

WorkOS RBAC is organization-scoped by default. A user's role and permissions in the session reflect their membership in the active organization specifically. Switching organizations updates the role and permissions automatically.

Protect Server Actions with permission checks drawn from the session:

  
// Reusable permission check helper
export async function requirePermission(permission: string) {
  const { user, organizationId, permissions } = await withAuth({
    ensureSignedIn: true,
  })

  if (!organizationId) {
    throw new Error('No active organization')
  }

  if (!permissions?.includes(permission)) {
    throw new Error('Forbidden')
  }

  return { user, organizationId, permissions }
}
  
  
// Usage in Server Actions
export async function updateOrgSettings(formData: FormData) {
  const { organizationId } = await requirePermission('org:settings:write')

  await db.organizations.update({
    where: { id: organizationId },
    data: { name: String(formData.get('name')) },
  })
}
  

Handling users with no organization

New users who sign up without an invitation land in your app with a valid session but no active organization. You need to handle this state: either prompt them to create an organization or invite them to join one.

  
// app/dashboard/page.tsx
import { withAuth } from '@workos-inc/authkit-nextjs'
import { redirect } from 'next/navigation'

export default async function DashboardPage() {
  const { user, organizationId } = await withAuth({ ensureSignedIn: true })

  // User is authenticated but has no active org context
  if (!organizationId) {
    redirect('/onboarding/create-org')
  }

  // Normal dashboard rendering with org context
  const projects = await db.projects.findMany({
    where: { organizationId },
  })

  return <Dashboard user={user} projects={projects} />
}
  
  
// app/onboarding/create-org/page.tsx
import { createOrganization } from '@/app/actions/create-org'

export default function CreateOrgPage() {
  return (
    <div>
      <h1>Create your organization</h1>
      <form action={createOrganization}>
        <input name="name" placeholder="Acme Inc" required />
        <button type="submit">Create organization</button>
      </form>
    </div>
  )
}
  

Syncing WorkOS events with webhooks

WorkOS emits events for organization lifecycle changes that your application should respond to: memberships created or revoked, invitations accepted, SSO connections changed, directory sync events. Wire these into a Route Handler:

  
// app/api/webhooks/workos/route.ts
import { WorkOS } from '@workos-inc/node'
import { headers } from 'next/headers'
import { db } from '@/lib/db'

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

export async function POST(request: Request) {
  const payload = await request.text()
  const headersList = await headers()
  const signature = headersList.get('workos-signature') ?? ''

  let event
  try {
    event = workos.webhooks.constructEvent({
      payload,
      sigHeader: signature,
      secret: process.env.WORKOS_WEBHOOK_SECRET!,
    })
  } catch {
    return new Response('Invalid signature', { status: 400 })
  }

  switch (event.event) {
    case 'dsync.user.created':
    case 'dsync.user.updated': {
      // A user was provisioned or updated via Directory Sync
      // Upsert into your database
      const directoryUser = event.data
      await db.users.upsert({
        where: { workosId: directoryUser.id },
        create: {
          workosId: directoryUser.id,
          email: directoryUser.emails[0]?.value,
          firstName: directoryUser.firstName,
          lastName: directoryUser.lastName,
        },
        update: {
          email: directoryUser.emails[0]?.value,
          firstName: directoryUser.firstName,
          lastName: directoryUser.lastName,
        },
      })
      break
    }

    case 'dsync.user.deleted': {
      // User was deprovisioned - revoke access immediately
      const directoryUser = event.data
      await db.users.update({
        where: { workosId: directoryUser.id },
        data: { deprovisioned: true },
      })
      break
    }

    case 'organization_membership.created': {
      // A user joined an organization (via invite or SSO JIT provisioning)
      const membership = event.data
      // Sync to your database if needed
      break
    }

    case 'organization_membership.deleted': {
      // A member was removed from an organization
      // Revoke any resource access scoped to this membership
      break
    }
  }

  return new Response('OK', { status: 200 })
}
  

Directory Sync is the enterprise feature that replaces manual invitation management for large organizations. When a customer connects their Okta, Azure AD, or Google Workspace directory, WorkOS provisions and deprovisions users automatically as their IT admin adds and removes people. Users added to the directory get organization memberships created in WorkOS. Users removed from the directory get deprovisioned immediately. Your webhook handler keeps your database in sync.

Production checklist

Session and middleware

  • Use the authkit-nextjs middleware on all authenticated paths, not just protected ones. This ensures sessions are refreshed correctly.
  • Avoid catch-all middleware matchers that include _next/static paths - this breaks CSS and image loading.
  • Always read organizationId from withAuth, never from URL parameters or form fields.
  • Keep Next.js up to date. The CVE-2025-29927 middleware bypass was patched in v14.2.25 and v15.2.3. Check your version.

Data access and tenant isolation

  • Validate that every resource you read or write belongs to the authenticated organizationId before operating on it.
  • Return Not found for cross-tenant access attempts, not Forbidden. Do not confirm that a resource exists for a different tenant.
  • Never return organizationId from Server Actions unless the client genuinely needs it. Minimize data exposure.

Invitations and membership

  • Set invitation expiry to a reasonable window (5 to 7 days). Expired invitations cannot be accepted.
  • List pending invitations on the members page so admins can see what is outstanding and revoke invitations that were sent in error.
  • Handle the no-organization state for users who sign up without an invitation. Redirect them to an onboarding flow rather than rendering a broken dashboard.

Org switching

  • Test org switching for users who belong to orgs with different SSO requirements. The refreshSession redirect to the SSO provider should complete and return to your app correctly.
  • After an org switch, verify that all Server Actions and data queries are using the new organizationId from the updated session.

Webhooks

  • Verify the WorkOS webhook signature before processing any event. Reject events with invalid signatures immediately.
  • Handle dsync.user.deleted events to deprovision users as soon as their IT admin removes them from the directory. Timely deprovisioning is a key enterprise security requirement.

Conclusion

Multi-tenant authentication in Next.js App Router is primarily a question of where org context comes from and where it is enforced. The answer to both is the same: the session. withAuth gives you the session-verified organization ID. Every Server Action and Route Handler that touches tenant data uses that ID to scope queries and validate resource ownership.

WorkOS handles the complexity that sits above the session: organization management, invitation flows, per-tenant SSO, Directory Sync for enterprise customers, and org-scoped RBAC. Your application reads the resulting session and enforces it consistently across the data layer.

WorkOS is free up to 1 million monthly active users, with no additional charge for organization management, invitations, or RBAC.

Sign up for WorkOS and add multi-tenant authentication to your Next.js application.