Security

Isolation is a database property.

Not a middleware, not a where clause someone remembered to write. Postgres Row Level Security scopes every row to the caller's tenant, so a bug in a component cannot leak data the component was never given.

01 — The model

One database, one tenant_id, one helper

Single-database, shared-schema multi-tenancy. Every table carries a tenant_id, and one security-definer function answers who is asking.

supabase/migrations/20260101000000_init.sql
create or replace function public.current_tenant_id()
returns uuid
language sql stable security definer
set search_path = public as $$
  select tenant_id from public.profiles
  where id = auth.uid();
$$;

It is security definer so it can read profiles to answer the question without recursively triggering RLS on profiles itself — the classic policy-recursion trap.

profiles.id is the auth user id. No join table, no mapping to keep in sync.

02 — A real policy

Read the rule, not the marketing

This is the actual notes policy, unedited. Tenant scoping and ownership are one expression the database evaluates on every single read.

supabase/migrations/20260101000000_init.sql
create policy "notes_select_owner_or_admin"
on public.notes for select using (
  tenant_id = public.current_tenant_id()
  and (
    user_id = auth.uid()
    or public.current_role() = 'admin'
  )
);
supabase/migrations/20260101000000_init.sql
create trigger on_auth_user_created
  after insert on auth.users
  for each row execute function
    public.handle_new_user();

-- Signup creates a tenant and makes
-- the registrant its admin.

Roles are member and admin. The definePageMeta({ roles }) gate on a page is UX — it decides what to show, never what can be read. The two live in different places on purpose.

03 — The proof

A claim that fails loudly

Every starter says multi-tenant. This one logs in as a second tenant and asserts the first tenant's rows are absent — from the list, from search, and from the SSR payload.

e2e/tenant-isolation.spec.ts
test('a Globex admin cannot see Acme notes', async ({ page }) => {
  await login(page, ADMIN2.email)

  await page.goto('/notes')
  await expect(page.getByText(ACME_SECRET_NOTE_TITLE)).toHaveCount(0)
})

test('the notes API returns no Acme rows for a Globex caller', async ({ page }) => {
  await login(page, ADMIN2.email)

  // Hit the data path the app uses and assert the secret title is nowhere
  // in the payload — isolation at the source, not just the rendered list.
  const body = await page.evaluate(() => fetch('/notes').then(r => r.text()))
  expect(body).not.toContain(ACME_SECRET_NOTE_TITLE)
})

It runs its own login rather than reusing the saved admin session, so it cannot accidentally pass by testing the wrong user. Widen a policy and this goes red.

04 — Above the database

The ordinary web attack surface, closed

RLS is the boundary that matters. It is not an excuse to ship without the rest.

Content Security Policy

Nonce-based script-src with strict-dynamic, per request. frame-ancestors none, base-uri self. Every external host the stack talks to is allowlisted per directive rather than wildcarded.

CSRF tokens

Double-submit with an httpOnly secret cookie on POST, PUT and PATCH, layered over SameSite=Lax auth cookies. App calls carry the token automatically.

Rate limiting

Upstash Redis where configured, in-memory otherwise — so a bare clone still limits, and a deployed one limits across instances.

Machine callers, explicitly

Webhooks and the Sentry tunnel are CSRF-exempt by route rule and verified by secret in the handler instead. Exemptions are a short, readable list.

05 — Known ceiling

Where this design stops scaling

Written in the migration itself, not discovered by you at 3am.

current_tenant_id() reads the caller's profile row on every policy evaluation. That is one indexed primary-key lookup, cached per statement — fine well past the point most products get to, and wrong to optimise before then.

When it does start to hurt, the upgrade path is already written down: move tenant_id into a custom JWT claim via a Supabase access-token hook and read it from auth.jwt(). Same call sites, same policies, no per-request lookup.

See how the rest of it fits together.

Ten layers, the request lifecycle, and the type pipeline behind it.

Read the architecture