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 requestExpected boundaryWhat a failure would expose
Anonymous user lists projectsNo tenant rowsPublic grant, policy, view or endpoint is broader than intended.
User A lists projectsOnly organisation A rowsTenant filter or membership rule is missing.
User A requests project B by known IDNo B rowDirect-ID path crosses the boundary.
User A inserts a project with organisation B IDWrite rejectedThe proposed row is not checked against membership.
User A moves project A to organisation BUpdate rejectedThe old row is checked but the new tenant value is not.
User B updates project ANo changeCross-tenant write is possible.
Privileged server job reads both tenantsOnly when the path is intentionally privilegedA 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 = true for 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 evidenceWhat must be preserved
Versioned migrationSupabase CLI, PostgreSQL version, schema, grants, owners and policies.
Two-tenant fixturesUser A/org A and user B/org B with known foreign row IDs.
Database negativesRead, insert, move, update, anonymous, view and privileged-path results.
Application negativesThe same denials through the actual publishable key, session and server routes.
Domain reviewTested 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