Skip to content
RowShield
All posts

The tenant-isolation test: five queries that prove your boundaries

Five probes to audit your own Supabase project: anon reads, forged writes, cross-tenant access - each with its expected result and failure meaning.

RowShield12 min read

Tenant isolation is provable in minutes, not asserted in meetings. This article gives developers five concrete probes — runnable against their own project with their own keys — where every expected result is stated in advance, so passing or failing is never a matter of interpretation.

It is easy to believe your tenants are isolated because no customer has complained. That belief costs nothing until it's wrong, and when it's wrong it's expensive in the worst currency: someone else's data. The alternative is cheap enough to be embarrassing not to do — five requests, sent by you, against your own project, each with an unambiguous correct outcome. Run them once and you know your boundaries hold today. Wire them into CI and you know they held on every deploy since.

Everything here uses only resources you own: your project URL, your public anon key, and two test accounts you created yourself. Nothing below requires or produces privileged access to anyone else's system — it is the same evidence-gathering the RowShield free scan automates from outside, done by hand.

Why black-box probes at all, when you could read the policies? Because reading verifies intent while probing verifies outcome, and the distance between those is where every incident in this site's catalog lives. Policies are code written by humans and migrations under pressure; requests are truth. A policy review that says "only owners" and a probe that returns Bob's rows are not in disagreement — one of them is wrong, and it has never been the probe. The five tests below are also deliberately cheap: no test framework, no fixtures beyond two accounts, nothing to maintain except attention. Cheap enough that "we should really check that" stops being a reason to skip it.

Setup: two accounts and one honest baseline

Create two throwaway users through your own signup flow — call them Alice and Bob — and put each in their own workspace, project, or tenancy unit. Seed each with data you can recognize. Then establish the baseline both personas must agree on:

curl -s "https://YOUR-PROJECT.supabase.co/rest/v1/documents?select=id" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ALICE_JWT"

Alice should see exactly her fixture rows' IDs. If this baseline already surprises you — wrong count, missing rows, extra rows — stop here and investigate before running any negative tests, because everything downstream assumes you understand the happy path.

One more preparatory habit matters: capture responses with headers (curl -i) during your first run. Status codes carry half the diagnosis, and several of the five tests distinguish correct rejection from accidental success only by code.

The five probes

Test 1: the anonymous read

What it proves: private tables are invisible to the identity everyone holds — no session, just the public key.

curl -s "https://YOUR-PROJECT.supabase.co/rest/v1/documents" \
  -H "apikey: $ANON_KEY"

