How shared-table multi-tenancy leaks in SvelteKit, the three ways to isolate tenants, and why Postgres RLS is the one mechanism that isn't a discipline.
There are three ways to put more than one customer in one database: a database
per tenant, a schema per tenant, or a shared table with a tenant column. For
most B2B products the third is the right answer, and it is the one that makes
you responsible for isolation in application code.
This is the design I keep coming back to, the failure modes it has, and how to
test that it actually holds.
The shape
Three tables carry almost all of it:
-
organizations —
id,name,created_at,slug -
memberships —
org_id,user_id,role,created_at -
everything else —
org_idplus whatever the feature needs
The rule that makes this work: org_id appears on every tenant-scoped table,
and it is the first column of every index that starts with a foreign key. The
index ordering is not a detail. A query filtered on org_id and created_at
that has an index on (created_at) alone will still be correct, and it will
still read the whole table to find your tenants' rows.
Two consequences worth stating up front:
-
A user can belong to many orgs, so a user is never the tenant key. If you
find yourself writing
user_idinto a scopingWHERE, you have built a single-tenant app that happens to have a login form. -
Membership is the authority, not the session. The session says who you
are; the
membershipsrow says what you can reach. Resolve membership per request and treat everything downstream as already authorised.
This is the schema I use in the Postgres kit I maintain, with the Postgres
specifics — RLS as a second layer, pooling, and why the org_id index is not
optional.
The shared-table model itself is not vendor-specific; the isolation strategy is
the part that changes.
How it leaks
Isolation is a rule applied in query construction, and anything that does not
go through your query helper is a second implementation of that rule. The leaks
I have actually seen, in rough order of frequency:
1. The detail route that forgot the filter.
Index routes filter by org_id because you wrote them together. The detail
route takes a row id, and if it selects by primary key alone it returns another
tenant's row. This is the most common one by a wide margin, and it is also the
one that survives code review, because the route looks obviously correct.
2. The second query builder.
A search, an export, a report, an admin view — each of these reaches for the
raw client because the helper does not quite fit. Each is now a place org_id
can be omitted. Count them: grep for direct selects and count the ones that do
not go through the scoped helper.
3. The job with no request context.
A background task runs as the system. It has no org_id in scope, so it looks
up "the" row, and it does so without knowing which tenant asked for the work.
If the job is triggered by an org-scoped event, the tenant identity has to be
carried in the payload explicitly — not re-derived.
4. The one that is not a bug.
Under row-level security, every connection must carry a tenant setting, and
with a pooled connection pool that setting has to be set on checkout and
cleared on return. Miss the clear and the next request inherits it. This one
only appears in production, at volume, and it is why I treat RLS as
defense-in-depth rather than as the primary mechanism — the application filter
is the thing you can test; the pool behaviour is the thing you can get subtly
wrong.
Testing it, which is the part everyone skips
An isolation bug is the easiest kind of bug to write and the hardest to find,
because the happy path works. The test that catches it is boring and has to
run every time:
- Seed two organizations with overlapping data — same-ish names, similar timestamps, so an unfiltered query returns the wrong row rather than an empty one.
- For every read path, assert that tenant A gets exactly its own rows. Assert the count, not the absence of a crash.
- For every id-addressable route, request a resource belonging to tenant B while authenticated as tenant A, and assert 404 or 403. Both are acceptable; returning the row is not.
- For every write path, assert tenant A cannot mutate tenant B's row even when it knows the id.
That last group is the one that earns its keep. Reads fail loudly in review;
writes fail silently in production.
In the three kits I maintain this is a declared count enforced in CI rather
than a convention — the declarations and the actual test files are compared on
every run, so the count cannot drift. The number itself is not the point;
what matters is that the comparison exists and fails when they disagree.
What to index
The minimum that keeps queries fast as tenants accumulate:
-
(org_id, created_at DESC)on every listable table -
(org_id, user_id)on memberships, with a unique constraint — this is what makes a double-accept of the same invite fail in the database rather than in application code -
org_idas a foreign key with a delete rule you have actually chosen.ON DELETE CASCADEon an audit log is a decision, and usually the wrong one.
Where to start
Shared tables, org_id everywhere, index leading with org_id, membership as
the authority. Then write the two-tenant test before you write the second
feature, because every feature you add is a new place to forget.
Top comments (4)
RLS being “the one mechanism that isn’t a discipline” is the right way to put it, and there’s one pooling detail that decides whether it actually holds in a request-per-connection backend. The value RLS reads (a tenant GUC, or an identity) has to be scoped to the transaction, not the session. If you set it with
SET(orSET SESSION) on a connection you later return to the pool, the value survives the checkout, and the next request that gets that backend can inherit the previous tenant — a leak that is invisible in dev and rare under load. Two rules close it: set it transaction-scoped (set_config('app.current_tenant', $1, true)insideBEGIN ... COMMIT, orSET LOCALinside a transaction block), and on release runRESET ALL/DISCARD ALLso a connection can never carry a tenant back. Two related gotchas:SET x = $1takes no bind parameter at all — it is a syntax error,set_config()is the parameterized form — andSET LOCALoutside a transaction is a no-op with a warning. To prove the isolation the article promises, a two-account probe that asserts account B sees zero of account A’s rows, with the first transaction held open, is the test that catches this: github.com/cekuu35/supabase-rls-le...You're right, and checking it turned up something worse than what you flagged.
First: our docs shipped exactly the broken form you predicted.
rls/0010_rls_policies.sql,docs/architecture.mdanddocs/deployment.mdall told buyers to wire the GUC withSET LOCAL app.current_user_id = ${...}— which is your gotcha #1, a syntax error the moment a driver binds the parameter. And the one-line form omitted the scope argument entirely, so nothing in the kit explained why transaction-scoping is load-bearing rather than stylistic.But writing your probe surfaced a worse bug, which I didn't expect: the shipped policies couldn't execute at all.
memberships_selectis gated byapp_is_org_member(org_id), andapp_is_org_member()readsmemberships. As written it wasSECURITY INVOKER, so evaluating the policy re-entered the policy:Every tenant-scoped read errored out. Loud rather than silent — but it's the exact feature the kit sells as its Postgres differentiator, and it survived this long because RLS is opt-in: nothing in the kit, the demo, or the 451-test suite ever applied the file.
Fixed with
SECURITY DEFINER+SET search_path = public, pg_tempon the three helpers that readmemberships(app_current_user()reads only the GUC, so it stays invoker-scoped). One consequence worth passing on:SECURITY DEFINERonly escapes the recursion if the role that owns the function is exempt from RLS. The old instructions applied the file as the app role — which is subject to the very policies it creates — so following them brings the recursion straight back.HOW TO APPLYnow says apply as the table owner.Your probe is in the suite as
tests/rls-isolation.test.ts— 11 tests, real Postgres, against a genuine non-superuser non-owner role, becauseTEST_DATABASE_URLconnects as a superuser and bypasses RLS unconditionally, which would make every assertion vacuous. It holds A's transaction open and asserts B sees nothing. And it also pins the inverse: the session-scoped form does leak on a reused pooled backend while the transaction-scoped form doesn't, same pool shape, same order — without that pair, a check that set nothing at all would also pass.On your last point, agreed that single-connection dev is exactly why this hides.
tests/pool-options.test.tsalready documented the identical hazard forSET search_path, and nobody carried it across to the RLS docs.Shipped in v0.1.15. Thanks — the two-account framing is what turned "the docs look thin" into a regression test, and the leak demo is a clearer write-up of the pool behaviour than I'd have managed.
Thanks for turning it into a shipped fix and, more usefully, a regression test — the recursive SECURITY INVOKER case is a nastier bug than the pool one, because it fails loudly only where it is exercised, and RLS being opt-in is exactly why nothing caught it. One detail worth pinning for anyone copying the pattern: SECURITY DEFINER only escapes the recursion if the owning role is exempt from RLS, so applying the helpers as the table owner (not the app role) is load-bearing, not stylistic — as the app role it re-introduces the recursion silently. The paired pool test is the part I'd steal: without the session-scoped leak assertion, a check that set nothing at all would also pass. Glad v0.1.15 landed.
You're right about the principle, and following your comment found that our docs still got the remedy wrong. v0.1.15 told buyers to apply the file as the table owner — but that same file runs
ALTER TABLE … FORCE ROW LEVEL SECURITY, andFORCEremoves the table owner's exemption. A plain owner is subject to the very policies it creates, so following the instruction as written brought the recursion straight back. Tested on Postgres 16: same table, same owner, one row —ENABLEalone lets the owner read it,ENABLE+FORCEreturns nothing. Corrected in v0.1.16: the instruction now asks for a role withBYPASSRLS(or a superuser), and there is a regression test that fails if it drifts back. Thanks for pushing on exactly the part I was about to leave alone.