Row-Level Security in Supabase: Test It Before You Ship
In Supabase the database is reachable through an API, so row-level security is the access control. Here is what it does, the mistake that exposes a table, and how to test a policy as anon and as a signed-in user.

In Supabase the database is reachable through an API, so row-level security is not a detail to tidy up after launch. It is the access control. A table in an exposed schema with no RLS is readable and writable by any role that has a grant on it, and the publishable key is designed to be shipped in browser source code. The policies on the table are what stand between that key and the rows.
What row-level security does
Postgres row-level security attaches a rule to every row a query touches. Supabase describes each policy as an extra WHERE clause added to the query. A select returns only rows that pass the policy, an update can only reach rows that pass, and an insert is checked against what the new row will contain.
Two checks run on every request, and both have to pass. Grants decide whether a role may run the operation on the table at all. Policies decide which rows that operation applies to. If the grant is missing, Postgres raises 42501 before any policy is evaluated, so a legitimate request can fail even when the policy is correct. If the grant is present and RLS is off, there is no policy to help.
You enable it with one statement, in the SQL editor, over psql, or in a migration: alter table public.reports enable row level security;. A migration is the better home, because the grant and the policy then travel with the schema. The Supabase guide recommends the full sequence in the same migration: enable RLS, revoke the existing grants, grant back only what the role needs, then test.
How anon and authenticated relate to the API keys
Supabase maps every API key to a built-in Postgres role, and that role is what your policies match on. The publishable key (sb_publishable_...) is the low-privilege key for browsers, mobile apps and public code. With no signed-in user it runs as anon. When a user signs in through Supabase Auth, the same key runs as authenticated. Using a publishable key does not make the user anonymous; the key identifies the application, and the user's own token identifies the person.
The secret key (sb_secret_...) maps to service_role, which has the BYPASSRLS attribute. Policies never apply to it. It belongs on a server only, and Supabase rejects browser requests made with one with HTTP 401 based on the User-Agent header. The older long-lived JWT anon and service_role keys still work and are the legacy versions of the same two keys; Supabase says it is deprecating them by the end of 2026.
The practical consequence is that the browser can hold a key that reaches the database, and RLS is the only thing deciding what it reaches. The API keys page puts it plainly: enable RLS on every table before you deploy.
The mistake we see most often
It is a table in the public schema, exposed through the API, with RLS switched off. The linter in Supabase flags it as 0013_rls_disabled_in_public, at error level, with the summary "Table publicly accessible" and the note that anyone with the project URL can read, edit and delete all data in it. No part of the key saves that table, because the publishable key is meant to be public.
The second version is quieter: RLS is on, but the policy is too broad. A policy with using (true) or with check (true) passes every row. The linter reports it as 0024_permissive_rls_policy at warning level, and it only catches the literal always-true forms. A policy that compares the wrong column, or forgets the to clause and so also runs for anon, reads correctly and still hands out rows.
Two neighbouring lints are worth knowing. 0007_policy_exists_rls_disabled means policies exist but RLS is off, so they do nothing. 0008_rls_enabled_no_policy means RLS is on with no policy, so the API returns no rows. You can run the checks in the dashboard's Security Advisor or with supabase db advisors.
There is also a grant problem that policies do not fix. Adding policies does not remove existing grants. Supabase notes that new tables in public on some projects still grant every privilege to anon, authenticated and service_role. Revoke first, then grant back what the table needs.
A policy we can defend
Take a profiles table where each row belongs to one user through user_id. The goal is simple: a signed-in user reads and changes only their own row, and nobody else reads anything. The Supabase guide's example uses auth.uid(), the id of the signed-in user, and this is the shape we use:
alter table public.profiles enable row level security;revoke all on table public.profiles from anon, authenticated;grant select, insert, update, delete on table public.profiles to authenticated;create policy "read own profile" on public.profiles for select to authenticated using ((select auth.uid()) is not null and (select auth.uid()) = user_id);create policy "insert own profile" on public.profiles for insert to authenticated with check ((select auth.uid()) is not null and (select auth.uid()) = user_id);create policy "update own profile" on public.profiles for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id);
Four details in that block are deliberate. to authenticated is always present, because a policy without it also runs for anon. There is one policy per operation instead of for all, so each rule stays visible. using controls which rows are visible or affected; with check controls what a new or changed row may look like. And the null case is explicit, because auth.uid() returns null when nobody is signed in, and null = user_id is never true.
Two more rules from the same page matter here. An update also needs a select policy, or it will not behave as expected. And a policy that is meant to be public should say so with to anon using (true), on a table where every row really is public.
Test it as each role
A policy that reads well is a hypothesis. The way to check it is to run the query as the role the API will use. In the SQL editor or psql, switch roles inside a transaction and roll back:
begin; set local role anon; select count(*) from public.profiles; rollback;begin; set local role authenticated; set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111'; select count(*) from public.profiles; rollback;
Run the same probe with a second user id and with a session where the claim is absent. The results should tell a story: zero rows for anon, one row for the owner, zero rows for the other user. Probe a write too, for example an insert that claims another user's user_id, and expect it to fail with 42501. To check the grant separately from the policy, ask Postgres directly with select has_table_privilege('anon', 'public.profiles', 'insert,update,delete');.
For a suite you can keep, Supabase documents pgTAP tests under supabase/tests/. Create one with supabase test new <name> and run the set with supabase test db. The assertions the guide recommends are specific: throws_ok with 42501 for a missing grant or a with check violation, is_empty with returning for a row filtered by using, and results_eq for a write that should succeed. It warns against lives_ok, which passes even when zero rows match.
Why testing beats reading the policy
Reading a policy checks the sentence against your intention. Running it checks the sentence against Postgres, and the two checks are not the same. The gap shows up in ordinary places.
- Grants are checked first. A missing grant produces
42501before the policy is consulted, and a leftover grant toanonis the other half of the same mistake. usingandwith checkdo different jobs. Anupdatepolicy with a correctusingand nowith checkcan still let a row move to another user.- A policy without
toapplies toanonas well. The policy looks scoped to signed-in users and is not. - An
updateneeds aselectpolicy to find its rows. Without one, the query returns nothing and the table looks broken. - Views normally run as their creator and bypass RLS. On Postgres 15 and later,
security_invoker = truechanges that; before that, revoke access or move the view out of the exposed schema. - Two tables whose policies read each other fail with
42P17, infinite recursion, and the error appears only when you run the query. - JWT claims go stale until the token refreshes, and
user_metadatacan be modified by the user. Authorization belongs inapp_metadata.
The linter helps, but it flags patterns, not intent. It will catch using (true). It will not catch a policy that compares user_id to the wrong id, or a table whose grants were never revoked but whose policies happen to look strict. Running the query as each role is the only check that exercises the same path the API does: role resolution, grant, policy, row.
A sandbox for the same test
We run this check by hand often enough that it shaped a tool. Supabase Local Manager is an independent project, not affiliated with Supabase, Inc., and it is in early access. It provisions and manages many local Supabase stacks from one Node and Express service, and its dashboard includes row-level security with a policy test sandbox, where you pick a role and run a probe query against a project. That is the same loop described above, with the role switch and the rollback handled for you. Access is requested through GitHub while the source stays private.
Where this fits in our work
Danube Labs is a founder-led software studio in Bucharest. Many of the sites we ship have no database at all, as we describe in our article on git-based CMS, and when a project does need Postgres, the policies are part of the schema from the first migration, not a hardening step at the end. If you are building on Supabase and want a second pass over your access model, see our custom software work or book a free intro call.
Frequently asked questions
- What is row-level security in Supabase?
- Row-level security is a Postgres feature that limits which rows a query can read or change. In Supabase each policy acts like an extra WHERE clause on the table, and the policies are matched against the Postgres role the API request runs as. Grants decide whether a role can run the operation at all; policies decide which rows it applies to.
- What is the difference between anon and authenticated in Supabase?
- They are Postgres roles, not keys. A request with the publishable key runs as anon when nobody is signed in, and as authenticated once a user signs in through Supabase Auth. The key identifies the application; the user's token identifies the person. Write your policies for both roles.
- Is the Supabase publishable or anon key safe to expose?
- It is meant to be public, because it ships in browser source code. It only reaches what row-level security allows, so the protection comes from the policies on your tables. The secret key is the opposite: it maps to service_role, bypasses RLS, and must stay on a server.
- What happens if a table in Supabase has RLS disabled?
- If the table is in an exposed schema such as public and a role holds a grant on it, anyone with the project URL can read, edit and delete its rows. Supabase's linter reports this as 0013_rls_disabled_in_public at error level. Enable RLS, revoke the existing grants and grant back only what the role needs.
- How do I test an RLS policy as a specific role?
- Start a transaction, run set local role anon or set local role authenticated, and set the user id with set local request.jwt.claim.sub. Run your select, insert or update in that transaction and roll back. For a kept test suite, write pgTAP tests under supabase/tests/ and run them with supabase test db.
- Why does my RLS policy return no rows or let everything through?
- The usual causes are a missing select policy for an update, a missing or revoked grant, or a policy without a to clause that also applies to anon. A policy using true is wide open, while a policy comparing a null auth.uid() never matches. Running the query as each role shows which of these it is.


