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.
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.
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'
)
);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.
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.