The boundary
RLS decides which rows a database request may touch
Supabase exposes PostgreSQL data through APIs. Once RLS is enabled on a table, policies can evaluate each request using its database role and signed-in user identity. A SELECT policy controls which existing rows are visible. Write policies can control which rows may be targeted and which new row values are allowed.
What RLS does not do
- It does not make a signed-in user authorized for every record.
- It does not protect a request made with a role that bypasses RLS, including
service_roleand some direct database-owner connections. - It does not automatically cover Storage objects, database functions, views, Edge Functions, or third-party services; each path needs review.
- It does not validate every business rule, hide client-side secrets, rate-limit abuse, patch dependencies, monitor incidents, or prove legal compliance.
If a user copies a request out of the browser and changes its row ID or organization ID, what trusted fact prevents the request from succeeding?
Identity and privilege
Know which role reaches the database
The project key identifies the Supabase project; the user JWT carries the user’s session. A browser can use a public anon or publishable key and still execute as authenticated when it attaches a valid user JWT. The role is not the same thing as your product’s “owner,” “admin,” or “member” role.
| Database role | Typical request | Appropriate use | Important caution |
|---|---|---|---|
anon | No valid signed-in user JWT is attached. | Public reads or tightly limited public actions only. | A publishable key—or legacy anon key—is expected in a browser. It is not a secret, so RLS must make every permitted action safe. |
authenticated | A valid user JWT is attached and auth.uid() identifies that user. | User-owned and tenant-scoped product actions. | Authenticated means “we know the identity,” not “this user may access every row.” Policies still need ownership or membership checks. |
service_role | A trusted server uses a secret key or legacy service-role credential. | Narrow backend jobs that deliberately need elevated access. | It bypasses RLS. Never ship it to a browser or mobile app, and do not use it as a shortcut for ordinary user requests. |
JWT claims can support authorization, but mutable profile or client-provided metadata should not be treated as authoritative. Use trusted claims and database state that users cannot rewrite for themselves.
Multi-tenant apps
Derive tenant access from membership, not form fields
A common structure has organizations, organization_members, and resources such as projects. Each project stores an organization_id. Access is allowed only when a trusted membership row connects auth.uid() to that organization.
iduser_id · organization_id · roleid · organization_id · …Conceptual SELECT policy
to authenticated
using (
exists (
select 1
from organization_members m
where m.organization_id = projects.organization_id
and m.user_id = auth.uid()
)
)For writes, verify both sides
USING determines which existing rows an update or delete may target. WITH CHECK determines whether inserted or resulting row values are allowed. Explicitly constrain both so a member cannot move a row into another tenant, rewrite ownership, or assign a privileged role.
Real policies depend on your schema, membership lifecycle, operations, indexes, and role rules. Membership-table policies can also recurse if they query themselves. Resolve that deliberately—sometimes with a narrowly designed database function—and review any SECURITY DEFINER function as privileged code.
Files and buckets
Storage needs its own authorization model
Supabase Storage records live in storage.objects and use RLS policies for operations on private buckets. A protected projects row does not automatically protect a file whose path happens to contain that project ID.
Authorize every operation you use
Check list, download, upload, overwrite, move, and delete separately. Base access on trusted membership and a validated path convention—not only a filename supplied by the client.
Assume object delivery is public
Public buckets are appropriate only for intentionally public assets. Obscure URLs and hard-to-guess names are not authorization controls.
Treat links as temporary access
Confirm who may create the link, which object it covers, and how long it lasts. Anyone who receives a still-valid signed URL may be able to use it.
Negative testing
Test anonymous, User A, and User B as separate identities
Create two clean accounts with distinct data. Put them in separate tenants, then add a second scenario where both belong to one tenant with different product roles. Record stable test IDs and use synthetic data. Run important requests through the interface and directly through the same API surface the app uses.
Anonymous visitor
List records, request a known ID, create a row, call an RPC, and fetch a private file.
Only deliberately public behavior succeeds. Private rows and objects stay unavailable.
User A
Read, insert, edit, and delete User A’s own permitted data.
Expected product journeys work without privileged credentials or manual database changes.
User A targeting User B
Replace row IDs, owner IDs, organization IDs, file paths, and request bodies with values belonging to User B.
Cross-account reads return no protected rows and cross-account writes are rejected or affect zero rows.
User A targeting another tenant
Use a known organization ID, move a record between organizations, or claim a stronger membership role.
Membership is checked from trusted database state; client-supplied tenant or role fields do not grant access.
Removed member
Repeat earlier links, cached IDs, file downloads, and API requests after membership is revoked.
Access ends when the authoritative membership row changes, including for direct API calls.
A short repeatable sequence
- 01
Establish the allowed baseline
Confirm each account can complete its intended reads and writes.
- 02
Swap identifiers, not sessions
Stay signed in as User A and request User B’s known row, tenant, and object IDs.
- 03
Forge writable fields
Attempt inserts and updates with another owner, tenant, status, or role value.
- 04
Remove the session and membership
Repeat as an anonymous visitor, then after revoking a user’s tenant membership.
- 05
Inspect the final state
Confirm denied writes did not partially change data and logs contain useful, non-sensitive context.
Broader launch review
RLS is one item in a production-readiness decision
Use the free checklist to cover authentication, secrets, payments, recovery, and deployment alongside data isolation.
Patterns to challenge
Common Supabase RLS failures in fast-built apps
RLS is enabled, but one table has no useful policy
An enabled table defaults to denying API access, which can break the app safely. The dangerous variation is a table in an exposed schema where RLS was never enabled or a broad policy was added just to make an error disappear.
A policy checks login, not ownership
A condition such as auth.uid() IS NOT NULL admits every signed-in user. It does not prove that the row belongs to that user or one of their organizations.
SELECT is protected, but writes are not
INSERT, UPDATE, and DELETE need deliberate coverage. UPDATE should constrain both the row that may be targeted and the resulting values so a user cannot reassign ownership or tenant IDs.
The interface supplies the trusted tenant or role
Hidden inputs, disabled controls, route middleware, and TypeScript types can all be bypassed. Authorization must be derived from the signed-in identity and trusted database records.
A server route quietly uses service_role for everything
RLS cannot protect queries that intentionally bypass it. The server route must perform and test equivalent authorization before each privileged action.
Database rows are private, but Storage is not
Storage access is a separate policy surface. Public buckets make object delivery public, and private buckets still need policies for listing, uploading, updating, moving, and deleting objects.
A helper function widens access accidentally
SECURITY DEFINER functions run with the function owner’s privileges. They need narrow logic, a controlled search_path, appropriate execute permissions, and explicit tests.
Tests cover only the happy path
A successful User A flow says nothing about User A requesting User B’s IDs. Negative tests are the evidence that boundaries hold.
Review record
Evidence to collect before calling isolation verified
A dashboard screenshot that says RLS is enabled is not enough. Keep evidence that connects the intended rule, deployed configuration, request identity, attempted operation, observed result, and any remaining uncertainty.
- An inventory of every table, view, function, and Storage bucket reachable by the application.
- A role-and-resource matrix for anonymous visitors, members, tenant admins, internal admins, and backend jobs.
- The deployed RLS-enabled state and applicable policies—not only migration files in the repository.
- Sanitized request and response evidence for anonymous, own-account, cross-account, and cross-tenant tests.
- Write tests that attempt to forge owner_id, organization_id, status, price, and role fields where relevant.
- Storage tests for list, download, upload, overwrite, move, and delete operations using another user’s path.
- A list of every service-role, database-owner, webhook, Edge Function, RPC, and scheduled-job path that can bypass ordinary RLS.
- Known exclusions, unresolved assumptions, finding severity, an owner, and a retest plan for each launch blocker.
Scope and limitations
A useful review is explicit about what it did not prove
Passing these checks improves evidence for the tested schema, identities, operations, and deployed configuration. It does not cover every future migration, leaked credential, database extension, new API route, provider failure, unknown vulnerability, or misuse. Repeat the tests when policies, functions, claims, membership logic, buckets, or privileged backend paths change.
Supabase is additional specialist coverage that AppGrout confirms before any engagement. Availability, architecture, access, surfaces, exclusions, deliverables, and price must be agreed in writing. AppGrout does not present this guide or a scoped review as certification, penetration testing, or a guarantee of security.
For the general review model, see the vibe-coded app audit service ↗.
Questions founders ask
Supabase RLS FAQ
Does enabling RLS make a Supabase app secure?
No. RLS is an important database authorization layer, but its result depends on the policies, database functions, Storage configuration, privileged server paths, and the rest of the application. It also does not replace input validation, secret management, rate limiting, dependency review, monitoring, or recovery planning.
Is the Supabase anon key safe to put in a browser?
The legacy anon key or newer publishable key is designed to identify the Supabase project from a public client. It must be treated as public. Safety comes from correctly configured RLS and other controls—not from hiding that key. The service-role or secret key is privileged and must never be shipped to a client.
Why can a signed-in user still see another user’s data?
Authentication only establishes identity. A policy that grants access to the authenticated role without checking auth.uid(), ownership, or trusted tenant membership can expose rows across accounts. Views, functions, Storage, or a privileged server route can also create a separate access path.
Should the organization ID come from the URL or form?
It can be supplied as a requested target, but it cannot be trusted as proof of access. The policy or trusted server must compare the signed-in user with authoritative membership data before reading or writing that organization’s resources.
Can AppGrout review my Supabase setup?
Supabase is additional specialist coverage, not an automatic inclusion in every AppGrout engagement. We first confirm the architecture, requested surfaces, reviewer availability, access, and scope in writing. Any review is limited to the agreed evidence and cannot guarantee that an application is secure or that every issue will be found.
