Supabase RLS: how row level security works, the policies you need, and how to test them

Supabase RLS (row level security) is a Postgres feature that attaches policies to a table and applies them to every query, so each caller only reads and changes the rows a policy allows. Your app's public key reaches the database straight from the browser, so RLS is your access control. Enable it on every exposed table, write one policy per operation, then test as a stranger and as a second account.

The team behind Keybrum's attacksUpdated 16 min read

In short

  • With RLS on and no policy, a table is closed to anon and authenticated. Policies for the same operation combine with OR, so one leftover using (true) overrides every careful policy next to it.
  • Grants and RLS are separate locks. Grants decide whether a role may touch a table, policies decide which rows. Adding a policy never takes a grant back.
  • Signed in does not mean allowed. With open sign-ups, a select policy to authenticated using (true) is readable by any stranger who makes an account, and Security Advisor's always-true check skips select policies.
  • Views and security definer functions run with their owner's rights and skip RLS. Set security_invoker = true on views, and keep those functions in a schema the API does not expose, with execute revoked from the API roles.
  • Test from outside: call the API with the public key alone, then as a second account trying to read and change the first account's rows. The SQL editor runs as postgres and bypasses RLS.
On this page

What is RLS in Supabase?

Row level security (RLS) is a Postgres feature that attaches rules, called policies, to a table. Postgres checks them on every query and for every row: a select returns only the rows a policy lets the caller see, and an insert, update or delete only touches rows a policy allows. Supabase's documentation describes a policy as a WHERE clause added to every query. With the policy below, select * from notes behaves as if it ended with where user_id = auth.uid(), whatever the app asked for.

sql
create policy "Users read their own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );

It matters more in Supabase than in most stacks because no server of yours usually sits between the browser and the database. Your frontend calls the Data API (PostgREST, at /rest/v1/) with the project's public key: the anon key or its replacement, the publishable key. Anyone can copy that key from your JavaScript and send their own queries. Whatever your policies allow, they get, and that includes everything your policies forgot.

RoleWhen Supabase uses itDoes RLS apply?
anonA request with the public key and no signed-in userYes
authenticatedA request with a user's access token, including anonymous sign-ins and any account a stranger createsYes
service_roleYour server, with a secret key or the legacy service role keyNo, it has bypassrls
postgresQueries you run in the dashboard, including the SQL editorNo, it has bypassrls
The last two rows are why a query that works in the SQL editor proves nothing about what your users can reach.

Grants and RLS: two locks, checked in order

Postgres checks two things before a request touches a table. Grants (privileges) decide whether a role may run select, insert, update or delete on the table at all. Policies decide which rows that operation reaches. A missing grant fails at once with error 42501 (permission denied), before any policy runs. Adding a policy never removes a grant, so a table protected only by policies still has every grant it was given.

For years, every new table in public came with every privilege granted to anon and authenticated, which left RLS as the only lock that mattered. Supabase is ending that default: new projects stopped receiving those grants in a rollout that began on 30 May 2026, and existing projects were scheduled to follow on 30 October 2026. Tables that already existed keep their grants. So on most apps shipped before mid-2026, the grants are wide open and RLS does all the work. On an older project, you can turn off Automatically expose new tables in the Data API settings yourself. The first query in the test section below shows where your project stands.

How to enable RLS in Supabase

  1. 1

    In the dashboard

    Tables you create in the Table Editor have RLS switched on by default. For a table that does not, open Database, then Policies, find the table and click Enable RLS. The same page lists every policy per table.

  2. 2

    In SQL or a migration

    Tables created in the SQL editor, by a migration, by an ORM or by a coding agent get no RLS unless the SQL says so. Put the statements below in the same migration as the create table, so the table is never exposed without them.

  3. 3

    For every future table

    Supabase's event trigger guide includes a function, rls_auto_enable, that turns RLS on for each table created after you install it, in the schemas it lists. It does nothing for tables that already exist, so still run the audit query below.

sql
alter table public.notes enable row level security;

