RowShield
Guides

Can tenant A read tenant B? A test you can run tonight

For B2B software the nightmare query is simple: tenant A’s finance lead opening tenant B’s invoices. Everything else — uptime, polish, pricing — recovers from bad weeks. A cross-tenant read does not, and on a Supabase backend the question has a precise, testable answer: what does the database return when a signed-in caller asks for rows whose tenant is not theirs?

Teams rarely test this deliberately. Access looks right through the interface, because the interface only renders the caller’s own workspace; the database, however, answers whoever asks. A leak can hide for quarters behind a friendly screen, which is why the test below talks straight to Postgres wearing a forged identity — the same way an attacker would.

Part of the answer is automatable. The ANON_TABLE_READABLE rule in RowShield continuously probes the anonymous path, which forms the outermost ring of the onion, and this page layers the authenticated cross-tenant rehearsal that you run by hand carefully on top of that automated base.

Rules that check this

Ring one: the anonymous caller

Before impersonating users, close the trivial case. With the project URL and anon key, request the documents table cold: rows returned means the table fences nobody; a permission refusal means policies engaged; an empty array is ambiguous and worth revisiting with seeded data. This is exactly the probe RowShield industrialises under ANON_TABLE_READABLE — GET requests only, private addresses refused, repeated on schedule so drift surfaces as alerts.

Why automate the boring ring? Because it regresses silently. A migration recreates a table without policies, the app keeps working, and nobody re-runs the manual test from March. Continuous behavioural checking converts that drift into a notification within the scan interval rather than a discovery during a customer’s security questionnaire.

Ring two: forge an authenticated identity

Postgres lets you rehearse a signed-in request entirely inside a transaction: set the role to authenticated and plant the JWT claims the helper functions would normally supply from a real token. That session now experiences exactly what a member of tenant A would — no application code, no frontend, only the policy verdict:

BEGIN;
SET LOCAL ROLE authenticated;
SET LOCAL request.jwt.claims =
  '{"sub":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
    "role":"authenticated",
    "org_id":"org-1"}';

SELECT count(*) FROM public.documents;              -- expect org-1 rows only

UPDATE public.documents SET title = 'x'
WHERE organisation_id = 'org-2';                    -- expect zero rows affected

ROLLBACK;

Reading a failing policy

Read the results strictly. Any foreign-tenant row in the first query, or any affected row in the second, is a finding with a name and a policy to repair. Repeat with a second identity from another tenant, then again with malformed payloads — an empty claims object, an unknown subject — because sloppy policies tend to admit exactly those shapes.

Failures cluster around few causes: a predicate referencing a column the token does not carry, so the comparison nulls out; an unwrapped helper evaluated per row and compared against the wrong field; or an OR chain added for one integration admitting more than intended. Each leaves a characteristic signature above, and none survives a careful read of the policy text once suspected.

Cadence and cover

The repair for the documents table follows the membership pattern — traversal through a link table, wrapped uid, separate grants per action — and then the rehearsal reruns until the transaction returns exactly the tenant’s own counts. Keep the forged-identity script beside the migrations; it doubles as regression armour for future policy changes.

Boundaries, stated plainly: the manual cross-tenant ring needs a human crafting identities and interpreting verdicts — RowShield does not automate that part today. Between releases it holds the line instead: scheduled scans snapshot anonymous reachability per table and alert on transitions — daily on Free, hourly on Indie, every fifteen minutes on Team. Reads touch catalogue metadata only, probes stay GET-only, and RowShield is an independent product unaffiliated with Supabase.

Frequently asked

Why SET ROLE instead of testing through the app?
The application layer can mask database truth — caching, post-fetch filtering, or a helpful default workspace all disguise what the policy actually allows. Setting the role and claims inside a transaction interrogates the policy directly, which is the level where a tenant leak lives and dies.
The empty-array case: pass or fail?
Neither, on its own. Empty can mean correctly filtered or genuinely no rows, so seed a row you know the identity should see and rerun. Seeing the seeded row, and only it, converts ambiguity into a positive result — the difference between absence of evidence and evidence of correctness.
What about the service role key in tests?
Leave it out entirely. The service role bypasses row security, so any test using it measures nothing about tenant isolation and risks conditioning the team to reach for it casually. Test with the same two credentials production callers use: anon and authenticated.

Check your project in about ten seconds

Paste a URL. No signup, no writes, nothing stored.

Run the free audit
supabase tenant isolation testb2b saas rls testingrequest.jwt.claims test postgrescross tenant leak check