In this article
August 4, 2026
August 4, 2026

How to add multi-tenant authentication to your TanStack Start 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 why server functions, not routes, are where tenant checks actually have to live.

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 server functions do not independently enforce organization membership, cross-tenant data access becomes a matter of calling the wrong RPC endpoint with the wrong ID.

That last point matters more in TanStack Start than in most frameworks, and it's worth stating up front rather than burying it: TanStack Start's own SDK documentation is explicit that server functions, not routes, are the real security boundary. A route's beforeLoad guard controls what a user sees. It does not control what a createServerFn will execute if called directly, because every server function is also an independently reachable RPC endpoint. This guide covers the auth layer with that distinction in mind: how organization context flows through TanStack Start sessions, how to build an invitation flow, how org switching works (this is one of the few frameworks where WorkOS ships it as a first-class function), and how to scope every server function, not just every page, to the right tenant.

The core concept: Org-scoped sessions, and where they actually get checked

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

In most React frameworks, the natural instinct is to check all of this once, in a layout loader or a route guard, and trust that the rest of the page tree inherits the check. TanStack Start breaks that instinct on purpose. createServerFn functions can be called from route loaders, from other server functions, and from server route handlers, but they can also be called directly over the network as their own RPC endpoint, independent of whichever route rendered the button that triggered them. A beforeLoad hook that checks getAuth() protects the page experience. It does not stop a request from hitting the underlying server function directly with a different organizationId or resource ID in the payload.

The practical implication: every server function that reads or writes tenant data needs its own getAuth() call and its own organization check, not just the route that renders its trigger button. We'll come back to this with a concrete before-and-after example later in the guide.

Setting up WorkOS AuthKit for TanStack Start

WorkOS AuthKit handles the multi-tenant auth layer. Organizations, org membership, invitations, org-scoped roles and permissions, per-tenant SSO configuration, and cookie-based session management are all built in. Your TanStack Start application reads organization context from the session and enforces it inside each server function.

Option 1: The AI installer

The fastest path is the WorkOS CLI. One command detects your TanStack Start 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
  

That's it for the basics. The sections below explain the manual setup and how to extend it for multi-tenancy.

Option 2: Manual setup

  
npm install @workos/authkit-tanstack-react-start
  
  
# .env
WORKOS_CLIENT_ID="client_..."
WORKOS_API_KEY="sk_test_..."
WORKOS_REDIRECT_URI="http://localhost:3000/api/auth/callback"
WORKOS_COOKIE_PASSWORD="..." # min 32 characters, generate with: openssl rand -base64 24
  

Configure middleware

Create or update src/start.ts:

  
// src/start.ts
import { createStart, createCsrfMiddleware } from '@tanstack/react-start'
import { authkitMiddleware } from '@workos/authkit-tanstack-react-start'

// Reject cross-site requests to server-function RPC endpoints.
const csrfMiddleware = createCsrfMiddleware({
  filter: (ctx) => ctx.handlerType === 'serverFn',
})

export const startInstance = createStart(() => ({
  requestMiddleware: [csrfMiddleware, authkitMiddleware()],
}))
  

This is the setup step worth reading twice. TanStack Start applies CSRF protection to server functions automatically, but only when your app doesn't define its own startInstance. Registering authkitMiddleware means you now have one, which silently opts you out of that default protection. createCsrfMiddleware restores it. It's a pure header check (Sec-Fetch-Site, Origin, Referer) with no tokens and no interaction with the AuthKit session cookie, and it needs to run before authkitMiddleware so cross-site requests are rejected before any session work happens. Skipping this is an easy way to end up with an app that has authentication but not CSRF protection on its mutation endpoints.

Callback route

  
// src/routes/api/auth/callback.tsx
import { createFileRoute } from '@tanstack/react-router'
import { handleCallbackRoute } from '@workos/authkit-tanstack-react-start'

export const Route = createFileRoute('/api/auth/callback')({
  server: {
    handlers: {
      GET: handleCallbackRoute({
        onSuccess: async ({ user, authenticationMethod }) => {
          // Persist anything you need after a successful sign-in
          console.log(`${user.email} signed in via ${authenticationMethod}`)
        },
        onError: ({ error, request }) => {
          console.error('Auth callback failed', error)
          return Response.redirect(new URL('/sign-in?error=auth_failed', request.url))
        },
      }),
    },
  },
})
  

Make sure this route matches WORKOS_REDIRECT_URI exactly.