-- Take back what the API roles got by default,
-- then give back only what the app does.
revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;
Grant only what the app does. A table the browser only reads gets grant select and nothing more.

With RLS on and no policy, the table is closed to anon and authenticated: Postgres denies by default. Your app will show empty lists until you add policies, and this is the risky moment. The two quick ways to make the errors go away, disable row level security or a policy of using (true), are also exactly how data leaks.

Supabase RLS policy examples: five patterns most apps need

Write one policy per operation, name the role with to, and wrap auth.uid() in a select (the performance section explains why). auth.uid() returns the id of the signed-in user, or null for a visitor. We ran each pattern below on Supabase's Postgres 17 image as the owner, as a second account and as anon before publishing it.

OperationClauseWhat it checks
selectusingWhich existing rows the caller can see
insertwith checkWhether the new row is allowed
updateusing and with checkWhich rows can change, and what the changed row may look like
deleteusingWhich rows the caller can remove
An update also needs a select policy that lets the user see the row. Without with check, Postgres reuses the using expression for the new row.

1. Owner only: each user reads and changes their own rows

sql
create policy "Owners read their notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Owners add notes for themselves"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Owners edit their notes"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "Owners delete their notes"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );

create index notes_user_id_idx on public.notes (user_id);

This is the base for notes, profiles, carts and anything else that belongs to one person. The with check on insert and update stops a user from writing a row that carries somebody else's user_id.

2. Public read, owner write

sql
grant select on table public.posts to anon, authenticated;

create policy "Anyone reads published posts"
on public.posts for select
to anon, authenticated
using ( status = 'published' );

create policy "Authors read their own drafts"
on public.posts for select
to authenticated
using ( (select auth.uid()) = author_id );

Everyone reads published posts; only the author reads drafts. The two select policies combine with OR. For writes, reuse pattern 1 with author_id.

3. Insert with check: users create rows, but only safe ones

sql
grant insert on table public.comments to authenticated;

create policy "Users post comments that wait for review"
on public.comments for insert
to authenticated
with check (
  (select auth.uid()) = author_id
  and status = 'pending'
);

with check can enforce more than ownership. Here a user can post a comment only as themselves and only as pending, so nobody approves their own comment by sending "status": "approved". Some rows should not be insertable from the browser at all: orders, payments, credits, subscriptions. Leave those tables without an insert policy and write them from your server once the payment succeeds. In a follow-up to the research behind CVE-2025-48757, the researcher inserted a record with payment_status set to paid as an anonymous visitor, bypassing the app's Stripe integration.

4. Team membership

sql
create table public.team_members (
  team_id uuid references public.teams (id) on delete cascade,
  user_id uuid references auth.users (id) on delete cascade,
  primary key (team_id, user_id)
);
alter table public.team_members enable row level security;
-- The primary key indexes team_id first. Policies look up user_id.
create index team_members_user_id_idx on public.team_members (user_id);

-- Members see their own memberships. No insert policy: your server adds members.
create policy "Members see their own memberships"
on public.team_members for select
to authenticated
using ( (select auth.uid()) = user_id );

create schema if not exists private;

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 usage on schema private to authenticated;
grant execute on function private.my_team_ids() to authenticated;

create policy "Members read their team's projects"
on public.projects for select
to authenticated
using ( team_id in (select private.my_team_ids()) );

The helper is security definer, so it reads team_members with its owner's rights. That also avoids the 42P17 error (infinite recursion in policy) you get when two tables' policies read each other. It sits in a private schema that the API does not expose, with search_path pinned to an empty value, as Supabase recommends. The join table needs RLS like any other: in our test, without it, anon could read every membership, and a signed-in stranger could add themselves to any team and read its projects. Leave it without an insert policy, so memberships come from an invite your server accepts. public.projects needs RLS on too, as in the enable step above.

5. Admin access through a custom claim

sql
create policy "Customers read their own orders"
on public.orders for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Admins read every order"
on public.orders for select
to authenticated
using ( ((select auth.jwt()) -> 'app_metadata' ->> 'role') = 'admin' );

