Supabase RLS Checklist: Policies, Tests and the Service Role Key
A safe Supabase setup needs row-level security enabled on every table in an exposed schema, grants that give clients only what they need, a separate policy for each operation, ownership checked with both using and with check, no authorisation data that users can edit, functions, views, realtime and storage covered, the secret or service_role key kept off the client, and tests that prove two users cannot reach each other's rows. Enabling RLS is the start, not the finish: a policy of true passes a glance and protects nothing.
This checklist is for anyone building on Supabase, whether by hand, with a framework, or through an AI builder such as Lovable or Bolt. The SQL follows Supabase's own documentation, checked on 20 October 2026. One date matters this month: from 30 October 2026, Supabase stops granting API access to new tables automatically on existing projects. Item 3 explains what that changes.
At a glance
| Item | Rule |
|---|---|
| Which tables | Every table in an exposed schema, usually public |
| Grants | New tables need explicit grants on new projects since 30 May 2026 and on all projects from 30 October 2026 |
| Policies | One per operation: select, insert, update, delete |
| Who | Always name the role with to authenticated (or anon) |
| Ownership | using checks the existing row; with check checks the new one |
| Updates | Need a matching select policy, or they silently do nothing |
| Teams | A membership table, read through a security definer function |
| Performance | Wrap auth.uid() as (select auth.uid()) and index the columns policies filter on |
| Authorisation data | In app_metadata or your own tables, never user_metadata |
| Functions | security definer only in a private schema, with search_path = '' and execute revoked from public |
| Views | security_invoker = true, or keep them out of exposed schemas |
| Realtime | Postgres Changes follow RLS; Broadcast and Presence need realtime.messages policies |
| Keys | Publishable/anon in the client; secret/service_role only on a server |
| Tests | pgTAP with supabase test db, plus a manual two-user test |
| Automated check | Supabase's Security Advisor |
Why RLS carries so much weight
When a browser or mobile app calls Supabase with the publishable (or legacy anon) key, the request runs as the anon role, or as authenticated once a user signs in. Supabase's API key guide says these keys are fine to ship in public code, provided RLS is on. That makes your policies the only thing between any visitor and your tables.
This is not theoretical. CVE-2025-48757 records apps generated by Lovable whose tables could be read or written without logging in because their RLS was insufficient. Lovable disputes the record, and the cause is not specific to Lovable: any Supabase project with the same policies has the same exposure. More on that case in is Lovable safe?
Grants and policies are two different locks
It helps to keep two layers apart, because Supabase's October change makes the difference visible:
- Grants decide whether a role (
anon,authenticated,service_role) may touch a table at all, and for which operations. - Policies decide which rows that role sees or changes, once a grant lets it in.
A request needs both. A missing grant gives a loud error (42501, "permission denied for table"). A missing policy, with RLS on, gives a quiet one: no rows. A policy of true, or RLS off, gives no error at all, which is why it is the dangerous one.
The checklist: tables and grants
1. Find every table where RLS is off
Start with an inventory. This query uses Postgres's own catalogue and lists every table in public with its RLS state:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by rowsecurity, tablename;
Any row with rowsecurity = false is a table the API can read with the public key, if a grant allows it. Supabase's Security Advisor reports the same thing as 0013_rls_disabled_in_public.
2. RLS enabled on every table in an exposed schema
Supabase's RLS guide is blunt: "Enable RLS on every table in an exposed schema." Check new tables especially, including ones created by a migration or an AI prompt. The same guide points to event triggers if you want RLS switched on automatically for every new table.
alter table public.reports enable row level security;
3. Grants given deliberately, not by default
Until this year, a new table in public started with select, insert, update and delete granted to anon, authenticated and service_role, and the guide warns that "adding policies doesn't take those grants back." Supabase's changelog changes that: "New tables in the public schema will no longer be exposed to the Data API automatically." It became the default for new projects on 30 May 2026 and is applied to all existing projects on 30 October 2026. Existing tables keep their current grants; direct Postgres connections are not affected.
What to do:
- For new tables, add explicit grants in the same migration that creates the table, giving each role only what it needs. If
anonshould never read it, do not grant it. - For existing tables, the old broad grants are still there. Revoke what clients should not have.
- If a new table answers
42501, that is the change working. The error's hint names the grant; add it on purpose, then check the policies.
-- A table only signed-in users touch:
revoke all on table public.reports from anon, authenticated;
grant select, insert, update, delete on table public.reports to authenticated;
The checklist: policies
4. One policy per operation, with the role named
Postgres does not accept several operations in one for clause, so write four policies, and always say who they apply to. Naming to authenticated also stops the policy running at all for signed-out users.
create policy "Users can view their own profile."
on profiles for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Users can create their own profile."
on profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Users can update their own profile."
on profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Users can delete their own profile."
on profiles for delete
to authenticated
using ( (select auth.uid()) = user_id );
5. with check on every insert and update
using decides which existing rows a user may touch. with check decides what the row may look like afterwards. Without it, a user can insert a row claiming to belong to someone else, or update their own row to move it to another owner.
6. Updates have a matching select policy
Supabase's guide: "To perform an UPDATE operation, a corresponding SELECT policy is required." Without one, the update matches no rows and returns no error, which is confusing in development and tempts people into loosening the wrong policy.
7. No policy that just says true, or only checks login, on private data
using (true) is right for genuinely public content, such as published blog posts. On anything private it is the same as RLS being off. Its quieter cousin is using (auth.uid() is not null): it keeps out anonymous visitors but lets every signed-in user read every row. Search your migrations for both, and for policies that compare against a value the client sends.
8. Team and organisation access goes through a membership table
Most B2B apps need "members of the same team can see this", which per-user policies cannot express. Supabase's guide shows a membership table keyed on team and user, read through a security definer function so that policies on the membership table itself do not recurse. A sketch of that pattern:
create table public.team_members (
team_id uuid not null references public.teams (id) on delete cascade,
user_id uuid not null references auth.users (id) on delete cascade,
role text not null default 'member',
primary key (team_id, user_id)
);
create index team_members_user_id_idx on public.team_members (user_id);
alter table public.team_members enable row level security;
create schema if not exists private;
grant usage on schema private to authenticated;
create function private.my_team_ids()
returns setof uuid
language sql
stable
security definer
set search_path = ''
as $$
select team_id from public.team_members
where user_id = (select auth.uid());
$$;
revoke execute on function private.my_team_ids() from public;
grant execute on function private.my_team_ids() to authenticated;
create policy "Team members can view projects"
on public.projects for select
to authenticated
using ( team_id in (select private.my_team_ids()) );
Then decide, and test, who may add members. If ordinary members can insert rows into team_members, any member can add themselves to any team. Membership changes usually belong in server code or a policy limited to team admins.
The alternative the guide also shows is reading team IDs from app_metadata in the JWT (item 10). It avoids a lookup, but a removed member keeps access until their token refreshes.
9. Columns users must not change are protected
A user who may update their own profiles row may also be able to update its role, plan or credits column. Keep those in a separate table that clients cannot write, or change them only from server code. Row-level security works on whole rows, which is why the researcher's write-up of the Lovable CVE recommends splitting sensitive and non-sensitive data into tables with their own policies.
10. Authorisation never read from user_metadata
The guide warns that raw_user_meta_data "can be updated by the authenticated user" and "is not a good place to store authorization data." Use app_metadata, which users cannot edit, or your own tables:
create policy "User is in team"
on my_table
to authenticated
using ( team_id in (select auth.jwt() -> 'app_metadata' -> 'teams') );
Remember that a JWT is not refreshed instantly, so a removed team membership can linger until the token renews. Security Advisor flags policies that read user metadata as 0015_rls_references_user_metadata.
The checklist: functions, views, realtime and storage
11. Helper functions are security definer done properly
For role checks that need another table, Supabase shows a security definer function in a private schema, with an empty search_path and schema-qualified names. The guide warns that "a security definer function in an exposed schema is callable over the Data API with the creator's privileges", which makes it a way around every policy.
create function private.has_good_role()
returns boolean
language plpgsql
security definer
set search_path = ''
as $$
begin
return exists (
select 1 from public.roles_table
where (select auth.uid()) = user_id and role = 'good_role'
);
end;
$$;
create policy "rls_test_select"
on test_table
to authenticated
using ( (select private.has_good_role()) );
12. Functions the API can call check permissions themselves
Postgres lets everyone execute a new function unless you revoke it. Any function in public is reachable as an RPC call from the browser. For each one:
- revoke execute from
publicand grant it only to the roles that need it, as the guide's example does; - if it is
security definer, move it out of the exposed schema, or make it checkauth.uid()and ownership before doing anything; - validate its arguments, because the caller chooses them.
revoke execute on function public.archive_project(uuid) from public;
grant execute on function public.archive_project(uuid) to authenticated;
13. Views do not route around the policies
"Views bypass RLS by default because they are usually created with the postgres user." On Postgres 15 and later, create them like this; on older versions, revoke access from anon and authenticated or move them to a schema the API does not expose. Security Advisor reports the risky kind as 0010_security_definer_view.
create view public.my_reports
with (security_invoker = true)
as select * from public.reports;
14. Realtime channels are authorised too
Supabase's Realtime authorization guide says that for Postgres Changes, "database records are sent only to clients who are allowed to read them based on your RLS policies", so your table policies already apply. Broadcast and Presence are different: they are controlled by policies on realtime.messages, and only on private channels. To enforce them, turn off "Allow public access" in Realtime settings and create channels with private: true on the client. Permissions are checked when a client joins and cached for the connection, so revoking access takes effect when the JWT expires or a new one is sent.
15. Storage buckets have policies too
Files live in storage.objects, which has its own RLS. Supabase's storage access control guide says uploads are refused by default unless a policy allows them, and that public buckets need no policy to be read. Keep private documents out of public buckets, and scope uploads to a folder named after the user:
create policy "Users upload to their own folder"
on storage.objects for insert
to authenticated
with check (
bucket_id = 'documents'
and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);
Add matching select, update and delete policies for the same folder.
16. Policies are fast enough to keep
Wrap auth functions in select so Postgres evaluates them once per statement rather than once per row, and index the columns policies filter on. Supabase warns that "an unindexed filter column turns a read into a sequential scan."
create index profiles_user_id_idx
on public.profiles
using btree (user_id);
The checklist: keys and the project around the policies
17. Secret and service_role keys stay on a server
Supabase's secret keys (sb_secret_...) and the legacy service_role key run with BYPASSRLS: no policy applies to them. The API key guide says to use them only in backend code you control, such as servers with their own authorisation checks, Edge Functions and scheduled jobs, and "never put one in a browser, a shipped application, or source control."
Four consequences people miss:
- Server code with a secret key must check permissions itself. RLS is no longer protecting it. An Edge Function that takes a record ID and updates it with the secret key must first confirm the caller owns that record.
- Bundlers publish prefixed variables. Anything named
VITE_orNEXT_PUBLIC_ends up in the JavaScript sent to the browser. A secret key there is public. - The browser block is not protection. The guide says Supabase refuses a secret key sent from a browser, matching on the
User-Agentheader, with HTTP 401. An attacker who copies the key uses another tool, so a key that reached a browser must still be deleted. - A leaked key needs rotating, not just deleting from the code. The guide's order: fix the cause, create a new secret key, replace it everywhere, confirm, then retire the old one. Use a separate secret key per backend component so one leak affects one component.
The same guide says Supabase is deprecating the legacy anon and service_role keys by the end of 2026, in favour of publishable (sb_publishable_...) and secret keys. Legacy keys keep working until you disable them, so plan the switch rather than waiting for it.
18. Auth and project settings are hardened
Policies protect rows; these settings protect accounts and the database around them. From Supabase's production checklist:
- enable email confirmations;
- set the OTP expiry to 3,600 seconds (one hour) or lower;
- turn on CAPTCHA protection for sign-up, sign-in and password reset;
- use a custom SMTP server for auth emails;
- review the rate limits on auth endpoints;
- enable SSL enforcement and network restrictions for the database;
- protect the Supabase account itself with multi-factor authentication.
The same page notes that database backups are not available for download on the Free plan, so keep an export of your own if you are on it.
How to test your policies
Automated: pgTAP
Supabase's RLS guide documents pgTAP tests under supabase/tests/, run with supabase test db, and says plainly: "Until the suite passes, you don't know whether the policies do what you intended." Inside a test, switch role and identity like this, then assert what that user sees:
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
Write at least these cases for every private table:
| Who | Select | Insert | Update | Delete |
|---|---|---|---|---|
| Signed out | No rows | Refused | No effect | No effect |
| Owner | Own rows | Own rows only | Own rows, cannot change owner | Own rows |
| Second signed-in user | No rows of the owner's | Cannot insert as the owner | No effect on the owner's rows | No effect on the owner's rows |
| Team member (if teams) | Team rows only | Into own team only | As designed | As designed |
| Ordinary user on role or plan columns | As designed | Refused | Refused | Refused |
Supabase's guide also points to the supabase-test-helpers package, which adds helpers such as tests.authenticate_as(). Run the suite in CI on every migration.
Manual: two users and a logged-out request
On your own project only:
- Make two test accounts. As user A, change an ID in the URL to user B's record, and try to view, edit and delete it.
- As an ordinary user, try to update your own role or plan.
- Call each RPC function signed out and as the wrong user.
- With no session, request a private table directly with the publishable key:
curl "https://YOUR-PROJECT.supabase.co/rest/v1/profiles?select=*" \
-H "apikey: YOUR-PUBLISHABLE-KEY"
An empty array is the right answer for a private table. Rows mean the policy is wrong.
The Security Advisor
Supabase's Security Advisor, in the dashboard or via supabase db advisors, flags known problems, including 0013_rls_disabled_in_public (a table anyone with the project URL can access), 0007_policy_exists_rls_disabled, 0010_security_definer_view, 0015_rls_references_user_metadata and 0024_permissive_rls_policy. Clear every error, and read the warnings. It checks configuration, not intent: it cannot know who should see your invoices.
Common mistakes
- Enabling RLS and stopping there. The app breaks, someone adds
trueto fix it, and the table is open. auth.uid() is not nullas an ownership check. It only proves the caller is logged in.- An update policy with no
with check. The owner can hand a row to someone else, or take one. - Testing as the dashboard user. The SQL editor runs as
postgres, the owner of tables created there, and RLS does not apply to a table's owner by default. Test asanonandauthenticated. - A
security definerfunction inpublic. It is an API endpoint that ignores your policies. - Trusting the JWT for instant revocation. Membership in
app_metadatalasts until the token refreshes. - Assuming views and storage follow table policies. Each has its own rules.
- Loosening grants to silence
42501after 30 October. Grant what the feature needs, to the role that needs it.
If you built with an AI tool
Lovable Cloud is built on Supabase, and Lovable and Bolt can both use your own Supabase project, so everything above applies. The tool-specific notes are in the guides for Lovable and Bolt. Watch for policies an AI wrote to make an error disappear: they are often true. If something stopped working after a prompt, what to do when a Lovable app stops working covers the empty-query and permission errors from the other side.
When not to hire us: if your app has one user type, owner-only data and you have passed the manual tests and the Security Advisor, you probably do not need a review. Keep the tests running.
If your model has teams, organisations, shared records or admin roles, that is where policies get subtle. The €99 Prototype Check has a person read the policies and test them against the live app, with the top risks in writing within 48 hours of payment and access, then a 15-minute call. The €99 is credited against the build if you hire us. We are based in Budapest, working remotely across Europe on CET, with our engineering team in India.
Work with us
Want someone to read your RLS policies against the live app?
We are a Budapest team that takes AI-built and fast-built apps to production for founders across Europe, working remotely on CET. The €99 Prototype Check includes a person testing your Supabase policies against the live app, with the top security and data risks in writing within 48 hours. Send the repository and live URL, and we reply within one business day.
Frequently asked