How to review Supabase RLS before real users arrive
Supabase makes it reasonable for a browser to talk directly to Postgres through its Data API. Row Level Security is what makes that design safe.
The easy mistake is to treat RLS as a checkbox. You enable it, add a policy, and move on. The harder question is whether the policy expresses the rules of your product.
Before real customer data arrives, test the rules as an attacker would.
Know which key is allowed in the browser
Supabase's current API key documentation separates low-privilege publishable keys from elevated secret keys.
A publishable key, or the older anon key, is expected to be visible in a web or mobile app. It identifies the project. It is not a password. Access is constrained by Postgres grants and RLS policies for the anon and authenticated roles.
A secret key, or the older service_role key, is different. It uses a role with BYPASSRLS and must stay in a trusted backend.
The rule is:
publishable / anon -> may be public, still needs correct RLS
secret / service_role -> server only, never ship to a browser
Search the deployed JavaScript, not only the repository. Build tools can expose a value that looked private in source.
For Next.js:
rg "service_role|sb_secret_|SUPABASE_SERVICE" .next/static
If a privileged key reached the browser, remove it, rotate it, and review access logs.
Find every exposed table without RLS
Supabase says RLS must be enabled on tables in exposed schemas, which is usually public. Tables created through the dashboard have RLS enabled by default. Tables created through raw SQL may not.
Run an inventory:
select
schemaname,
tablename,
rowsecurity
from pg_tables
where schemaname in ('public')
order by tablename;
Anything exposed with rowsecurity = false needs a deliberate decision.
Enabling RLS without policies produces a useful default: API access through low-privilege roles is denied.
alter table public.projects enable row level security;
Then add only the access the product needs.
Test anonymous access first
Start with no user session. Use the publishable key against every table, view, and function you expect the frontend to touch.
curl \
"https://YOUR_PROJECT.supabase.co/rest/v1/projects?select=*" \
-H "apikey: YOUR_PUBLISHABLE_KEY"
An empty result is not always proof that the policy is safe. The table may simply have no data that matches today. Seed test records that should be public and private, then repeat the request.
Also test writes:
- Can an anonymous user insert a row?
- Can they set
user_idto somebody else's ID? - Can they update or delete a known row?
- Can they call an RPC function directly?
Read and write policies are separate decisions.
Test two users, not one
The most valuable RLS test uses two accounts.
Create:
- User A in Tenant A
- User B in Tenant B
- A normal member and an admin in each tenant
Seed a few objects for each. Then use User A's JWT to request User B's IDs.
A basic ownership policy often looks like:
create policy "users read own projects"
on public.projects
for select
to authenticated
using ((select auth.uid()) = owner_id);
For multi-tenant access, the rule is usually membership-based:
create policy "members read tenant projects"
on public.projects
for select
to authenticated
using (
exists (
select 1
from public.memberships m
where m.tenant_id = projects.tenant_id
and m.user_id = (select auth.uid())
)
);
That policy still needs adversarial tests:
- Change the project ID.
- Change the tenant ID sent by the client.
- Remove the user from the membership table and reuse an old session.
- Try every operation, not only
select. - Try an admin-only field through a normal update.
The client-provided tenant ID is input. The membership table is evidence.
Do not forget WITH CHECK
For insert and update, USING and WITH CHECK answer different questions.
USINGdecides which existing rows the user may target.WITH CHECKdecides which new row state the user may create.
Without a correct WITH CHECK, a user may be able to create or move a row into a tenant they do not own.
create policy "users create own projects"
on public.projects
for insert
to authenticated
with check ((select auth.uid()) = owner_id);
Do not let the browser choose security-sensitive ownership fields if the server or database can set them safely.
Views and functions have different traps
RLS on a base table does not mean every database object around it is safe.
Supabase's RLS guide explains that views may bypass underlying RLS depending on the Postgres version and view configuration. In PostgreSQL 15 and later, security_invoker = true can make a view obey the caller's RLS context:
create view public.project_summary
with (security_invoker = true)
as
select id, tenant_id, name
from public.projects;
Functions need a separate review. The Supabase API security guide notes that RLS does not apply to functions in the same way. Control who has EXECUTE, and pay special attention to SECURITY DEFINER functions because they run with the function owner's privileges.
Inventory them:
select
n.nspname as schema,
p.proname as function,
p.prosecdef as security_definer
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public';
Revoke broad execution where it is not needed.
Keep grants and policies together
Supabase's Data API uses both Postgres grants and RLS:
- Grants decide whether a role can reach a table, view, or function.
- RLS decides which rows that role can use.
Put grants, RLS enablement, and policies in the same migration. That makes the security state reviewable and reproducible.
revoke all on public.admin_exports from anon, authenticated;
grant select, insert on public.projects to authenticated;
alter table public.projects enable row level security;
The dashboard is useful, but migrations are your record of what should exist.
Use the Security Advisor, then verify manually
Supabase's shared responsibility guidance says the platform provides Security Advisor alerts, while applying the recommendations remains the customer's responsibility.
Run it. Fix missing RLS and unsafe role findings. Then test the application manually because an advisor cannot know whether User A should see Project B.
A solid pre-launch RLS review covers:
- Every table in exposed schemas
- Anonymous reads and writes
- Cross-user and cross-tenant access
- All CRUD operations
- Ownership fields on insert and update
- Views, RPC functions, and
SECURITY DEFINER - Grants to
anonandauthenticated - Privileged keys in source, bundles, logs, and CI
- Storage policies as well as database policies
RLS is strongest when it is treated as product authorization written in SQL, not as a database setting somebody enabled once.
Sources
Want us to check your app for this?
We prove what’s exploitable and hand you a clear report. Share your app and we’ll confirm scope.
Start an audit →