Supabase RLS checklist for Lovable, Bolt, and Cursor apps
· PlugLeak
A practical Row Level Security checklist for Supabase apps built with Lovable, Bolt, or Cursor: grants, real policy SQL, how to test as a stranger, and what a passive scan cannot see.
If a Lovable, Bolt, or Cursor app talks to Supabase from the browser, the publishable key (or the older anon key) is in the JavaScript. That is how the Supabase data API is meant to work. The key identifies the project. Grants and Row Level Security decide which rows a stranger can read. Lovable's integration docs call missing RLS policies the most common way app data gets exposed, and they tell you to verify policies yourself in the Supabase dashboard before you go live (Connect to Supabase). The queries and SQL below are that verification. A green badge in a builder is not.
What has to be true before a policy matters
Postgres runs two checks, in order. Grants decide whether a role may touch the table at all. Policies decide which rows that operation applies to. Supabase documents both in Row Level Security and Securing your API. A policy on a table the role cannot reach never runs. A grant without RLS, on a table in an exposed schema, is how a table becomes public.
Supabase maps requests like this (API keys):
- A publishable or anon key, with nobody signed in, uses the
anonPostgres role. - The same key, with a signed-in Supabase Auth user, uses the
authenticatedrole. The user's own JWT is whatauth.uid()reads. - A secret key or legacy
service_rolekey uses theservice_rolerole. That role hasBYPASSRLS. Policies do not apply. It belongs on a server you control, never in the browser.
On existing projects, Supabase says a new table in public can start with every privilege already granted to anon, authenticated, and service_role. Adding policies does not take those grants back. Not every project grants them automatically, so read the grants instead of assuming. The dashboard SQL editor is also the wrong place to "see if it looks locked": the table owner and roles with BYPASSRLS are not subject to policies, which is why the same select can still return rows there (Tables and data).
Before you touch SQL, run the free PlugLeak scan on the live URL. It is a passive read of the HTML and same-origin JavaScript, plus one request each to /.env and /.git/config, security headers, and the TLS certificate. If a JWT in that JavaScript has role: service_role, or the bundle contains an sb_secret_ key, RLS is not protecting visitors who copy that key. The scan does not connect to your database and cannot tell you whether a policy is correct. That is the rest of this page. If the scan does flag a service-role key, fix that first using the service_role guide, then come back.
1. List tables in public and whether RLS is on
In the Supabase SQL editor, list ordinary tables. relrowsecurity is the Postgres flag behind "RLS enabled".
select c.relname as table_name,
c.relrowsecurity as rls_enabled
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
and c.relkind = 'r'
order by 1;Any row with rls_enabled = false needs a decision. Supabase's warning is direct: a table in an exposed schema without RLS is readable and writable by any role that has a grant on it. Enable RLS on every table in an exposed schema. The Data API exposes the public schema by default.
Then list the grants the browser roles still hold:
select table_name, grantee, privilege_type
from information_schema.role_table_grants
where table_schema = 'public'
and grantee in ('anon', 'authenticated')
order by table_name, grantee, privilege_type;anon should keep a privilege only when a signed-out visitor is supposed to do that operation. A marketing "announcements" table might keep SELECT. A tasks table of private user rows should not. TRUNCATE, REFERENCES, and TRIGGER do not belong on either client role.
2. Turn RLS on, then grant only what the app does
Supabase's procedure for every exposed table is: enable RLS, revoke the client roles, grant back the operations the app actually uses, then write one policy per operation. This is the private-table version, adapted from their profiles example. Change tasks and user_id to your columns. Run it in a migration if you have one, so the next environment gets the same rules.
alter table public.tasks enable row level security;
revoke all on table public.tasks from anon, authenticated;
grant select, insert, update, delete on table public.tasks to authenticated;After enable row level security, Supabase says no data is accessible through the API with a publishable key until you create policies. That empty result is the closed state. It is also why a later AI edit often "fixes" a blank screen by adding a policy of using (true). The app starts working and the table is public.
A missing grant fails with SQLSTATE 42501 before any policy runs. If a request that your policy should allow fails that way, check the grant before you loosen the policy.
For a table signed-out visitors may read and nobody may change from the client:
alter table public.announcements enable row level security;
revoke all on table public.announcements from anon, authenticated;
grant select on table public.announcements to anon, authenticated;
create policy "Anyone can read announcements"
on public.announcements
for select
to anon, authenticated
using (true);using (true) is correct only when every row is meant to be public. Supabase says a policy of to anon using (true) grants every unauthenticated visitor read access to every row that role can already reach. Lovable's quick scan flags access rules that let everyone through (Security overview). Keep a true policy only after you have named the table as public on purpose.
3. One policy per operation, scoped to the user
Postgres does not accept several operations in one for clause. Supabase asks you to write separate select, insert, update, and delete policies, and to name the role with to. A for all policy hides which operation you meant. Naming to authenticated stops the expression from running for anon at all.
auth.uid() returns the id of the user making the request. Wrapped as (select auth.uid()), Postgres can evaluate it once per statement instead of once per row. Supabase's current examples use that form. When nobody is signed in, auth.uid() is null, and null = user_id is not true, so a comparison policy does not match a signed-out request. The to authenticated clause makes that intention obvious instead of relying on the null.
create policy "Users can read their own tasks"
on public.tasks
for select
to authenticated
using ((select auth.uid()) = user_id);
create policy "Users can insert their own tasks"
on public.tasks
for insert
to authenticated
with check ((select auth.uid()) = user_id);
create policy "Users can update their own tasks"
on public.tasks
for update
to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
create policy "Users can delete their own tasks"
on public.tasks
for delete
to authenticated
using ((select auth.uid()) = user_id);
create index tasks_user_id_idx on public.tasks using btree (user_id);Read those four lines as four different questions:
selectusesusing. It is the filter on rows the user may see. Supabase describes this as aWHEREthe database adds for you.insertuseswith check. It rejects a new row whoseuser_idis someone else.updateneeds both.usingdecides which existing rows can change.with checkdecides what the row is allowed to look like afterward, so a user cannot reassignuser_id. If you omitwith check, Postgres reusesusingfor the new row. Supabase also warns that anUPDATEdoes not behave as you expect without aSELECTpolicy.deleteusesusingonly.
Index the column the policy filters on. Supabase notes that a composite primary key only indexes its first column, so a membership table keyed on (team_id, user_id) still needs its own index if policies filter on user_id.
Do not store authorization in user_metadata. Supabase warns that raw_user_meta_data can be changed by the signed-in user through the auth API, so a policy that trusts auth.jwt() -> 'user_metadata' trusts data the user can edit. Keep role and tenant ids in a column you control, or in app metadata that users cannot write.
4. Read the policies you already have
Generated apps often already have policies. List them before you add more. Overlapping permissive policies are combined with or, so one using (true) policy opens the operation even if a stricter policy sits beside it.
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
order by tablename, policyname;qual is the using expression. with_check is the write check. A qual of true on a table of private rows is the bug. cmd of ALL means the policy was written for all.
On a Lovable project that uses the built-in database, the same review is in the product UI: More → Database → Manage → Policies. Lovable shows the policy name, the command (SELECT, INSERT, UPDATE, DELETE), who it applies to, and the SQL expression. That view is read-only; Lovable says to change a policy from the project chat, then read it again (Control who can access your app's data). If the project is connected to a Supabase project you own, check the Supabase dashboard as well. Lovable's integration docs still tell you to verify there yourself.
5. Views and security-definer functions
A view does not automatically inherit the policies on the tables under it. Supabase says views bypass RLS by default because Postgres creates them as security definer. A view over a protected table can return rows the table policies were written to hide. On Postgres 15 and above, create the view so it runs with the caller's rights:
create view public.my_tasks
with (security_invoker = true)
as
select id, title, user_id
from public.tasks;On older Postgres, Supabase's alternative is to revoke anon and authenticated on the view, or to put the view in a schema the Data API does not expose.
RLS also does not apply to functions. A function marked security definer runs as its owner. In an exposed schema it can be called through the Data API as /rest/v1/rpc/<name> when anon or authenticated has EXECUTE. List the ones in public:
select p.proname
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
and p.prosecdef
order by 1;For each name, decide whether a signed-out visitor should be able to call it. If not, revoke execute from anon (and from public, which is how the privilege often arrives). Read the function body. A definer function that forgets to filter on auth.uid() is a second door around the policies you just wrote.
6. Storage is a second table
Files live in storage.objects, and Supabase applies RLS there too (Storage access control). A public bucket bypasses access checks for retrieving files: anyone with the asset URL can download it. Uploads, deletes, moves, and copies still need policies. Private buckets stay private, including downloads, unless a policy or a signed URL allows them (Storage buckets). Profile photos are a common public bucket. Invoices and exports are not. The free scan does not open buckets or guess object URLs, and a service key bypasses storage policies the same way it bypasses table policies.
7. Prove it as a signed-out visitor
The SQL editor will keep showing rows. Test the API the browser uses. Put a row in tasks while signed in as user A. Then, in a private window where nobody is signed in, the app should not show that row. Sign in as user B and confirm B cannot read or edit A's row either. Lovable's best-practices page asks for the same check before publish: users cannot access other users' private data, shared data behaves as intended, and new tables are not missing policies (Security best practices for Lovable apps).
You can do the signed-out check with curl. Use the publishable or anon key, never the secret key. The Data API requires the apikey header.
curl "https://PROJECT_REF.supabase.co/rest/v1/tasks?select=id,user_id" \
-H "apikey: PUBLISHABLE_OR_ANON_KEY" \
-H "Authorization: Bearer PUBLISHABLE_OR_ANON_KEY"If that returns user A's row, RLS is off or a select policy matches anon. An empty array means this key cannot see rows, or the table is empty. Confirm the row exists while you are signed in as the owner before you treat empty as success. A 42501 or permission error means the grant is missing, which is what you want for anon on a private table.
To test the signed-in path, keep apikey as the publishable key and send user B's access token as Authorization: Bearer. You can copy that token from the session in the browser's application storage for your own test user. Do not send someone else's token, and do not paste tokens into a third-party decoder site.
If you use the Supabase CLI, add a pgTAP file under supabase/tests/ and run supabase test db. The RLS guide says that until that suite passes, you do not know the policies do what you intended. The curl above is the minimum when you only have the hosted project.
8. Repeat after the next prompt
The usual miss is the next feature, not the first schema. A prompt that adds a table, a view, or an admin screen can ship RLS off, a policy of true, or a client call that needs the secret key. Lovable says to review policies before publishing and after authentication or schema changes. Bolt and Cursor will not do that for you. Re-run the queries in sections 1 and 4 after each schema change.
Then scan the deployed URL again with PlugLeak. The free scan still will not grade policies. It will tell you whether that deploy put a service_role JWT, an sb_secret_ key, a Stripe or model-provider secret, a private key, or a public /.env or /.git/config in files a visitor can download, and it checks security headers and the certificate. A clean scan plus the SQL on this page is a launch check. It is not a security audit.
Sources
- Supabase: Row Level Security
- Supabase: Securing your API
- Supabase: Securing your data
- Supabase: API keys
- Supabase: Tables and data
- Supabase: Storage access control
- Supabase: Storage buckets
- Lovable: Connect to Supabase
- Lovable: Security overview
- Lovable: Control who can access your app's data
- Lovable: Security best practices
Check your app now — free, passive, about 20 seconds.
Same scanner as the homepage scan. Paste the live URL. It reads what a visitor already downloads. It does not log in or query your database.