Skip to content

Supabase RLS Checklist: Policies, Tests and the Service Role Key

15 min read
Isometric illustration of a person placing a code block on a laptop screen, with a shield and padlock, a cloud upload icon and gears around it

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 anon should 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 public and 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 check auth.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_ or NEXT_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-Agent header, 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:

  1. 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.
  2. As an ordinary user, try to update your own role or plan.
  3. Call each RPC function signed out and as the wrong user.
  4. 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 true to fix it, and the table is open.
  • auth.uid() is not null as 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 as anon and authenticated.
  • A security definer function in public. It is an API endpoint that ignores your policies.
  • Trusting the JWT for instant revocation. Membership in app_metadata lasts until the token refreshes.
  • Assuming views and storage follow table policies. Each has its own rules.
  • Loosening grants to silence 42501 after 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

Is enabling RLS on a Supabase table enough?
No. Enabling RLS with no policies blocks access through the API, which is safe but useless. Enabling it with a policy of true lets everyone through, which looks configured but protects nothing. You need a policy per operation that limits rows to the right users, and a test that proves it.
Is RLS enabled by default in Supabase?
Do not rely on it. Supabase's guide says to enable RLS on every table in an exposed schema and points to an event trigger if you want it switched on automatically for new tables. Tables created by raw SQL, migrations or an AI tool are the ones to check. A query on pg_tables lists every public table where it is off.
What changes in Supabase on 30 October 2026?
Supabase's changelog says new tables in the public schema are no longer exposed to the Data API automatically. That became the default for new projects on 30 May 2026 and applies to all existing projects on 30 October 2026. Existing tables keep their grants. New tables need explicit GRANT statements, and RLS still decides which rows each role can see.
Can I put the Supabase anon or publishable key in my front end?
Yes. Supabase's API key guide says publishable and anon keys are safe in browsers, mobile apps and public source code, provided RLS is enabled on every table. The secret and service_role keys are different: they bypass RLS and belong only in backend code you control. Supabase also says the legacy anon and service_role keys are being deprecated by the end of 2026.
Why does my Supabase update silently do nothing?
Supabase's RLS guide says an UPDATE needs a matching SELECT policy as well. Without one, the update affects no rows and returns no error. Add a select policy for the same users, then check the update policy has both using and with check clauses.
Do Supabase views and realtime respect row-level security?
Views do not by default, because they are usually created by the postgres user; on Postgres 15 or later create them with security_invoker set to true. Realtime Postgres Changes are sent only to clients allowed to read the rows under your policies. Broadcast and Presence need their own policies on realtime.messages and private channels.
How do I test Supabase RLS policies automatically?
Supabase supports pgTAP tests in supabase/tests, run with supabase test db. Inside a test you switch to the anon or authenticated role with set local role, and set the user with set local request.jwt.claim.sub, then assert what that user can and cannot read or write.
Does this checklist apply to Lovable and Bolt apps?
Yes. Lovable Cloud is built on Supabase, Lovable can also use your own Supabase project, and Bolt can connect to Supabase. Any app whose front end or server code queries Supabase with the public key depends on these policies.

Want this done properly on your own site?

Start a conversation ↗