Skip to content

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

Tell us about your multi-tenant SaaS

Greenfield multi-tenant in four to eight weeks; migration from single-tenant scoped separately.