-- Make someone an admin (SQL editor or your server, never the browser)
update auth.users
set raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) || '{"role": "admin"}'::jsonb
where id = 'THE_ADMIN_USER_ID';

Policies for the same operation combine with OR, so customers keep seeing their own orders and admins see all of them. The role lives in app_metadata, which users cannot edit. Never read it from user_metadata: a signed-in user can change that from the browser, and Security Advisor flags policies that rely on it. A role change reaches the policy only when the user's token refreshes. For several roles and permissions, Supabase's RBAC guide adds the claim with a custom access token hook instead.

The Supabase RLS mistakes that leak data

Each one looks fine at a glance in the dashboard.

RLS off on tables created by SQL

The Table Editor turns RLS on; a create table in a migration does not. Coding agents create tables that way, so check the newest ones first.

using (true) for signed-in users

A select policy to authenticated using (true) reads as members only. With open sign-ups, membership costs an email address, and with anonymous sign-ins it costs nothing, since anonymous users get the authenticated role too. Supabase's own examples use this shape for profiles that are meant to be public. Copied onto orders, messages or invoices, it publishes them. Security Advisor's always-true check looks for true on insert, update and delete policies and skips select on purpose, because a public read is often intended, so this mistake does not show up there.

A leftover policy that opens everything

Permissive policies, the default kind, combine with OR. If a table has a careful owner policy and an old using (true) policy from an earlier prompt, the old one wins for every row. When you fix a table, list all of its policies.

Update and delete forgotten, then opened with for all

A table with only select and insert policies refuses updates silently: the update matches zero rows and returns no error, so the save button seems broken. The tempting fix is a single for all policy with using (true), which hands update and delete on every row to every signed-in user. Write the update and delete policies explicitly, with the owner check from pattern 1.

Update policies protect rows, not columns

An owner-only update policy on profiles lets a user change every column of their own row, including role, plan, credits or is_admin if those live there. Move them to a table users cannot write, or limit which columns the role may update:

sql
revoke update on table public.profiles from authenticated;
grant update (full_name, avatar_url) on table public.profiles to authenticated;

Views that skip RLS

A view runs with its owner's rights by default, and in Supabase that owner is usually postgres. In our test, anon had no grant on a notes table yet read every row through a plain view over it. On Postgres 15 and later, create views with (security_invoker = true) or switch existing ones; on older versions, keep views out of exposed schemas. Materialized views have no such option, so keep them out of exposed schemas on any version.

sql
alter view public.notes_with_authors set (security_invoker = true);

security definer functions in public

A function marked security definer runs as its owner and skips RLS on everything it reads. Functions in an exposed schema are callable at /rest/v1/rpc/. Postgres grants execute on new functions to PUBLIC, meaning every role, and on existing projects Supabase also grants it to anon and authenticated in public. Keep these functions in a private schema, pin search_path, and revoke execute from anon, authenticated and public unless the app calls them. Security Advisor flags security definer functions that anon or authenticated can run.

How to test Supabase RLS as a stranger and as a second user

The SQL editor bypasses RLS, so test in three passes: read the catalog (steps 1 to 3), impersonate a user in SQL, then call the real API with real tokens. Run all of it against your own project only.

1. List every table, its RLS state and its grants

sql
select c.relname as table_name,
       c.relrowsecurity as rls_on,
       (select count(*) from pg_policies p
         where p.schemaname = n.nspname
           and p.tablename = c.relname) as policies,
       has_table_privilege('anon', c.oid, 'select') as anon_reads,
       has_table_privilege('anon', c.oid, 'insert,update,delete') as anon_writes,
       has_table_privilege('authenticated', c.oid, 'insert,update,delete') as users_write
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind in ('r', 'p')
order by c.relrowsecurity, c.relname;
Start with the rows where rls_on is false: if anon_reads or anon_writes is true, the public key reaches that table right now. Tables with RLS on and zero policies are closed to the app. Add any other schema your API exposes to the filter.

2. Find policies that never look at the caller

