In short
- With RLS on and no policy, a table is closed to
anonandauthenticated. Policies for the same operation combine with OR, so one leftoverusing (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 definerfunctions run with their owner's rights and skip RLS. Setsecurity_invoker = trueon views, and keep those functions in a schema the API does not expose, withexecuterevoked 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
postgresand bypasses RLS.
On this page
- What is RLS in Supabase?
- Grants and RLS: two locks, checked in order
- How to enable RLS in Supabase
- Supabase RLS policy examples: five patterns most apps need
- The Supabase RLS mistakes that leak data
- How to test Supabase RLS as a stranger and as a second user
- Keep RLS policies fast
- Supabase RLS checklist before you ship
- Questions
- Sources
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.
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.
| Role | When Supabase uses it | Does RLS apply? |
|---|---|---|
anon | A request with the public key and no signed-in user | Yes |
authenticated | A request with a user's access token, including anonymous sign-ins and any account a stranger creates | Yes |
service_role | Your server, with a secret key or the legacy service role key | No, it has bypassrls |
postgres | Queries you run in the dashboard, including the SQL editor | No, it has bypassrls |
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
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
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
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.
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 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.
| Operation | Clause | What it checks |
|---|---|---|
select | using | Which existing rows the caller can see |
insert | with check | Whether the new row is allowed |
update | using and with check | Which rows can change, and what the changed row may look like |
delete | using | Which rows the caller can remove |
with check, Postgres reuses the using expression for the new row.1. Owner only: each user reads and changes their own rows
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
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
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
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
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:
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.
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
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;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
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;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
-- 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
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;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:
# 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"}'-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_idtook the test query from 171 ms to under 0.1 ms. A primary key on(team_id, user_id)does not speed up lookups byuser_idalone, which is why pattern 4 adds an index onteam_members (user_id). - Wrap functions in a `select`.
(select auth.uid()) = user_idlets 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 authenticatedstops the policy before it runs foranonrequests. - 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 behindto authenticated using (true). - Each table has explicit update and delete policies, or none at all, and no
for allpolicy withusing (true). - No column a user can update changes what that user is allowed to do.
- Roles come from
app_metadataor a table only your server writes, never fromuser_metadata. - Views use
security_invoker = true, andsecurity definerfunctions live outside exposed schemas. - Policy columns are indexed and
auth.uid()is wrapped inselect. - You called the real API as
anonand 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.Row Level Security, Supabase Docs
- 2.Securing your API, Supabase Docs
- 3.Breaking Change: Tables not exposed to Data and GraphQL API automatically, Supabase Changelog
- 4.Custom Claims and Role-based Access Control (RBAC), Supabase Docs
- 5.RLS Performance and Best Practices, Supabase Docs
- 6.Column Level Security, Supabase Docs
- 7.Postgres Roles, Supabase Docs
- 8.Event Triggers, Supabase Docs
- 9.Advisors, Supabase Docs
- 10.Lint 0024: RLS policy always true, Supabase (GitHub)
- 11.Row Security Policies, PostgreSQL documentation
- 12.Statement on CVE-2025-48757, Matt Palmer
- 13.CVE-2025-48757, NIST National Vulnerability Database