Use case · Multi-tenant SaaS

A multi-tenant architecture that survives the first enterprise review

Membership through a typed join table, isolation enforced by row-level security in SQL, per-tenant Stripe customer, audit log from day one, tenant suspension and GDPR deletion that actually work. Built so the question "how do you guarantee customer A cannot see customer B" has a one-screen answer.

The problem

Most SaaS bolts multi-tenancy on at the wrong layer

The pattern that breaks under audit is the one where authorisation lives in the application code. A junior engineer adds a route that forgets the tenant scope; a refactor moves a helper to a place where the tenant is not in scope; a third-party integration writes to the wrong row. The right answer is to put the tenant boundary in the database, where it cannot be forgotten. The membership table is the law; row-level security is the enforcement; the application code stops carrying the question. We design this from the start because retrofitting it after the first enterprise contract is twice the work.

Our approach

The six layers of a multi-tenant architecture

Each layer answers one question the team will be asked under audit. Skipping any of them turns into an emergency a year later.

  1. 01

    Model membership as a join table

    A `members` table with `(tenant_id, user_id, role)` is the only source of truth for who belongs where. A user can be in many tenants; a tenant has many members. Every other table delegates authorisation to a subquery against `members`.

  2. 02

    Enforce isolation with row-level security in SQL

    Every multi-tenant table has RLS enabled and a policy that delegates to `members`. A renamed authorisation check in the application code does not bypass the policy because the policy runs on every query, regardless of who wrote the query.

  3. 03

    Track the active tenant in the session

    A user with multiple tenants needs an explicit "active tenant" in the session. We store it as a server-readable cookie set by a Server Action; the middleware exposes it as a header; the Supabase client reads it and scopes the queries.

  4. 04

    Build the invitation flow

    An invite is a `members` row with `status = 'pending'`. The invited user receives an email with a signed link; accepting flips the status to `active`. The invitation expires; the audit log records who invited whom.

  5. 05

    Isolate billing per tenant

    Each tenant has its own Stripe customer ID. Payment failure for tenant A never affects tenant B. The Customer Portal opens for the active tenant only. Per-tenant billing reports surface in the admin dashboard.

  6. 06

    Audit log from day one

    Every privileged action writes one row to `audit_events` (tenant, actor, action, resource, before-after snapshot). The log is append-only at the policy level. The first auditor asks for it; we hand it over without rebuilding the data trail.

What we deliver

Tenants and members tables

Postgres tables with foreign keys, indexes, role enum, joined-at and invited-at timestamps. The membership join is the only source of truth.

Row-level security policies

Every multi-tenant table has RLS enabled. Policies delegate every read, write, update and delete to a subquery against `members`. Stored in source-controlled migrations.

Active tenant cookie + middleware header

A signed cookie carries the active tenant ID; the middleware reads it and exposes `x-tenant-id` to the application. The Supabase client picks it up automatically.

Invitation flow

Server Actions for inviting, listing pending invitations, accepting, declining and revoking. Email sent via Resend with a signed token; expiry handled at the database level.

Role-based permissions

Roles (owner, admin, member, billing) defined as a Postgres enum. Policies check the role against the action. The application reads the role; the database enforces it.

Per-tenant Stripe customer

One Stripe customer ID per tenant, stored on the tenants row. Subscription, invoices, Customer Portal all scoped to the active tenant. Cross-tenant billing actions are physically impossible.

Audit log

Append-only `audit_events` table with policy that blocks updates and deletes. Every Server Action writes one row; the admin dashboard surfaces the log per tenant.

Tenant switcher UI

A header dropdown that lists the user's tenants and switches the active tenant via a Server Action. Validates membership before flipping the cookie.

Per-tenant feature flags

Edge Config entries keyed by tenant ID, read at the edge in single-digit milliseconds. Used for gradual rollouts, A/B tests and enterprise-tier features.

GDPR data deletion per tenant

A documented procedure for full tenant deletion, including Stripe customer deletion, file storage purge and audit log retention per regulation.