sql
select tablename, policyname, cmd, roles, qual, with_check
from pg_policies
where schemaname = 'public'
  and coalesce(qual, '') || coalesce(with_check, '') !~ 'auth\.(uid|jwt)\(\)'
order by tablename, cmd;
Every row is a policy that never mentions auth.uid() or auth.jwt(). Some are intended, like published posts, or call a helper, like team membership. The rest let anyone through.

3. Find views and functions that skip RLS

sql
-- Views the API roles can read that run with their owner's rights
select c.relname as view_name
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind in ('v', 'm')
  and (has_table_privilege('anon', c.oid, 'select')
       or has_table_privilege('authenticated', c.oid, 'select'))
  and not coalesce(array_to_string(c.reloptions, ',') ~ 'security_invoker=(true|on|1)', false);

-- Security definer functions the API roles can call through /rest/v1/rpc/
select p.proname as function_name
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
where n.nspname = 'public'
  and p.prosecdef
  and (has_function_privilege('anon', p.oid, 'execute')
       or has_function_privilege('authenticated', p.oid, 'execute'));

4. Impersonate a user in SQL

sql
begin;
set local role authenticated;
set local request.jwt.claim.sub = 'USER_B_ID';

-- Should return only user B's rows
select id, user_id from public.notes;

-- Should print UPDATE 0
update public.notes set title = 'changed by B'
where user_id = 'USER_A_ID';

rollback;
Replace the ids with two of your own test accounts. rollback throws away anything the test changed. For policies that read auth.jwt(), also set request.jwt.claims to the token's JSON.

The dashboard can also impersonate a user in the Table Editor and the SQL editor. For checks that run on every change, Supabase runs pgTAP tests from supabase/tests/database/ with supabase test db: write one per table that asserts what anon and authenticated can and cannot do.

5. Call the API as a stranger, then as a second account

Create two test accounts in your app, A and B, and give A a row. Sign in as B, open the developer tools, and copy B's access token from the Authorization: Bearer header of any request to /rest/v1/. Then run:

bash
# 1. As a stranger: the public key only
curl "https://YOUR_PROJECT_REF.supabase.co/rest/v1/notes?select=*&limit=5" \
  -H "apikey: YOUR_PUBLIC_KEY"

# 2. As user B, asking for user A's rows
curl "https://YOUR_PROJECT_REF.supabase.co/rest/v1/notes?select=*&user_id=eq.USER_A_ID" \
  -H "apikey: YOUR_PUBLIC_KEY" \
  -H "Authorization: Bearer USER_B_ACCESS_TOKEN"

# 3. As user B, trying to change one of A's test rows
curl -X PATCH "https://YOUR_PROJECT_REF.supabase.co/rest/v1/notes?id=eq.A_TEST_NOTE_ID" \
  -H "apikey: YOUR_PUBLIC_KEY" \
  -H "Authorization: Bearer USER_B_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Prefer: return=representation" \
  -d '{"title": "changed by B"}'
Your own project and test accounts only. With the legacy anon key, also send -H "Authorization: Bearer YOUR_ANON_KEY" on the first call.

The stranger call should return [] or a 42501 error. B's read should return []. B's update should return [] too: a returned row means B just changed A's data, the database version of an IDOR vulnerability. Repeat for every table that holds personal data, and for files, using the Supabase storage guide.

Then open Security Advisor, or run supabase db advisors. It flags tables in public with RLS off, always-true write policies, security definer views and functions open to the API roles, and policies that read user metadata. Treat it as a floor: it cannot know which of your tables hold private data.

Keep RLS policies fast

Postgres runs the policy for every row it considers, so a slow policy makes every query slow. Supabase's benchmark on a 100,000-row table shows which fixes count:

  • Index the columns your policies filter on. An index on user_id took the test query from 171 ms to under 0.1 ms. A primary key on (team_id, user_id) does not speed up lookups by user_id alone, which is why pattern 4 adds an index on team_members (user_id).
  • Wrap functions in a `select`. (select auth.uid()) = user_id lets Postgres compute the user id once per statement instead of once per row: 179 ms down to 9 ms in the same test. It only works for values that do not depend on the row.
  • Name the role. to authenticated stops the policy before it runs for anon requests.
  • Filter in the query too. Adding .eq('user_id', userId) next to the policy helps the planner: 171 ms down to 9 ms in the same test.

