User A knows a project ID from tenant B. The interface never shows it, but the browser can still request it. A policy screenshot is not the boundary. The rejected request is.
Write the hostile requests first
Use two users, two organisations and one row owned by each organisation. Exercise the same publishable key, signed-in session, SDK or REST path and server route that the application will use.
| Actor and request | Expected boundary | What a failure would expose |
|---|---|---|
| Anonymous user lists projects | No tenant rows | Public grant, policy, view or endpoint is broader than intended. |
| User A lists projects | Only organisation A rows | Tenant filter or membership rule is missing. |
| User A requests project B by known ID | No B row | Direct-ID path crosses the boundary. |
| User A inserts a project with organisation B ID | Write rejected | The proposed row is not checked against membership. |
| User A moves project A to organisation B | Update rejected | The old row is checked but the new tenant value is not. |
| User B updates project A | No change | Cross-tenant write is possible. |
| Privileged server job reads both tenants | Only when the path is intentionally privileged | A bypass role is being mistaken for a tenant-scoped session. |
A minimal fictional data model
Illustrative schema choice: users may belong to several organisations; every tenant-owned project row carries organization_id. That is one model, not a Supabase requirement.
organizations(id, name)
memberships(organization_id, user_id, role)
projects(id, organization_id, name, created_by)
foreign keys:
memberships.organization_id -> organizations.id
memberships.user_id -> auth.users.id
projects.organization_id -> organizations.id
projects.created_by -> auth.users.id
Index policy columns in the teaching schema, then measure representative queries. An index does not establish acceptable performance, and a documentation benchmark does not transfer to your data shape.
Match each policy to a rejected request
Supabase documentation observed 28 July 2026 says RLS must be enabled on exposed tables and that tables created through raw SQL need explicit enabling. Supabase and PostgreSQL distinguish USING, which governs visibility of existing rows, from WITH CHECK, which governs proposed rows for inserts and updates.
-- Teaching shape only; not compiled or tested here.
alter table public.projects enable row level security;
create policy "member reads own organisation projects"
on public.projects for select to authenticated
using (
exists (
select 1 from public.memberships m
where m.organization_id = projects.organization_id
and m.user_id = (select auth.uid())
)
);
create policy "member creates inside own organisation"
on public.projects for insert to authenticated
with check (
created_by = (select auth.uid())
and exists (
select 1 from public.memberships m
where m.organization_id = projects.organization_id
and m.user_id = (select auth.uid())
)
);
The missing update, membership-management, delete and other policies are deliberate evidence boundaries—not implied implementation. The real migration must record Supabase CLI and PostgreSQL versions, grants, owners and every exposed schema.
Test the paths that can bypass the neat policy story
- Service keys and server roles: Supabase documents that service keys can bypass RLS and must not be exposed in the browser. Test the actual authorization header and database role on each path.
- Views: views may run with definer behaviour; Supabase documents
security_invoker = truefor supported PostgreSQL versions. Verify version, owner and client exposure. - Owners, superusers and
BYPASSRLS: PostgreSQL documents privileged roles that normally bypass RLS. Put intentional privilege in the test matrix. - Storage, functions and server endpoints: table policies do not automatically prove these paths tenant-safe.
- Session replacement: SDK configuration can change which user token or service credential reaches the database. Capture the effective role and token source.
Provider capabilities can change with an SDK, platform or environment update. Check the current Supabase documentation for the deployed configuration, and align PostgreSQL-specific conclusions to the version actually running.
Build the evidence harness
Supabase’s testing guidance documents pgTAP for RLS and also recommends application-level and negative tests. Use both. Database tests isolate policy behaviour; application tests catch session, wrapper, view and server-route mistakes.
begin;
select plan(5);
set local role authenticated;
set local request.jwt.claim.sub = :'user_a_id';
-- Implement and execute assertions for:
-- A lists only A rows
-- A cannot select B by known ID
-- A cannot insert into B
-- A cannot move a row from A to B
-- A can perform the one intended same-tenant update
select * from finish();
rollback;
These comments are not test results. Save the migration, fixtures, command, environment versions and output together. Then repeat through the application with real signed-in sessions and the production-shaped route.
Prove denial before adding convenience features
Ignore policy elegance, dashboard screenshots and performance tuning claims. Do not add invitations, billing roles or storage policies to make the example look complete. First make a foreign read and foreign write fail in the smallest real path. Expand the matrix only after that result is reproducible.
The tenant-boundary evidence gate
| Required evidence | What must be preserved |
|---|---|
| Versioned migration | Supabase CLI, PostgreSQL version, schema, grants, owners and policies. |
| Two-tenant fixtures | User A/org A and user B/org B with known foreign row IDs. |
| Database negatives | Read, insert, move, update, anonymous, view and privileged-path results. |
| Application negatives | The same denials through the actual publishable key, session and server routes. |
| Domain review | Tested paths, intentional bypasses and everything still outside scope. |
Test the awkward path
“User A can read project A” is a comforting green check and weak boundary evidence. The boundary lives in the red checks: User A knows project B’s ID, changes the organisation field, reaches a view or triggers a privileged job.
No tenant data before the denials run
Do not treat this teaching SQL as a backend template. Create the smallest real migration and two-tenant fixtures, then make the seven hostile requests fail through database and application paths. Until those tests run and receive domain review, keep real multi-tenant customer data out.
Sources for the tenant boundary
- Supabase, Row Level Security, observed 28 July 2026; confirm the current behaviour against the deployed configuration.
- Supabase, Testing overview, observed 28 July 2026; no test result is implied.
- Supabase, RLS performance and best practices, observed 28 July 2026; measure the actual schema and workload.
- PostgreSQL 18, Row security policies, observed 28 July 2026; replace version 18 assumptions with the deployed version.