Tenant suspension

A `status` column on the tenants row with values like `active`, `suspended`, `cancelled`. Middleware enforces the status; suspended tenants see a paywall, not a partial app.

Migration from single-tenant

When the application started single-tenant, the migration creates a default tenant, backfills membership, enables RLS gradually, and finishes with a CI guard that fails if any new table lacks RLS.

Active tenant context and the invitation flow

Two patterns that hold the multi-tenant architecture together. The active tenant cookie is set by a Server Action and read by middleware. The invitation flow is one Server Action that creates the pending member, sends the email, and returns the URL the invited user clicks.

The multi-tenant SaaS architecture stands on two patterns the application code carries around with it: the active tenant context, and the invitation flow. The two files below are what we ship; together with the row-level security policies from the Supabase stack page, they cover the multi-tenant surface the application actually touches.

1. The active tenant cookie and the middleware

A signed cookie carries the active tenant ID. The middleware reads it on every request, validates the user is a member of that tenant, and exposes x-tenant-id as a header that the application code (and the Supabase client) read from.

// src/middleware.ts (excerpt)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyTenantCookie } from '@/lib/auth/active-tenant'

export async function middleware(request: NextRequest) {
  const session = request.cookies.get('session')?.value
  const tenantCookie = request.cookies.get('active_tenant')?.value

  if (!session) return NextResponse.next()

  const activeTenantId = await verifyTenantCookie({
    session,
    tenantCookie,
  })

  const requestHeaders = new Headers(request.headers)
  if (activeTenantId) {
    requestHeaders.set('x-tenant-id', activeTenantId)
  }

  return NextResponse.next({ request: { headers: requestHeaders } })
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
// src/lib/auth/active-tenant.ts
import { jwtVerify, SignJWT } from 'jose'
import { adminClient } from '@/lib/supabase/admin'

const TENANT_COOKIE_SECRET = new TextEncoder().encode(
  process.env.TENANT_COOKIE_SECRET!,
)

export async function verifyTenantCookie(opts: {
  session: string
  tenantCookie: string | undefined
}): Promise<string | null> {
  if (!opts.tenantCookie) return null

  // Verify the cookie is signed by us and carries a user-tenant binding.
  let payload
  try {
    const verified = await jwtVerify(opts.tenantCookie, TENANT_COOKIE_SECRET)
    payload = verified.payload as { userId: string; tenantId: string }
  } catch {
    return null
  }

  // Verify the user is still a member of the tenant.
  const { data } = await adminClient
    .from('members')
    .select('tenant_id')
    .eq('user_id', payload.userId)
    .eq('tenant_id', payload.tenantId)
    .eq('status', 'active')
    .maybeSingle()

  return data?.tenant_id ?? null
}

export async function signTenantCookie(opts: {
  userId: string
  tenantId: string
}): Promise<string> {
  return new SignJWT({ userId: opts.userId, tenantId: opts.tenantId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('30d')
    .sign(TENANT_COOKIE_SECRET)
}

2. The tenant switcher Server Action

A Server Action validates membership and writes the signed cookie. The UI's dropdown calls this when the user picks a different tenant; the next request operates inside the new tenant context.

// app/[lang]/(app)/actions/switch-tenant.ts
'use server'

import { z } from 'zod'
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'
import { adminClient } from '@/lib/supabase/admin'
import { signTenantCookie } from '@/lib/auth/active-tenant'
import { getServerSession } from '@/lib/auth/server'

const Input = z.object({
  tenantId: z.string().uuid(),
})

export async function switchActiveTenant(
  raw: unknown,
): Promise<{ ok: true } | { ok: false; error: string }> {
  const session = await getServerSession()
  if (!session) return { ok: false, error: 'unauthorised' }

  const parsed = Input.safeParse(raw)
  if (!parsed.success) return { ok: false, error: 'invalid input' }

  // Verify the user is a member of the target tenant.
  const { data: membership } = await adminClient
    .from('members')
    .select('tenant_id')
    .eq('user_id', session.userId)
    .eq('tenant_id', parsed.data.tenantId)
    .eq('status', 'active')
    .maybeSingle()

  if (!membership) return { ok: false, error: 'not a member' }

  const cookie = await signTenantCookie({
    userId: session.userId,
    tenantId: parsed.data.tenantId,
  })

  const cookieStore = await cookies()
  cookieStore.set('active_tenant', cookie, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 30,
    path: '/',
  })

  revalidatePath('/', 'layout')
  return { ok: true }
}

3. The invitation flow

Inviting a member is one Server Action: it creates a pending row, sends the email, returns success. The invitation token is the pending row's ID, signed with a short TTL.

// app/[lang]/(app)/team/invite/actions.ts
'use server'

import { z } from 'zod'
import { SignJWT } from 'jose'
import { Resend } from 'resend'
import { revalidatePath } from 'next/cache'
import { adminClient } from '@/lib/supabase/admin'
import { getServerSession } from '@/lib/auth/server'
import { getActiveTenantId } from '@/lib/auth/server'

const Input = z.object({
  email: z.string().email(),
  role: z.enum(['owner', 'admin', 'member', 'billing']),
})

const INVITE_SECRET = new TextEncoder().encode(process.env.INVITE_SECRET!)
const resend = new Resend(process.env.RESEND_API_KEY!)

export async function inviteMember(
  raw: unknown,
): Promise<{ ok: true; inviteId: string } | { ok: false; error: string }> {
  const session = await getServerSession()
  if (!session) return { ok: false, error: 'unauthorised' }

  const tenantId = await getActiveTenantId()
  if (!tenantId) return { ok: false, error: 'no active tenant' }

  // Only owners and admins can invite.
  const { data: actor } = await adminClient
    .from('members')
    .select('role')
    .eq('user_id', session.userId)
    .eq('tenant_id', tenantId)
    .single()
  if (!actor || !['owner', 'admin'].includes(actor.role)) {
    return { ok: false, error: 'permission denied' }
  }

  const parsed = Input.safeParse(raw)
  if (!parsed.success) return { ok: false, error: 'invalid input' }

  // Create the pending member row.
  const { data: invite, error: insertError } = await adminClient
    .from('members')
    .insert({
      tenant_id: tenantId,
      email: parsed.data.email,
      role: parsed.data.role,
      status: 'pending',
      invited_by: session.userId,
      invited_at: new Date().toISOString(),
    })
    .select('id')
    .single()
  if (insertError) return { ok: false, error: insertError.message }

  // Sign a short-lived token referencing the invite ID.
  const token = await new SignJWT({ inviteId: invite.id })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')
    .sign(INVITE_SECRET)

  // Record the audit event.
  await adminClient.from('audit_events').insert({
    tenant_id: tenantId,
    actor_user_id: session.userId,
    action: 'member.invited',
    resource_id: invite.id,
    metadata: { email: parsed.data.email, role: parsed.data.role },
  })

  // Send the email.
  await resend.emails.send({
    from: 'team@adamarant.com',
    to: parsed.data.email,
    subject: 'You are invited',
    html: renderInviteEmail({
      acceptUrl: `${process.env.SITE_URL}/invite/accept?token=${token}`,
    }),
  })

  revalidatePath('/team')
  return { ok: true, inviteId: invite.id }
}

function renderInviteEmail(opts: { acceptUrl: string }): string {
  return `<p>You have been invited to join a team.</p>
    <p><a href="${opts.acceptUrl}">Accept invitation</a></p>
    <p>The link expires in seven days.</p>`
}

4. The accept invitation handler

When the invited user clicks the email link, the handler verifies the token, links the existing user (or prompts signup), flips the pending row to active, and writes the audit event.

// app/[lang]/(public)/invite/accept/actions.ts
'use server'

import { jwtVerify } from 'jose'
import { redirect } from 'next/navigation'
import { adminClient } from '@/lib/supabase/admin'
import { getServerSession } from '@/lib/auth/server'

const INVITE_SECRET = new TextEncoder().encode(process.env.INVITE_SECRET!)

export async function acceptInvite(token: string): Promise<void> {
  let payload
  try {
    const verified = await jwtVerify(token, INVITE_SECRET)
    payload = verified.payload as { inviteId: string }
  } catch {
    redirect('/invite/expired')
  }

  const session = await getServerSession()
  if (!session) {
    redirect(`/login?return=/invite/accept?token=${token}`)
  }

  // Flip the pending member row to active, attaching the user ID.
  const { data: invite, error } = await adminClient
    .from('members')
    .update({
      user_id: session.userId,
      status: 'active',
      joined_at: new Date().toISOString(),
    })
    .eq('id', payload.inviteId)
    .eq('status', 'pending')
    .select('tenant_id, role')
    .single()
  if (error || !invite) redirect('/invite/expired')

  await adminClient.from('audit_events').insert({
    tenant_id: invite.tenant_id,
    actor_user_id: session.userId,
    action: 'member.joined',
    resource_id: payload.inviteId,
    metadata: { role: invite.role },
  })

  redirect('/dashboard')
}

5. What this composes

The active tenant cookie scopes every request. The middleware exposes it as a header. The Supabase client reads the header and scopes its queries. The invitation flow uses the same membership table the RLS policies delegate to, so a new member who accepts the invite is immediately authorised to read tenant data without any application-code update.

The architecture has three components: the membership join, the row-level security policies, the active tenant context. Once those three are in source control, the application code stops asking "is this user allowed to see this row" and starts asking "what is the most useful query I can write today". That is the productivity dividend of putting authorisation in the database.

Frequently asked questions

When should a SaaS go multi-tenant?

From the start, if there is any chance the second customer joins the product. Retrofitting tenant boundaries onto a single-tenant codebase is twice the work of building them in from day one. We design every new SaaS as multi-tenant unless the customer explicitly does not want it (rare; usually a niche internal tool).

One tenant per Stripe customer, or one Stripe customer per user?

One Stripe customer per tenant. Billing follows the team, not the individual. When a tenant has multiple members, the billing role decides who can manage billing; the Customer Portal opens for the active tenant only. This is also the only way to handle plan migrations cleanly when team membership changes.

How do you migrate a single-tenant app to multi-tenant?

Create a default tenant, backfill membership so every existing user joins it, enable RLS on every multi-tenant table behind a feature flag, run dual-track validation in staging, flip the flag in production. The migration takes one to three days for a small codebase; bigger codebases scale with the number of tables that need RLS.

Can users belong to multiple tenants?

Yes. The `members` table is a join, so the same user can be a member of many tenants with potentially different roles in each. The UI carries an active tenant switcher; the application code reads the active tenant from the session, not from the user.

How does row-level security perform at scale?

A well-indexed policy adds one to two milliseconds per query. The standard pattern (subquery against `members` keyed by `user_id`) needs an index on `members(user_id)`; we add it in the schema. Slow query analysis with `explain analyze` catches the rare case where a policy is doing too much work.

What about service-role writes that bypass RLS?

Service-role keys do bypass RLS by design (the migration scripts, the webhook handlers, the cron jobs need that). We restrict the service-role key to server-side code via the build configuration, and we audit every service-role write into the audit log. The application code never sees the service-role key.

How does the audit log meet compliance requirements?

Append-only via an RLS policy that blocks updates and deletes for anyone except the system. Every privileged Server Action writes one row before returning. The standard regulations (SOC 2, ISO 27001, GDPR Article 30) want an immutable trail; the table is queryable by the tenant admin, exportable by the customer.

Tell us about your multi-tenant SaaS

A scoping call, a concrete number in the first reply, no agency theater. Greenfield multi-tenant in four to eight weeks; migration from single-tenant scoped separately.