Supabase RLS checklist before you ship

  • RLS is on for every table in every exposed schema, including tables your agent created in migrations.
  • Grants were revoked and given back per role, not left at the defaults.
  • Every policy names its role with to, and no private table sits behind to authenticated using (true).
  • Each table has explicit update and delete policies, or none at all, and no for all policy with using (true).
  • No column a user can update changes what that user is allowed to do.
  • Roles come from app_metadata or a table only your server writes, never from user_metadata.
  • Views use security_invoker = true, and security definer functions live outside exposed schemas.
  • Policy columns are indexed and auth.uid() is wrapped in select.
  • You called the real API as anon and as a second account, and both got nothing they should not.

These checks read your policies. What a stranger reaches once tables, functions, storage and realtime work together in the live app is a separate question. For the rest of the stack, see the Supabase security guide and, for apps built with Lovable, the Lovable security guide.

Questions

What is RLS in Supabase?

Row level security is the Postgres feature Supabase relies on to control its Data API. Policies attached to each table decide which rows the anon and authenticated roles can read or change, and Postgres applies them to every query. Without it, any table the public key reaches is open to anyone who copies the key from your frontend.

Is RLS enabled by default in Supabase?

Only for tables created in the dashboard's Table Editor. Tables created with SQL, migrations, ORMs or coding agents need alter table ... enable row level security in the same migration. Supabase documents an event trigger that turns it on for every table created afterwards.

How do I enable RLS in Supabase?

Run alter table public.your_table enable row level security; in the SQL editor or a migration, or click Enable RLS on the table under Database > Policies. Then add a policy for each operation your app needs: with RLS on and no policy, only the service role and the database owner can read or write the table.

Why does my update return no error but change nothing?

When a using clause filters a row out, the update matches zero rows, and Postgres does not treat that as an error. Check that an update policy exists for the role, that its using matches the row, and that a select policy lets the user see it, which updates require. A missing grant, by contrast, returns error 42501.

Does the service role key bypass RLS?

Yes. Secret keys and the legacy service role key run as service_role, which has bypassrls, so they belong on your server only. One exception: a request that carries a user's access token runs under that user's policies, even when the client was created with a secret key. If one leaked, read what to do about a leaked service role key.

Is there a Supabase RLS checker?

Supabase's Security Advisor, also available as supabase db advisors in the CLI, flags tables with RLS off and several risky policy shapes. The free Supabase RLS checker lists the tables and policies open to everyone from one query. Neither replaces calling your API as anon and as a second user.

Does RLS slow down Supabase queries?

It can on large tables, because the policy runs for every row Postgres considers. Index the columns your policies filter on, wrap auth.uid() in select, and name the role with to. In Supabase's benchmark on 100,000 rows, those changes took queries from over 170 ms to under 10 ms.

Sources

  1. 1.Row Level Security, Supabase Docs
  2. 2.Securing your API, Supabase Docs
  3. 3.Breaking Change: Tables not exposed to Data and GraphQL API automatically, Supabase Changelog
  4. 4.Custom Claims and Role-based Access Control (RBAC), Supabase Docs
  5. 5.RLS Performance and Best Practices, Supabase Docs
  6. 6.Column Level Security, Supabase Docs
  7. 7.Postgres Roles, Supabase Docs
  8. 8.Event Triggers, Supabase Docs
  9. 9.Advisors, Supabase Docs
  10. 10.Lint 0024: RLS policy always true, Supabase (GitHub)
  11. 11.Row Security Policies, PostgreSQL documentation
  12. 12.Statement on CVE-2025-48757, Matt Palmer
  13. 13.CVE-2025-48757, NIST National Vulnerability Database

Your app is public. So are its mistakes.

Check what any attacker can already see in it, free, in ten seconds. The full attack, with proof and fixes: €49 once, or €29 a month on a plan.

or continue with Google or GitHub