A signed-in user knows another organization's project ID. Can they read it, edit it or move their own project into that organization? Hiding the project in the interface answers none of these questions.

The fictional model below specifies successful work, filtered foreign rows and rejected writes. This is an unexecuted request specification: no SQL, migration, test suite or Supabase request has been run. It does not demonstrate tenant isolation.

Give each request a known starting point

Fictional fixture: Aster is org-A; Birch is org-B. These labels are not runnable UUIDs, user accounts or JWTs. A technical owner must allocate real test identities and retain the mapping in an isolated environment.

  • user-A has one membership: organization_id=org-A, role=member.
  • user-B has one membership: organization_id=org-B, role=member.
  • project-A has organization_id=org-A, name=Roof, created_by=user-A.
  • project-B has organization_id=org-B, name=Door, created_by=user-B.
  • project-C does not exist. Insert cases propose name=Window and specify its organization and creator.

A separate trusted administrator resets names, memberships and the absence of project-C independently before every case. Neither tenant user gets that authority. Without a reset, an earlier insertion could make a later case fail on a duplicate ID rather than authorization.

Preconditions behind the expected results

These are design assumptions to implement and inspect, not settings already applied:

  1. RLS is enabled on projects and memberships. The unauthenticated anon role has no privileges on either table. The authenticated role has necessary schema access and project SELECT, INSERT and UPDATE grants, but no DELETE grant.
  2. Authenticated clients can only select their own membership rows: user_id must equal auth.uid(). They have no membership INSERT, UPDATE or DELETE grants.
  3. Project SELECT and UPDATE USING require membership matching the signed-in user and the existing row's organization_id. Project INSERT WITH CHECK requires matching membership and created_by=auth.uid(). Project UPDATE WITH CHECK requires membership in the proposed row's organization.
  4. No additional permissive policies broaden access. Tenant requests run as neither table owners, superusers nor roles with BYPASSRLS. R07 alone uses an intentional server bypass.
  5. Proposed records satisfy required fields, foreign keys and uniqueness constraints. No additional trigger, constraint or restrictive policy changes these outcomes. Reads use list semantics rather than a wrapper requiring exactly one row.

Supabase separates operation grants from row policies: a grant permits the operation; a policy limits its rows. It also distinguishes the anon database role from an anonymous Supabase Auth user, who assumes authenticated. R01 means a request without a signed-in user, not an anonymous Auth account.

Eleven expected outcomes, including successful work

Illustrative expectations only; none observed. Each row starts from the reset fixture and the preconditions above. “Rejected” means the proposed write fails; “zero rows” means filtering, not necessarily an HTTP error.

Two-tenant request specification — not executed
CaseActor / effective roleRequestExpected outcome and reason
R01Unauthenticated / anonList projects.Permission denied; no rows disclosed. There is no table grant, so this is not a policy-false result.
R02user-A / authenticatedList projects.Only project-A. This is the positive read control.
R03user-A / authenticatedRead projects filtered to id=project-B.Zero rows. The foreign row fails the membership predicate; an exception is not required.
R04user-A / authenticatedInsert project-C into org-B with created_by=user-A.WITH CHECK rejection; no insertion. The creator matches, but membership in the proposed tenant does not.
R05user-A / authenticatedChange project-A.organization_id to org-B.WITH CHECK rejection. project-A remains in org-A: access to the old row does not authorize its new tenant.
R06user-B / authenticatedChange project-A.name to Foreign edit.Zero rows changed. The existing row is filtered by USING, not automatically rejected with an exception.
R07Trusted server / effective service_roleList projects through an intentionally privileged path.project-A and project-B. This is the positive bypass control, never a browser request or evidence of tenant filtering.
R08user-A / authenticatedChange project-A.name to Roof review.One row changed; organization remains org-A. Both existing and proposed rows meet the membership rule.
R09user-A / authenticatedInsert project-C into org-A with created_by=user-A.One row inserted. Membership and creator checks both match, and the ID is unused.
R10user-A / authenticatedAdd membership organization_id=org-B, user_id=user-A, role=member.Permission denied; no membership added. Clients have no membership-write grant.
R11user-A / authenticated, after trusted revocationA trusted administrator removes A's membership in org-A and commits. A then lists projects in a new request and transaction.Zero rows because A now has no membership. This expectation does not cover a request already in flight or a concurrent revocation race.

Read the difference between filtering and rejection

PostgreSQL distinguishes existing-row access from proposed-row checks. R06 never reaches the foreign project; R05 reaches the user's project but rejects its proposed tenant. Requiring an exception in both cases would misread filtering as a failure.

R02, R08 and R09 catch a different mistake: blocking legitimate work. During execution, retain row counts, identifiers and final stored values. An HTTP success alone cannot show that the right row changed.

R10 tests the authority behind the project policy. If A can grant themselves membership in B, the next foreign read can satisfy that policy. Membership reads must also remain self-only.

The creator check applies only to insertion. created_by immutability during updates is not enforced; it needs a separate control if the product requires it.

Keep the server bypass separate from tenant access

The API key used to initialize a client is not enough to identify the role of every later request. Supabase's legacy service-role troubleshooting guide says the effective Authorization header, rather than apikey alone, determines whether the request uses the bypass credential or a user session. Cookie-based SSR sessions and auth operations can replace that header with a user token.

For R07, specify a separate server client with the intended effective service role and no inherited user session. Keep privileged credentials server-only. Record the credential type and token source without saving a token value. The cited guide concerns legacy keys; do not treat newer secret keys as interchangeable JWT bearer tokens.

Superusers and BYPASSRLS roles bypass row security; table owners normally do too. An owner can be subject to forced row security, but that does not constrain superusers. A successful query in an owner's console is not a tenant-role check.

Check each view's version, owner and execution behavior. Supabase documents security_invoker = true on PostgreSQL 15 and above for applying underlying table policies when invoked by anon or authenticated. Its availability does not establish that a view is configured or tested correctly.

What the first real run needs to preserve

Retain the versioned migration with grants, policies and owners; PostgreSQL and SDK versions; test-identity mapping; and reset procedure. For every case, record the request path, effective role, redacted token source, expected result, actual response and resulting stored state.

Database assertions do not replace requests through the application. Repeat relevant cases through its browser SDK or REST route and exposed server endpoints. Views, RPC/functions, storage, application wrappers, privileged automation, revocation races and creator immutability need separate checks; none is demonstrated here.

The security-record method helps retain observations and exclusions; the automation guidance identifies server jobs to examine. Neither supplies a tested implementation. Keep real multi-tenant customer data out of this teaching setup until its implemented paths have been tested and independently reviewed for security.

Primary documentation checked 13 September 2026: Supabase Row Level Security and legacy service-role troubleshooting; PostgreSQL 18 Row Security Policies, linked above. Match version-specific behavior to the database and clients actually deployed.