Supabase RLS checker: find tables anyone can read (free)
Find Supabase tables the anon key can read. Run two SQL queries and a logged-out GET, which is the same kind of read the free scan makes.
The anon key in a Supabase client is public. Row-level security is what stops that key from reading every row. A table with RLS off, and a grant that lets anon select, is readable by anyone who has the key. They already have it if your app loads in a browser.
The SQL below is the direct check. The DevTools request is the outside view, and it is the same kind of read the free scan performs. If you also found a service_role key in the bundle, rotate that first. The service role bypasses RLS, so policies do not contain it.
SQL: is RLS on?
In the Supabase SQL editor, run this as the project owner. It only reads catalog tables. It does not change data.
select
c.relname as table_name,
c.relrowsecurity as rls_on,
c.relforcerowsecurity as rls_forced
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'r'
order by 1;rls_onfalse on a table that holds customer data is the problem. Turn it on.rls_ontrue with no matching policy blocksanonandauthenticated. The table owner and the service role can still read.rls_forcedtrue also applies RLS to the table owner. Most apps leave this off.
select tablename, policyname, roles, cmd, qual
from pg_policies
where schemaname = 'public'
order by tablename, policyname;A policy for anon or public with qual of true and command SELECT means anyone with the anon key can read that table. That can be intentional for a public changelog. It is wrong for profiles, orders, or messages. An empty policy list plus rls_on true means anonymous reads are denied.
DevTools: the same read a stranger can make
Log out of the app first. This uses the anon key only. Do not put a service_role key in the console.
const url = "https://YOUR_PROJECT.supabase.co";
const anon = "YOUR_ANON_KEY";
const tables = ["profiles", "orders"];
for (const table of tables) {
const res = await fetch(
`${url}/rest/v1/${table}?select=*&limit=1`,
{
headers: {
apikey: anon,
Authorization: `Bearer ${anon}`,
Accept: "application/json",
Prefer: "count=exact",
},
},
);
console.log(table, res.status, res.headers.get("content-range"), await res.text());
}- HTTP 200 and a JSON array with rows: the anon key read data. Treat customer tables as exposed.
- HTTP 200 and
[]: the anon key is allowed to query, and the table happened to be empty. The next insert is public until a policy stops it. - HTTP 401 or 403: this read was denied. That is what you want on private tables. It does not prove every future table is locked.
Lock a private table
alter table public.your_table enable row level security;
create policy "own rows" on public.your_table
for select
to authenticated
using (auth.uid() = user_id);Enabling RLS does not delete rows. Existing anon queries against that table start failing until a policy allows them. Repeat for insert, update, and delete if the client needs those, with the same auth.uid() check. Then rerun the logged-out GET and expect 401 or 403.
Enable row level security on every table in the public schema that stores user data.
Do not add a policy that lets the anon or public role select every row.
For each private table, add select, insert, update, and delete policies so a signed-in user can touch only rows where auth.uid() equals that table's user id column.
Leave the anon key in the browser. Do not add the service_role key to client code.What the free scan actually checks
If the homepage or one of up to six linked scripts contains a Supabase anon JWT, the scan sends read-only GETs. It does not insert, update, or delete. First it requests the OpenAPI listing at /rest/v1/. Then it requests ?select=*&limit=1 for up to 12 tables named there, skipping RPC paths. A service_role key in the bundle is reported on its own and is not used for these requests.
HTTP 200 with rows is a high finding. HTTP 200 with an empty array is a medium finding, because the read was allowed. HTTP 401 or 403 produces no RLS finding for that table. If the listing returns no tables, the scan says it could not confirm RLS. It does not read pg_class, so it cannot see the RLS checkbox or the policy text. Use the SQL above for that. The Lovable key test covers whether the bundle also contains a service key.
Questions
Does enabling RLS delete any rows?
No. Enable row level security changes who can read and write. It does not drop data. With no policy, the anon and authenticated roles lose access. The table owner and the service role still bypass RLS.
Does the free scan prove every table has RLS on?
No. When a Supabase anon key is in the public page or a linked script, the scan sends read-only GETs for up to 12 tables from the OpenAPI list. HTTP 200 means that anon key could read the table. It does not open the SQL editor or read policy text.
Why would a table with RLS off still return 401?
RLS is not the only gate. If the anon role has no GRANT, PostgREST can still refuse the request. A 401 or 403 means this read was denied. It does not by itself prove a policy is correct.
Other guides
Run it against your live URL
The free scan reads the public site only. No account, no writes, and no use of a service key if one is sitting in the bundle.
Scan your URL