Sign-in endpoint

  
// src/routes/api/auth/sign-in.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getSignInUrl } from '@workos/authkit-tanstack-react-start'

export const Route = createFileRoute('/api/auth/sign-in')({
  server: {
    handlers: {
      GET: async ({ request }: { request: Request }) => {
        const returnPathname = new URL(request.url).searchParams.get('returnPathname')
        const url = await getSignInUrl(returnPathname ? { data: { returnPathname } } : undefined)
        return new Response(null, { status: 307, headers: { Location: url } })
      },
    },
  },
})
  

Set this route as the Sign-in endpoint in the WorkOS dashboard Redirects page. Without it, WorkOS-initiated flows like dashboard impersonation fail the PKCE and CSRF verification this library enforces on every callback.

Reading the session

You don't need a client-side provider unless you want reactive client hooks. For server-rendered pages, getAuth() in a loader is enough:

  
// src/routes/_authenticated.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getAuth } from '@workos/authkit-tanstack-react-start'

export const Route = createFileRoute('/_authenticated')({
  loader: async ({ location }) => {
    const { user, organizationId, role, permissions } = await getAuth()

    if (!user) {
      const returnPathname = encodeURIComponent(location.pathname)
      throw redirect({ href: `/api/auth/sign-in?returnPathname=${returnPathname}` })
    }

    return { user, organizationId, role, permissions }
  },
})
// All routes nested under _authenticated require a signed-in user.
  

If you do want reactive client-side auth state (a header that updates the moment a user signs out in another tab, for example), wrap the root route with the provider:

  
// src/routes/__root.tsx
import { AuthKitProvider } from '@workos/authkit-tanstack-react-start/client'
import { Outlet, createRootRoute } from '@tanstack/react-router'

export const Route = createRootRoute({ component: RootComponent })

function RootComponent() {
  return (
    <AuthKitProvider>
      <Outlet />
    </AuthKitProvider>
  )
}
  

getAuth() and the client hooks are imported from different subpaths on purpose: server functions from @workos/authkit-tanstack-react-start, client hooks from @workos/authkit-tanstack-react-start/client. Mixing them up produces confusing bundler errors rather than a clean type error, so it's worth double-checking imports when something doesn't compile.

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.
  

Mutations in TanStack Start are typically written as server functions with createServerFn, called directly from client components like ordinary async functions. Creating an organization is a good first example, and it's also the first place the "check inside the function, not just the page" rule applies:

  
// src/server/organizations.ts
import { createServerFn } from '@tanstack/react-start'
import { getAuth } from '@workos/authkit-tanstack-react-start'
import { WorkOS } from '@workos-inc/node'
import { db } from '~/lib/db.server'

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

export const createOrganization = createServerFn({ method: 'POST' })
  .validator((data: { name: string }) => data)
  .handler(async ({ data }) => {
    const { user } = await getAuth()

    if (!user) {
      throw new Error('Not signed in')
    }

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

    const org = await workos.organizations.createOrganization({ name: data.name })

    await workos.userManagement.createOrganizationMembership({
      organizationId: org.id,
      userId: user.id,
      roleSlug: 'admin',
    })

    await db.organizations.create({
      data: { id: org.id, name: data.name, slug },
    })

    return { organizationId: org.id }
  })
  
  
// src/routes/onboarding.create-org.tsx
import { useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { createOrganization } from '~/server/organizations'
import { switchToOrganization } from '@workos/authkit-tanstack-react-start'

export default function CreateOrgPage() {
  const [name, setName] = useState('')
  const navigate = useNavigate()

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    const { organizationId } = await createOrganization({ data: { name } })
    // The session is still scoped to whatever org context it had before.
    // Switch into the new org before sending the user to their dashboard.
    await switchToOrganization({ data: { organizationId } })
    navigate({ to: '/dashboard' })
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} placeholder="Acme Inc" required />
      <button type="submit">Create organization</button>
    </form>
  )
}
  

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 and revoking invitations

  
// src/server/invitations.ts
import { createServerFn } from '@tanstack/react-start'
import { getAuth } from '@workos/authkit-tanstack-react-start'
import { WorkOS } from '@workos-inc/node'

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

