Supabase Multi-Tenant RLS: How to Audit Tenant Isolation Before Launch
A multi-tenant Supabase app is not ready for production merely because Row Level Security (RLS) is enabled. You need to prove that a user in Organisation A cannot read, create, change or delete Organisation B's data, including through membership changes, privileged functions, views, file storage and backend endpoints. A policy that passes a happy-path test can still leave another route open.
The practical answer is a tenant-isolation audit: define who may perform each operation, test both allowed and forbidden actions using real user roles, inspect every elevated-access boundary, and run the checks in CI. This guide gives SaaS founders and engineering teams a test matrix, a minimal policy pattern, the failure modes that matter and a release gate they can actually use.
What does tenant isolation mean in Supabase?
Tenant isolation means that a user's authorisation is evaluated against the organisation that owns the requested resource, not merely against whether the user is signed in. If Alice belongs to Company A, a guessed project ID from Company B must not let her retrieve or modify Company B's project.
Supabase uses PostgreSQL RLS to enforce row-level rules. Database grants and RLS policies are separate checks: grants determine which operations a role can attempt; policies determine which rows pass. Supabase's RLS documentation explicitly recommends reviewing both and testing policies for each operation.
A frontend filter such as __INLINE_CODE_0__ is useful for product behaviour but is not an authorisation boundary. A user can modify a request or call the Data API directly. The database must independently establish membership and permissions.
The six boundaries your audit must cover
| Boundary | Question to prove | Typical failure |
|---|---|---|
| Data API tables | Can a member access only rows in their permitted tenants? | A broad __INLINE_CODE_0__ policy reveals another organisation's records |
| Inserts and updates | Can a member create or move a row into another tenant? | __INLINE_CODE_0__ is missing or allows tenant reassignment |
| Membership and invitations | Who can add members, change roles or accept invitations? | A user inserts their own membership or promotes themselves |
| RPC functions and server code | Does every privileged operation verify the target tenant? | A service key or __INLINE_CODE_0__ function bypasses RLS |
| Views and file storage | Are derived rows and attachments isolated too? | A definer view or permissive Storage policy exposes data |
| Lifecycle and CI | Does access disappear after removal, and do tests catch regressions? | A revoked member retains access through a stale token or untested path |
This is an engineering audit, not a compliance certification. Its purpose is to produce reproducible evidence that the access rules your product promises are the rules the system actually enforces.
How should organisation membership be modelled?
For a conventional B2B SaaS product, model membership as server-controlled data. A user can belong to more than one tenant; the currently selected tenant in the UI is not proof of membership. A common relational model is:
tenants (id, name)
tenant_members (tenant_id, user_id, role)
projects (id, tenant_id, name)
invitations (tenant_id, email, token_hash, expires_at, accepted_at)Every tenant-owned business table needs a reliable tenant relationship. Prefer a real foreign key where practical. Treat __INLINE_CODE_0__ and invitation acceptance as security-critical workflows: only authorised owners/admins or carefully validated backend functions should change membership or roles. Do not allow a client to assign itself __INLINE_CODE_1__ simply because its __INLINE_CODE_2__ matches the signed-in user.
Avoid storing the sole source of authorisation in user-editable JWT __INLINE_CODE_0__. Supabase warns that end users can modify that field. Server-managed __INLINE_CODE_1__ is safer for claims, but claims can remain stale until the JWT refreshes; use database membership checks when immediate revocation matters. See Supabase's RLS guidance.
What does a basic tenant-scoped RLS policy look like?
This simplified pattern grants signed-in members read access to projects in organisations they belong to, and lets members with an appropriate role create projects. It is a starting point to adapt and test, not a complete production schema or a replacement for invitation and role-management rules.
-- Assume tenants, tenant_members and projects already exist.
-- tenant_members: tenant_id uuid, user_id uuid, role text
-- projects: id uuid, tenant_id uuid, name text
alter table public.tenant_members enable row level security;
alter table public.projects enable row level security;
revoke all on public.tenant_members from anon, authenticated;
revoke all on public.projects from anon, authenticated;
grant select on public.tenant_members to authenticated;
grant select, insert on public.projects to authenticated;
-- A member can see their own membership records.
create policy "read own memberships"
on public.tenant_members for select to authenticated
using (user_id = (select auth.uid()));
-- A project is visible only to a member of its tenant.
create policy "members read tenant projects"
on public.projects for select to authenticated
using (
exists (
select 1 from public.tenant_members m
where m.tenant_id = projects.tenant_id
and m.user_id = (select auth.uid())
)
);
-- Only designated roles can create a project in their tenant.
create policy "editors create tenant projects"
on public.projects for insert to authenticated
with check (
exists (
select 1 from public.tenant_members m
where m.tenant_id = projects.tenant_id
and m.user_id = (select auth.uid())
and m.role in ('owner', 'admin', 'editor')
)
);Notice what this deliberately does not grant: clients cannot directly insert or edit memberships, and they cannot update or delete projects. Those actions require separate, reviewed rules before being enabled. If you add project updates, use both __INLINE_CODE_0__ and __INLINE_CODE_1__, and separately prevent unintended changes to __INLINE_CODE_2__; membership in two tenants should not automatically authorise moving a record between them.
If the membership table is itself protected by more complex RLS, policy subqueries can create recursive dependencies. Supabase documents carefully scoped __INLINE_CODE_0__ helper functions for this case, but those functions require their own privilege and tenant-parameter review. Do not solve a recursion error by granting everyone broad membership visibility.
Which tests actually prove tenant isolation?
Build a fixture with at least two organisations, two members of different organisations, a user who belongs to both, a read-only member, an administrator and an unauthenticated caller. Test through the same authenticated role or client your application uses, not only as the database owner.
| Test | Expected result |
|---|---|
| Member A reads a known A project ID | Allowed |
| Member A reads a known B project ID | No B row returned |
| Member A lists projects without a tenant filter | Only projects from permitted tenants |
| Member A inserts a project with B's __INLINE_CODE_0__ | Rejected |
| Viewer A inserts or deletes an A project | Rejected |
| Member A updates an A project's __INLINE_CODE_0__ to B | Rejected, including when they belong to both |
| Member A creates a membership in B or changes their role to owner | Rejected |
| Revoked member requests A data again | Rejected after the chosen revocation mechanism takes effect |
| Unauthenticated caller queries private data | Rejected or returns no rows |
| A user invokes every tenant-targeted RPC with B's ID | Rejected |
| A user requests B's file or report through an API/view | Rejected |
Test positive and negative cases together. An application that denies every request is not secure enough to ship; it is broken. Equally, a test that merely checks __INLINE_CODE_0__ exists does not prove a policy's behaviour. Supabase's pgTAP testing guide recommends asserting actual allow/deny outcomes, including cross-user attempts, and checking that a deliberately broadened policy causes a test to fail.
A practical end-to-end test through the client
Using your test project's signed-in user sessions, make the requests that an attacker could make. This TypeScript sketch assumes __INLINE_CODE_0__ is already authenticated as a user who belongs only to Tenant A, and that __INLINE_CODE_1__ and __INLINE_CODE_2__ come from fixtures:
// Test setup must use the real authenticated client role,
// not a service/secret key that bypasses RLS.
const { data: leaked, error: readError } = await clientA
.from('projects')
.select('id, tenant_id')
.eq('id', projectBId);
if (readError) throw readError;
if (leaked?.length !== 0) throw new Error('Cross-tenant read exposed a row');
const { error: insertError } = await clientA
.from('projects')
.insert({ tenant_id: tenantBId, name: 'Forbidden project' });
if (!insertError) throw new Error('Cross-tenant insert was accepted');A forbidden __INLINE_CODE_0__ commonly returns an empty result, whereas a forbidden insert commonly raises an RLS error. The precise error may depend on grants and the operation; assert the security outcome rather than hard-coding a single message. Add the allowed A-side requests to prove the fixtures and permissions are valid.
For SQL-level tests, Supabase supports pgTAP files under __INLINE_CODE_0__ and the command __INLINE_CODE_1__. Its advanced testing guide includes a multi-tenant example with organisation roles and plan restrictions. Run both database tests and real-client tests in CI: they catch different classes of mistakes.
Why can RLS pass while a tenant leak still exists?
RLS protects operations executed under roles subject to it. A privileged route can sit outside that boundary. PostgreSQL documents that superusers and __INLINE_CODE_0__ roles bypass row security, while table owners normally bypass it unless forced otherwise. See PostgreSQL's row security documentation.
Audit these cases explicitly:
- Secret/service-role clients: Supabase secret keys bypass RLS when used for privileged access. Keep them on trusted servers and validate the requesting user's tenant membership before acting. Never put one in a browser or a __INLINE_CODE_0__ environment variable. Supabase security guidance explains the client/server boundary.
- __INLINE_CODE_0__ functions: These execute with the function owner's privileges. An RPC that accepts a __INLINE_CODE_1__ must check that the caller may act on that tenant. Keep definer functions outside exposed schemas where possible, pin __INLINE_CODE_2__, schema-qualify objects and review __INLINE_CODE_3__ grants. Test every callable function signature, including obsolete overloads.
- Views: Views may run with the creator's privileges and expose rows hidden by base-table policies. PostgreSQL 15+ supports __INLINE_CODE_0__ for views that should honour the caller's underlying permissions; older deployments need tighter exposure controls. See Supabase's RLS guide.
- Permissive policy combinations: Multiple permissive policies are combined with __INLINE_CODE_0__. One broad policy can undo the intended isolation of another. PostgreSQL also supports restrictive policies combined with __INLINE_CODE_1__, but these require careful design. PostgreSQL documentation describes the combination rules.
- File storage: Supabase Storage uses separate policies on __INLINE_CODE_0__. A protected database row does not automatically protect its attachment. Check bucket visibility, listing, downloads, uploads, overwrites and signed-URL issuance. See Storage access control.
For a privileged background job, the correct question is not "does it use a service key?" It is "who authorised this job, what tenant scope did it receive, and can its inputs or output cross that scope?" Audit queues, scheduled jobs, exports and webhook handlers as carefully as browser-facing endpoints.
What should be in the release gate?
Use a short evidence-backed checklist rather than an informal statement that "RLS is configured":
- Inventory: Every tenant-owned table, view, Storage bucket, RPC and backend endpoint has an identified owner and access rule.
- Grants and policies: Exposed tables have RLS enabled; __INLINE_CODE_0__ and __INLINE_CODE_1__ have only required privileges; every operation has explicit allow/deny tests.
- Membership: Ordinary users cannot create their own access, promote themselves, hijack invitations or transfer records across tenants.
- Negative tests: Cross-tenant IDs are deliberately supplied to reads, writes, exports, uploads and privileged operations; tests fail if access is granted.
- Lifecycle: Removal, suspension, invitation expiry and role changes are tested with old and refreshed sessions. JWT-based authorisation has an explicit freshness policy.
- Performance: Policy-filtered columns, including __INLINE_CODE_0__ and membership lookup keys, are indexed appropriately. Verify query plans with realistic data, rather than assuming a secure policy is automatically fast.
- CI and change review: Migrations run policy tests before deployment; a policy change or new exposed endpoint cannot merge without its access matrix and regression tests.
For a useful final check, temporarily broaden a test-environment policy and verify that the negative suite turns red. Restore the policy immediately. If the suite stays green, it is not exercising the boundary you think it is. Supabase recommends this mutation-style check in its RLS testing guide.
When is shared-database RLS the wrong tenancy model?
A shared PostgreSQL database with RLS can be an effective fit for SaaS products with many small and medium tenants, provided access controls are consistently enforced and tested. But RLS is not a substitute for every isolation requirement.
Consider separate databases or more isolated deployments when a customer contract requires infrastructure separation, tenant-specific residency or backup/restore boundaries, or when one tenant's workload must not contend with another's. Those approaches add deployment, migration, monitoring and cost complexity. The decision belongs in architecture discovery, not after enterprise customers begin asking for isolation guarantees.
For teams planning a custom SaaS platform, tenant boundaries should be designed alongside billing, roles, audit logs and operational support. The database model is a product decision as much as a SQL decision.
FAQs
Does enabling RLS on every table guarantee multi-tenant security?
No. It is necessary for many exposed tables, but grants, policies, privileged functions, views, Storage and server-side access can still create cross-tenant paths. Test the whole request path.
Can I rely on a tenant ID stored in the user's JWT?
Only with a clear trust and refresh model. Do not use user-editable metadata for authorisation. Server-managed claims can be useful, but they may be stale after membership removal; database membership checks are often better for immediate revocation.
Should every Supabase RLS test run with a service-role key?
No. A service/secret key can bypass RLS, so tests of user isolation must use the actual __INLINE_CODE_0__ or __INLINE_CODE_1__ roles and user sessions. Reserve privileged clients for test-fixture setup and administrative paths.
What is the most dangerous membership-table mistake?
Allowing a user to add themselves to another tenant or change their role to an administrator. Every other policy that trusts membership may then grant unintended access. Test membership mutations before testing dependent business tables.
Is one pgTAP test suite enough?
Not by itself. pgTAP is valuable for database policy behaviour; client-level integration tests additionally exercise authentication, API grants, backend functions and the request path your users actually take.
If you are preparing a multi-tenant MVP or reviewing an existing Supabase backend, contact Softotic with your tenant model, membership rules and highest-risk workflows. A focused web application architecture review can turn those requirements into an access matrix and a testable release plan without turning the audit into a sales pitch.