Expected: [] — an empty JSON array. (Or a 404-class error if the table isn't exposed at all, which is also a pass.)

If you see rows: the table has no effective select policy for anon — either RLS was never enabled, or policies simply don't mention the role. This is the single most common exposure our scans find, catalogued as anon-table-readable, and it applies to every column including ones your UI never displays. A variation worth running per table: repeat with select=id&limit=1 across your full table inventory rather than assuming the answer transfers between tables.

Test 2: the forged write

What it proves: a signed-in user cannot create rows attributed to someone else — the WITH CHECK clause working as designed.

curl -s -X POST "https://YOUR-PROJECT.supabase.co/rest/v1/documents" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ALICE_JWT" \
  -H "Content-Type: application/json" \
  -d '{"workspace_id": "'"${ALICE_WS}"'", "title": "forged", "owner_id": "'"${BOB_ID}"'"}'

Expected: an HTTP error — PostgREST surfaces Postgres's policy rejection (42501) as a 4xx, typically 403 — and no row appears afterward.

If the insert succeeds: your insert path accepts planted rows, and the asymmetry that makes this dangerous deserves emphasis: check whether Alice can read what she just wrote. If she can't (selects correctly filter to her own rows), the forged record sits invisible in Bob's scope — a planted object in another tenant's account that functional testing will never notice, because from the app's perspective nothing happened. This exact hole is the missing-WITH-CHECK signature.

Test 3: the cross-tenant read

What it proves: knowing another tenant's identifiers buys nothing. Identity-based policies must beat ID-based queries.

curl -s "https://YOUR-PROJECT.supabase.co/rest/v1/documents?id=eq.${BOB_DOC_ID}" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ALICE_JWT"

Expected: []. The row exists; Alice's request is well-formed; the policy must still refuse it.

If the row comes back: your policies trust something other than the caller's verified identity — often a client-supplied workspace_id parameter, or a policy comparing against the wrong column. Cross-tenant reads by identifier are the canonical multi-tenant leak, and unlike Test 2 they're silent in both directions: nothing errors, nothing is planted, data just crosses a boundary that your architecture diagram says exists. Run the same shape for every foreign key chain an outsider could guess: workspace IDs, slugs, sequential numbers.

Test 4: the ownership transfer

What it proves: users cannot hand rows out of their own scope — the update path's WITH CHECK holding the line on the resulting row.

curl -s -X PATCH \
  "https://YOUR-PROJECT.supabase.co/rest/v1/documents?id=eq.${ALICE_DOC_ID}" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ALICE_JWT" \
  -H "Content-Type: application/json" \
  -d '{"owner_id": "'"${BOB_ID}"'"}'

Expected: an HTTP error, and the row's owner unchanged when Alice reads it again.

If the update succeeds: Alice just moved her row into Bob's account — or worse shapes exist depending on which clause is missing. An update policy with only USING lets callers modify any row they can currently see into anything at all; the before-state is guarded while the after-state escapes. Our WITH CHECK deep-dive dissects why this half of update protection is the most commonly omitted clause in generated policy sets.

Test 5: the silent delete

What it proves: deletion respects the same boundaries reads do — and stays silent when it refuses.

curl -s -X DELETE \
  "https://YOUR-PROJECT.supabase.co/rest/v1/documents?id=eq.${BOB_DOC_ID}" \
  -H "apikey: $ANON_KEY" \
  -H "Authorization: Bearer $ALICE_JWT" \
  -H "Prefer: return=representation"

Expected: an empty response — zero rows matched, zero returned — followed by Bob confirming his document still exists.

If the row vanishes: delete policies are wider than your model claims. Note the trap that makes this test necessary rather than obvious: a refused delete doesn't error, it matches nothing, so application code that treats "no error" as "deleted" will report success all day long while the boundary holds — and report success equally loudly on the day it doesn't.

Reading failures like an engineer

Each failure maps to exactly one clause of one policy, which is what makes this suite diagnostic rather than merely alarming:

Test failingClause implicatedTypical fix
1 — anonymous readUSING on select policy (or RLS flag off)Enable RLS; write the anon decision deliberately
2 — forged writeWITH CHECK on insert policyAdd the ownership condition to the write check
3 — cross-tenant readUSING comparing wrong column or trusting inputCompare stored columns against (select auth.uid())
4 — ownership transferWITH CHECK on update policyConstrain the resulting row, not just the target
5 — silent deleteUSING on delete policyScope deletable rows by verified identity

Two status codes carry most of the diagnosis. 403 on writes means Postgres's policy rejection surfaced correctly — the database refused, your policies just need correcting. 200 where you expected refusal means the statement executed; no amount of application-layer filtering downstream compensates for that, because the write already happened. And an empty-array pass on reads is only meaningful alongside its negative: if Test 3 returns [] but so does Alice's own baseline query, you haven't proven isolation, you've broken reads — which is why the baseline in setup comes first.

Variations worth running

The five core probes generalize. Four variations extend coverage without new concepts:

Column probing. Repeat Test 1 with explicit sensitive columns — select=owner_id,email,total_cents rather than defaults. A table can be "empty" under default column selection while leaking precisely the fields that matter when requested by name, if any view or generated mapping exposes them differently.

The pagination walk. On any anon-readable-but-supposedly-limited surface, walk pages: ?select=id&limit=1000&offset=0, then offset=1000, and so on. Row-level limits enforced in application code evaporate at the API layer unless policies or PostgREST configuration enforce them server-side.

The storage sibling. Buckets have their own policy system; the equivalent probe is requesting an object URL without credentials and checking the response. A public bucket fails it exactly like a naked table — same test shape, different subsystem (the storage rule).

The second-tenant matrix. Once two accounts work, three reveal more: add Carol as a member of Alice's workspace and re-run Tests 3–5 expecting success through membership but failure through direct ID. Scale personas to match your model — one per distinct access level (owner, editor, viewer, admin, former member) — and the five tests stay identical per persona pair; only the number of baselines you record changes. Isolation is not secrecy from everyone — proving collaborators can reach shared data while outsiders can't is half the model, and it's the half teams test least.

Making it a habit

A suite that runs once is an audit; a suite that runs always is a boundary. Three scheduling rules keep these probes alive:

  1. After every migration that touches tables or policies, run all five against staging. This is where regressions get caught while their cause is still in review.
  2. On a schedule against production — monthly is defensible, weekly is better — because restores and dashboard edits don't travel through CI.
  3. With an owner. Unowned checks rot. The table above fits in a README; a name next to it fits in a standup.

Teams that outgrow hand-running wire the five requests into their existing test runner — each is a single HTTP call with an assertion, translatable to any stack in minutes. The investment converts isolation from an annual question into a continuous answer, which is the difference between hoping and knowing that the boundaries your product promises are the boundaries your database enforces.

Scoring the run

Five tests, binary outcomes, no judgment calls. Run them in order — each builds on the previous one's passing state, and the sequence takes less time than the coffee it pairs with:

#ProbePassFail means
1Anonymous read[]Table open to the world
2Forged writeRejected (4xx)Rows can be planted into others' scope
3Cross-tenant read[]Tenant boundary crossed by ID
4Ownership transferRejectedRows can leave their tenant
5Silent deleteZero effect, row intactDeletion crosses tenants

Any failure earns a specific conversation, not a general worry — each test maps to one policy clause, and the remediation is usually a single corrected statement. The in-database versions of these same checks (with exact SQLSTATE assertions and pgTAP wrappers) live in our testing guide, and the natural next step is wiring the whole set to run after every migration, so isolation regressions surface in pull requests instead of support tickets.

What these five don't cover

Honest scoping, again: the suite proves the API surface for two personas on the tables you thought to test. It doesn't cover Storage buckets (their own policy system, checked separately), Realtime channels, service-role paths, or tables nobody remembered to include. It also proves today: a migration next week can invalidate every green checkmark without touching your test file. That's the gap continuous monitoring exists to close — the five probes tell you where your boundaries stand right now; monitoring tells you whether they moved.

Common questions

Can't I just run these once in staging?

Staging answers them for staging. Environments diverge — different migration histories, manual fixes, restore accidents — and production is where exposure is real. Run first in staging during development, then run the identical set against production as its own event; divergence between the two results is itself a finding about environment drift.

Do I need real JWTs for the authenticated tests?

You need valid tokens for accounts you control, obtained through your own auth flow — which is deliberately part of the test, since token issuance is step one of the authorization chain. Expired or malformed tokens should fail with 401s; if they don't, that's a sixth finding worth having.

One of my tables is genuinely public-read. Should Test 1 fail there?

No — Test 1's expectation is per-table intent, not blanket secrecy. For genuinely public tables, the expectation shifts: anon may read published columns but must not read unpublished ones (check with a known-draft row) and must fail writes. Deliberate public access still deserves deliberate proofs; anonymous access done deliberately's cluster covers the design side.

We found a failure. Fix the policy or fix the feature?

Fix the policy, then fix whatever breaks. Every one of these failures means the database admitted something your product never promised anyone — features built on that admission were borrowing unowned access. The remediation SQL for each failure class is small and mechanical (the findings reference) includes worked examples, and the breakage it causes in your app is the sound of assumptions being paid off.

Should views be tested separately?

Yes — a view is another API surface with its own exposure semantics. Depending on Postgres version and the view's security_invoker setting, it can bypass underlying table policies entirely or inherit them; probe the view endpoint as anon and as Bob exactly as you would a table. The views guide covers which behavior your project's views have.


Run the free scan on your own project — paste your app URL, and see your anonymous surface as the outside world sees it, with proposed remediation for every finding.

RowShield is an independent product and is not affiliated with, endorsed by, or sponsored by Supabase, Inc.