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.