dreamlabs.pro/services/supabase-rls-checklist · Updated Oct 7, 2026
[Free checklist]
Supabase gives your frontend a direct line to Postgres, which is a big part of why teams building in Lovable, in their own repositories with AI coding tools, or by hand get to a working product in days. The key that makes it possible sits in every visitor's browser by design, so Row Level Security is what decides who sees which row. When your policies say exactly that, you can open sign-ups, answer a security questionnaire with evidence, and keep shipping without wondering what a stranger with your key can do. This checklist covers 23 checks across RLS, grants, keys, functions, Storage and Realtime, including the Lovable-era exposure pattern behind CVE-2025-48757. Each one says what to look for, why it matters, how to test it with SQL, curl or the Dashboard, and which Supabase documentation backs it. Run the tests only against projects you own, ideally a staging branch.
[01]
Your project URL and publishable (or legacy anon) key sit in every visitor's browser by design. Supabase's own analogy: the publishable key is taped to the front door, and it only opens the lobby. These four checks confirm the lobby is all it opens.
Why it matters
Supabase's guide opens with it: a table in an exposed schema without RLS is readable and writable by any role with a grant on it. Tables created in the Dashboard get RLS by default, but tables created in the SQL Editor, by a migration or by an AI coding tool need it switched on explicitly.
How to test
Run the query below in the SQL Editor. Any row showing rowsecurity = false is a finding. Fix it in a migration with alter table public.<table> enable row level security;, then write policies, because once RLS is on the table returns nothing to the public key until a policy allows it.
select schemaname, tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename; Why it matters
Postgres checks grants before it looks at policies. On projects created under the old defaults, every new public table got select, insert, update and delete for anon, authenticated and service_role, and adding policies doesn't take those grants back. Supabase is making exposure opt-in: the new default began rolling out gradually to new projects on May 30, 2026, and is scheduled for existing projects on October 30, 2026, but in existing projects, tables that already exist keep their current grants.
How to test
List the grants with the query below and compare each line with what your app needs. Revoke what's extra, for example revoke insert, update, delete on table public.<table> from anon;, in the same migration as the table's RLS and policies.
select table_name, grantee,
string_agg(privilege_type, ', ' order by privilege_type) as privileges
from information_schema.role_table_grants
where table_schema = 'public' and grantee in ('anon', 'authenticated')
group by table_name, grantee
order by table_name, grantee; Source: Supabase docs: Securing your API
Why it matters
This is the view from outside: copy the URL and key from the page, then call the REST API directly. If a private table answers your curl with rows, it answers anyone's.
How to test
Find the project URL and key in your own app (browser network tab or built bundle). Request each table that holds user data with the first command. An empty array [] or a permission error (code 42501) passes; rows are a finding. Then sign in as a second test user, copy their access token from the Authorization header in the network tab, and repeat with the second command to check that one user can't see another's rows.
# As a signed-out visitor
curl 'https://PROJECT_REF.supabase.co/rest/v1/profiles?select=*&limit=5' \
-H "apikey: PUBLISHABLE_OR_ANON_KEY"
# As signed-in test user B (their access token, not the key, goes in Authorization)
curl 'https://PROJECT_REF.supabase.co/rest/v1/profiles?select=*&limit=5' \
-H "apikey: PUBLISHABLE_OR_ANON_KEY" \
-H "Authorization: Bearer USER_B_ACCESS_TOKEN" Source: Supabase docs: Build an API route in less than 2 minutes
Why it matters
Advisors are deterministic checks that ship with the platform, and they catch most of what this checklist covers: tables without RLS (lint 0013), always-true policies (0024), policies that read user_metadata (0015), views that bypass RLS (0010), user data exposed through a view (0002), sensitive columns exposed (0023), public buckets that allow listing (0025), and SECURITY DEFINER functions callable by signed-out or signed-in users (0028, 0029).
How to test
Open Security Advisor in Studio, or run supabase db advisors from the CLI. Treat ERROR-level findings as release blockers and re-run the advisor after every schema change. Supabase itself notes some findings may be intentional, so check each one against how your app is supposed to work before changing anything.
supabase db advisors Source: Supabase docs: Advisors
[02]
AI app builders made a powerful architecture mainstream: a React frontend that queries Supabase straight from the browser with the public key. Nothing sits between a visitor and the database except your policies. When a table ships without RLS, or with a policy written to make a feature work rather than to decide who may see it, anyone who copies the key from the page can read, and sometimes write, that table. In May 2025 the pattern got a CVE record for Lovable-generated sites, CVE-2025-48757: 'An insufficient database Row-Level Security policy in Lovable through 2025-04-15 allows remote unauthenticated attackers to read or write to arbitrary database tables of generated sites.' NVD lists a CVSS 3.1 base score of 9.3 (Critical) from MITRE. Lovable disputes the record, on the grounds that each customer of the platform accepts responsibility for protecting their application's data. Either way the fix lives in your project, and the checks below apply to any Supabase app whose frontend talks to the database directly, whichever tool wrote it.
Why it matters
An always-true policy makes RLS look switched on while letting the named role reach every row. Supabase spells it out: a policy for anon with using (true) grants every unauthenticated visitor read access to every row the role can reach through its grants. Security Advisor flags these as lint 0024.
How to test
List every policy with the query below. Look for qual or with_check equal to true, insert and update policies with an empty with_check, and policies whose roles are {public} or {anon} on tables that hold private data.
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname in ('public', 'storage')
order by tablename, cmd; Why it matters
with check is what stops a user creating a row that belongs to someone else, and on update it stops them reassigning user_id to another account. Supabase also notes that an update needs a matching select policy to behave as expected.
How to test
In the policy listing, confirm each INSERT and UPDATE row has a with_check that references auth.uid() (or your tenant check). Then, as test user B, insert a row carrying user A's user_id, and update one of B's own rows to set user_id to A. Both should fail with 42501.
create policy "Users update own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id ); Why it matters
An 'update your own profile' policy authorizes the row, not the column. If permission or billing data lives on a row its owner can update, the owner can upgrade themselves. Supabase recommends keeping roles in a dedicated table, and column-level privileges can limit which columns a role may update.
How to test
For each table users can update, list the columns that affect permissions or billing. As an ordinary signed-in user, try the request below; it should fail or change nothing. Fix it by moving those columns to a table users can't write to, or with column grants: revoke update on table public.profiles from authenticated; grant update (display_name, avatar_url) on table public.profiles to authenticated;.
curl -X PATCH 'https://PROJECT_REF.supabase.co/rest/v1/profiles?id=eq.YOUR_USER_ID' \
-H "apikey: PUBLISHABLE_OR_ANON_KEY" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d '{"role":"admin"}' Why it matters
When users sign in somewhere Supabase doesn't know about, the database never receives a user token, so policies that check the user match nothing and every query comes back empty. The tempting fixes, disabling RLS or querying with the secret key, make the data public. Supabase supports Clerk, Firebase Auth, Auth0, AWS Cognito and WorkOS as third-party auth providers, so RLS can see the signed-in user.
How to test
Check whether the app signs users in with a non-Supabase library. If it does, confirm a Third-Party Auth integration exists in the Dashboard, that RLS is still enabled on user tables (first check), and that no client code creates a Supabase client with a secret or service_role key (see the key check below).
Source: Supabase docs: Third-party auth
Why it matters
While 'Allow new users to sign up' is on, anyone can create an account with your public key, so to authenticated using (true) is close to public. Anonymous users also get the authenticated role; Supabase says to check the is_anonymous claim, using restrictive policies so the check always applies.
How to test
Review the Auth settings for Allow new users to sign up, Confirm email and Allow anonymous sign-ins. Search your policies for to authenticated paired with using (true) on private tables. If anonymous sign-ins are on, add a restrictive policy to writes that need a real account, like the one below.
create policy "Only permanent users can post"
on public.posts as restrictive for insert
to authenticated
with check ( (select (auth.jwt()->>'is_anonymous')::boolean) is false ); [03]
Why it matters
Supabase recommends always naming the role, so a policy stops at to authenticated for anonymous requests. auth.uid() returns null when nobody is signed in, and null = user_id is false, which denies access by accident rather than by design. Explicit policies are also easier for the next person, or the next AI prompt, to edit safely.
How to test
In the policy listing, flag rows whose roles are {public}. Rewrite them in the form below.
create policy "Users read own todos"
on public.todos for select
to authenticated
using ( (select auth.uid()) = user_id ); Why it matters
Supabase: raw_user_meta_data can be updated by the signed-in user, so it isn't a good place to store authorization data, while raw_app_meta_data can't be updated by the user. A policy that trusts user_metadata ->> 'role' lets users hand themselves the role. Security Advisor flags it as lint 0015.
How to test
Search your policies for user_metadata with the query below, and search your SQL functions and migrations for the same string.
select tablename, policyname
from pg_policies
where qual ilike '%user_metadata%' or with_check ilike '%user_metadata%'; [04]
Why it matters
Views bypass RLS by default because they run with their creator's rights, so a view over a protected table hands out every row its policies were meant to hold back. A public view over auth.users exposes your users' personal data (lint 0002), and views that bypass RLS are lint 0010. Materialized views can't apply RLS at all, so keep them out of exposed schemas (lint 0016).
How to test
List the views and materialized views in public with the query below. Any plain view without security_invoker=true in its options that reads protected tables is a finding: alter view public.<view> set (security_invoker = true);.
select c.relname as view_name, c.relkind, c.reloptions
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind in ('v', 'm'); Why it matters
RLS doesn't apply to functions. A SECURITY DEFINER function runs with its owner's privileges, and on Supabase the owner is usually postgres, which bypasses RLS. Any function in an exposed schema that a role can execute is callable at /rest/v1/rpc/<name>. Supabase's advice is never to create one in an exposed schema, and to pin search_path on every one.
How to test
Run the query below. Every true under anon_can_execute or auth_can_execute needs a decision. To close one, revoke execute (revoke execute on function public.<fn>(<args>) from anon, authenticated, public;), move the function to a schema the API doesn't expose, or switch it to SECURITY INVOKER. Advisor lints 0028 and 0029 flag these too.
select p.proname as function_name,
pg_get_function_identity_arguments(p.oid) as args,
has_function_privilege('anon', p.oid, 'execute') as anon_can_execute,
has_function_privilege('authenticated', p.oid, 'execute') as auth_can_execute,
p.proconfig as settings
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public' and p.prosecdef; Why it matters
A list of tables and columns takes the guesswork out of an attack. Supabase has been closing this by default: under its published schedule, the OpenAPI spec at /rest/v1/ has answered only secret (and legacy service_role) keys since March 11, 2026 for new projects and April 8, 2026 for existing ones. pg_graphql is no longer enabled automatically on new projects, and pg_graphql 1.6.0 turns introspection off by default for projects created from June 29, 2026. Older projects keep their GraphQL behaviour until they change it, and pg_graphql shows any table a role can select from, whatever your RLS says (lints 0026 and 0027).
How to test
Request the OpenAPI spec with only the public key (first command). It should return an 'Access to schema is forbidden' error, not a description of your tables. Then check Database > Extensions for pg_graphql and disable it if your app doesn't use GraphQL. If it does, run the second command with only the public key. On a project that doesn't need introspection, it should return an error, not a list of your tables.
# REST schema: expect "Access to schema is forbidden"
curl 'https://PROJECT_REF.supabase.co/rest/v1/' \
-H "apikey: PUBLISHABLE_OR_ANON_KEY"
# GraphQL introspection: expect an error, not your type names
curl -X POST 'https://PROJECT_REF.supabase.co/graphql/v1' \
-H "apikey: PUBLISHABLE_OR_ANON_KEY" \
-H 'Content-Type: application/json' \
--data-raw '{"query":"{ __schema { types { name } } }"}' Source: Supabase changelog: Breaking change in pg_graphql 1.6.0, GraphQL introspection disabled by default
[05]
Why it matters
A secret key bypasses every Row Level Security policy, so a leaked one exposes all of your project's data. New secret keys refuse browser requests (Supabase checks the User-Agent and returns 401), but an attacker can still use a leaked key from any other tool. Legacy keys are long JWTs starting with eyJ, and the role inside tells you which one you're looking at.
How to test
Search your build output and repository with the first command, and check git history too. For any eyJ... key you find, decode its middle segment in the browser console with the second snippet: anon is expected, service_role is a critical finding. If a secret key ever shipped, delete or rotate it in Settings > API Keys immediately.
grep -rnE "sb_secret_|service_role" dist/ build/ .next/static/ src/ 2>/dev/null
// Browser console: read the role inside a legacy JWT key
JSON.parse(atob('PASTE_MIDDLE_SEGMENT'.replace(/-/g, '+').replace(/_/g, '/'))).role Source: Supabase docs: API keys
Why it matters
Code using the secret key runs as service_role, which bypasses RLS, so any query it makes for a user has to filter by that user itself. Supabase's Edge Functions guide warns that a handler which queries a shared table with the admin client, without filtering by the caller's ID, returns every user's rows.
How to test
Find every place a secret-key client is created (search for SUPABASE_SECRET_KEY, SERVICE_ROLE and supabaseAdmin). For each query, confirm the user ID comes from the verified token, never from the request body. Where the work is on the caller's behalf, prefer a user-scoped client so RLS applies.
Why it matters
verify_jwt is on by default and rejects requests without a valid user JWT. Turning it off to clear a 401 during development removes that check. With the new API keys, the platform check also accepts publishable and secret keys, and Supabase says the check alone doesn't authenticate a caller that sends only an API key, so authorization belongs in your code.
How to test
Search supabase/config.toml for verify_jwt = false and review each function it names. Call every function with no credentials using the second command; anything other than a 401 or a deliberately public response is a finding. Then call it with only the publishable key in the apikey header, as the third command does. verify_jwt lets that request through, so unless the function is deliberately public, your handler has to answer 401 or 403 itself. Webhook endpoints should reject an unsigned request.
grep -n -B1 "verify_jwt = false" supabase/config.toml
# No credentials: expect 401
curl -i -X POST 'https://PROJECT_REF.supabase.co/functions/v1/FUNCTION_NAME' \
-H 'Content-Type: application/json' -d '{}'
# Publishable key only: passes verify_jwt, so your handler must reject it
curl -i -X POST 'https://PROJECT_REF.supabase.co/functions/v1/FUNCTION_NAME' \
-H "apikey: PUBLISHABLE_KEY" \
-H 'Content-Type: application/json' -d '{}' Source: Supabase docs: Authorization headers (Edge Functions)
[06]
Why it matters
A public bucket skips access control for reading: anyone with a file's URL can fetch it. That suits avatars and blog images, not IDs, invoices or exports. A broad SELECT policy on a public bucket also lets clients enumerate every file name in it (lint 0025).
How to test
Run the query below and confirm every public bucket only holds files you'd be happy to see on your homepage. Then review storage rows in the policy listing from the previous section.
select id, public from storage.buckets order by public desc, id; Source: Supabase docs: Storage Buckets
Why it matters
Storage permissions are RLS policies on storage.objects. A policy that only checks bucket_id hands every signed-in user every file in that bucket. Supabase's pattern ties the first folder in the path to the user: (storage.foldername(name))[1] = (select auth.jwt()->>'sub').
How to test
Sign in as test user B and try to list and download user A's folder with the snippet below. Expect an empty list and an error.
const { data: files } = await supabase.storage.from('documents').list('USER_A_ID')
// expect []
const { error } = await supabase.storage.from('documents').download('USER_A_ID/statement.pdf')
// expect an error Why it matters
Supabase only enforces private channels once the public access setting is disabled. A private channel's access policies are checked when a client joins and then cached for the connection, so a user whose access you revoke keeps receiving Broadcast and Presence messages until their token expires or a new one is sent; Supabase advises keeping the JWT expiry short. Postgres Changes is checked separately: it sends a row only to clients whose RLS policies on that table allow them to read it.
How to test
Check Realtime Settings for 'Allow public access'. Confirm clients join with { config: { private: true } } and that the policy listing includes policies on realtime.messages. Then join a private topic as a user who shouldn't have access and confirm you receive nothing.
const channel = supabase.channel('room:123', { config: { private: true } }) [07]
Why it matters
In Supabase's words, until the suite passes you don't know whether the policies do what you intended. A denial by a using clause raises no error, it just matches zero rows, so tests need returning and a follow-up read to prove a denied write left the row intact.
How to test
Create a test file per table with supabase test new <table>_rls.test, switch identity with set local role and set local request.jwt.claim.sub, and run supabase test db locally and in CI. Studio's user impersonation and the RLS tester preview are handy for quick manual checks, but they don't replace the suite.
-- supabase/tests/profiles_rls.test.sql
begin;
select plan(2);
insert into auth.users (id, email) values
('11111111-1111-1111-1111-111111111111', '[email protected]'),
('22222222-2222-2222-2222-222222222222', '[email protected]');
-- Seed the owner's row while still running as postgres, so 'reads none' proves something
insert into profiles (id, user_id, avatar_url)
values (gen_random_uuid(), '11111111-1111-1111-1111-111111111111', 'owner.png');
set local role authenticated;
set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
select is_empty(
$$ select * from profiles where user_id = '11111111-1111-1111-1111-111111111111' $$,
'another user reads none of the owner''s profile'
);
select throws_ok(
$$ insert into profiles (id, user_id, avatar_url)
values (gen_random_uuid(), '11111111-1111-1111-1111-111111111111', 'x.png') $$,
'42501', null,
'another user cannot create a profile for the owner'
);
select * from finish();
rollback; Why it matters
Supabase recommends bundling grants with the RLS setup in the same migration: grants control which roles reach a table, RLS and policies control which rows. For AI tools that create tables, Supabase suggests updating the tool's system prompt or adopting its agent skill, which includes the grants step. A policy that only exists in the Dashboard isn't in code review, isn't in your other environments and isn't covered by tests.
How to test
Confirm supabase/migrations/ contains the RLS, grant and policy statements for every table. Run the policy listing query on staging and production and compare the two.
-- One migration: RLS, grants and policies together
alter table public.invoices enable row level security;
revoke all on table public.invoices from anon, authenticated;
grant select, insert, update on table public.invoices to authenticated;
create policy "Owners read invoices"
on public.invoices for select
to authenticated
using ( (select auth.uid()) = user_id ); Source: Supabase docs: Securing your API
Why it matters
RLS protects data from your app's users. These settings protect the database connection and your own account, and slow down automated sign-up abuse.
How to test
Work through the Security section of Supabase's Production Checklist: SSL enforcement and network restrictions under Database settings, MFA for your Supabase account and organization, email confirmations in the Auth provider settings, and Auth CAPTCHA.
[FAQs]
The publishable key (or the legacy anon key) is designed to be public: Supabase says anyone can read it, so it only reaches what Row Level Security allows. The secret key (or legacy service_role key) is the opposite, because it bypasses RLS and must never leave your servers. Supabase is deprecating the legacy anon and service_role keys by the end of 2026, so this is also a good moment to move to publishable and secret keys.
It's a CVE record published in May 2025 describing insufficient Row-Level Security in Lovable-generated sites through 2025-04-15, which let unauthenticated visitors read or write database tables. Lovable disputes it, saying each customer is responsible for protecting their own app's data. Whether or not your app was built in Lovable, if your frontend talks to Supabase directly, the first two sections of this checklist are where to start: they test for exactly that exposure.
Partly. New tables in new projects are no longer exposed to the Data API automatically (rolling out to new projects since May 30, 2026), and the same change is scheduled for existing projects on October 30, 2026, but tables that already exist keep their current grants. Under Supabase's schedule, the OpenAPI schema endpoint has answered only secret keys since April 8, 2026, and pg_graphql is now opt-in on new projects. None of this changes how RLS behaves, so an existing table with RLS off or an always-true policy is exactly as open as it was.
It's a good first line. Lovable's Quick scan runs on every publish and flags things like tables without row-level security and access rules that let everyone through, and its Deep scan reviews application code. Lovable's own documentation also says these tools don't replace a thorough security review, and suggests an additional professional review for apps that handle sensitive data or critical functions.
Because that's how a policy says no. A missing grant raises a permission error (42501) before any policy runs, but a policy that matches no rows just returns an empty result. If the app shows no data after you enable RLS, check grants first, then policies, and resist switching RLS off or reaching for the secret key to make the screen load.
Run the RLS-enabled query, the outside-in curl test and the always-true policy query, then search your bundle for a secret key. Those four checks cover the exposure behind CVE-2025-48757 and the mistakes that need no skill to find.
[Sources]
Every check links to the documentation behind it. All 27 sources below were opened on Oct 7, 2026.