export const inviteMember = createServerFn({ method: 'POST' })
  .validator((data: { email: string; roleSlug?: string }) => data)
  .handler(async ({ data }) => {
    const { user, organizationId, permissions } = await getAuth()

    // The check lives here, inside the function, not just on the page
    // that renders the invite form.
    if (!user || !organizationId || !permissions?.includes('org:members:invite')) {
      throw new Error('Forbidden')
    }

    await workos.userManagement.sendInvitation({
      email: data.email,
      organizationId,
      roleSlug: data.roleSlug ?? 'member',
      invitedByUserId: user.id,
      expiresInDays: 7,
    })

    return { success: true }
  })

export const revokeInvitation = createServerFn({ method: 'POST' })
  .validator((data: { invitationId: string }) => data)
  .handler(async ({ data }) => {
    const { permissions } = await getAuth()

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

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

The members page

Reads happen in the route loader; writes go through the server functions above:

  
// src/routes/settings.members.tsx
import { createFileRoute } from '@tanstack/react-router'
import { getAuth } from '@workos/authkit-tanstack-react-start'
import { WorkOS } from '@workos-inc/node'
import { inviteMember, revokeInvitation } from '~/server/invitations'

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

export const Route = createFileRoute('/settings/members')({
  loader: async () => {
    const { organizationId, permissions } = await getAuth()

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

    return {
      memberships,
      pendingInvitations: invitations.filter((i) => i.state === 'pending'),
      canInvite: permissions?.includes('org:members:invite') ?? false,
    }
  },
  component: MembersPage,
})

function MembersPage() {
  const { memberships, pendingInvitations, canInvite } = Route.useLoaderData()

  return (
    <div>
      <h1>Team members</h1>
      <ul>
        {memberships.map((m) => (
          <li key={m.id}>{m.userId} - {m.role?.slug}</li>
        ))}
      </ul>

      {pendingInvitations.length > 0 && (
        <ul>
          {pendingInvitations.map((invitation) => (
            <li key={invitation.id}>
              {invitation.email}
              <button onClick={() => revokeInvitation({ data: { invitationId: invitation.id } })}>
                Revoke
              </button>
            </li>
          ))}
        </ul>
      )}

      {canInvite && (
        <button
          onClick={() =>
            inviteMember({ data: { email: 'new.hire@example.com', roleSlug: 'member' } })
          }
        >
          Invite
        </button>
      )}
    </div>
  )
}
  

Organization switching

This is the one area where TanStack Start's SDK is ahead of some of its siblings: switchToOrganization is a first-class, documented server function, not something you have to build yourself.

  
import { switchToOrganization } from '@workos/authkit-tanstack-react-start'

const auth = await switchToOrganization({
  data: {
    organizationId: 'org_456',
    returnTo: '/dashboard', // optional
  },
})
// The session now carries org_456's role, permissions, and other claims.
  

From a client component, the useAuth() hook exposes the same capability reactively:

  
import { useAuth } from '@workos/authkit-tanstack-react-start/client'

function OrgSwitcher() {
  const { organizationId, switchToOrganization } = useAuth()

  return (
    <select value={organizationId || ''} onChange={(e) => switchToOrganization(e.target.value)}>
      <option value="org_123">Acme Corp</option>
      <option value="org_456">Other Company</option>
    </select>
  )
}
  

If the target organization requires SSO that the user hasn't authenticated through yet, the switch will need to route through re-authentication rather than completing silently. Test this path specifically for users who belong to orgs with different SSO requirements, since it's easy to only test switching between two orgs that both use plain email and password.

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 getAuth() has the same shape regardless of how the user authenticated. organizationId in the session is always the org the user authenticated into.

Scoping server functions to the active tenant, not just the route

This is worth its own section rather than folding it into the invitation and project examples above, because it's the single most important habit to build for this framework.

Here's a vulnerable version of a delete-project function. It looks reasonable, because the page that renders its trigger button is behind _authenticated and only shows projects belonging to the current org:

  
// Don't do this
export const deleteProject = createServerFn({ method: 'POST' })
  .validator((data: { projectId: string }) => data)
  .handler(async ({ data }) => {
    // No auth check here. This function trusts that only the
    // authorized UI ever calls it.
    await db.projects.delete({ where: { id: data.projectId } })
    return { success: true }
  })
  

The problem is that deleteProject is reachable as its own RPC endpoint. Its route's beforeLoad guard never runs, because nothing about calling a server function directly requires loading the page it's normally called from. An authenticated user from a different organization who knows or guesses a projectId can call this function with that ID and delete a resource they were never authorized to touch.

The fix is to make every server function independently responsible for its own authorization, exactly as you would for a public API endpoint, because that is effectively what it is:

  
// Do this instead
export const deleteProject = createServerFn({ method: 'POST' })
  .validator((data: { projectId: string }) => data)
  .handler(async ({ data }) => {
    const { organizationId, permissions } = await getAuth()

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

    const project = await db.projects.findUnique({ where: { id: data.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: data.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.

beforeLoad and route layouts are still useful. They give users a coherent experience: redirecting them to sign-in, hiding navigation they can't use, avoiding a flash of content they're not authorized to see. They are UX, not enforcement. Enforcement is whatever runs inside the server function itself, every time, regardless of how it was called.

Org-scoped RBAC

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

A small helper keeps the pattern from the previous section consistent across every server function:

  
// src/server/require-permission.ts
import { getAuth } from '@workos/authkit-tanstack-react-start'

export async function requirePermission(permission: string) {
  const { user, organizationId, permissions } = await getAuth()

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

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

  return { user, organizationId, permissions }
}
  
  
export const updateOrgSettings = createServerFn({ method: 'POST' })
  .validator((data: { name: string }) => data)
  .handler(async ({ data }) => {
    const { organizationId } = await requirePermission('org:settings:write')

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

    return { success: true }
  })
  

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. Handle this state explicitly: either prompt them to create an organization or invite them to join one.

  
// src/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getAuth } from '@workos/authkit-tanstack-react-start'
import { db } from '~/lib/db.server'

export const Route = createFileRoute('/dashboard')({
  loader: async () => {
    const { user, organizationId } = await getAuth()

    if (!user) {
      throw redirect({ href: '/api/auth/sign-in' })
    }

    if (!organizationId) {
      throw redirect({ href: '/onboarding/create-org' })
    }

    const projects = await db.projects.findMany({ where: { organizationId } })
    return { projects }
  },
  component: DashboardPage,
})
  

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. A webhook endpoint is a server route handler, the same shape as the callback and sign-in routes above, just listening for POST:

  
// src/routes/api/webhooks/workos.ts
import { createFileRoute } from '@tanstack/react-router'
import { WorkOS } from '@workos-inc/node'
import { db } from '~/lib/db.server'

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

export const Route = createFileRoute('/api/webhooks/workos')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const payload = await request.text()
        const signature = request.headers.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': {
            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': {
            const directoryUser = event.data
            await db.users.update({
              where: { workosId: directoryUser.id },
              data: { deprovisioned: true },
            })
            break
          }

          case 'organization_membership.created':
          case 'organization_membership.deleted': {
            // Sync membership changes to your database if you cache them locally
            break
          }
        }

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

A webhook route is a plain HTTP endpoint, not a server function, so the CSRF middleware from the setup step doesn't apply to it and doesn't need to. Signature verification is what protects it instead.

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. Your webhook handler keeps your database in sync.

Production checklist

Session and middleware

  • Pair authkitMiddleware() with createCsrfMiddleware in src/start.ts. Registering a custom startInstance silently disables TanStack Start's default CSRF protection for server functions unless you add it back.
  • Configure a sign-in endpoint in the WorkOS dashboard, or dashboard-initiated impersonation will fail PKCE and CSRF verification.
  • Set WORKOS_COOKIE_PASSWORD to a securely generated 32+ character string, unique per environment.
  • Keep server (@workos/authkit-tanstack-react-start) and client (@workos/authkit-tanstack-react-start/client) imports separate. Mixing them produces confusing bundler errors.

Data access and tenant isolation

  • Never trust a route guard alone. Every server function that touches tenant data needs its own getAuth() call and its own organization check, because it is independently reachable as an RPC endpoint.
  • Validate that every resource a server function reads or writes 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.

Invitations and membership

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

Org switching

  • Test switching for users who belong to orgs with different SSO requirements, not just orgs that share the same authentication method.
  • After a switch, confirm downstream server functions are reading the updated organizationId rather than a value cached from before the switch.

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.

Conclusion

Multi-tenant authentication in TanStack Start comes down to the same two questions as any other framework: where does org context come from, and where is it enforced. The answer to the first is the session, read through getAuth(). The answer to the second is different here than it is elsewhere: enforcement belongs inside every server function that touches tenant data, not in a route guard that a direct RPC call can simply route around.

WorkOS handles the complexity that sits above that boundary: organization management, invitation flows, per-tenant SSO, Directory Sync for enterprise customers, org-scoped RBAC, and, unlike some of its other framework SDKs, first-class organization switching out of the box. Your application reads the resulting session inside each server function and enforces it there, every time.

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 TanStack Start application.