Supabase RLS is Postgres Row Level Security: a policy is a WHERE clause the database adds to every query, so each request made with your public key only reaches the rows it is allowed to. Write one policy per operation, name the role with a to clause, compare (select auth.uid()) to an indexed owner column, and set grants in the same migration. Then test as the anon and authenticated roles, because a denied read returns an empty result, not an error.
Supabase RLS (Row Level Security) is the Postgres feature that decides which rows each request can read or change, and on Supabase it is what stands between your public API key and every customer's data. A policy is a WHERE clause the database adds to every query on a table, so a rule such as "users see only their own rows" is enforced inside Postgres for every client, not in your front end.
Checked October 2026 against Supabase's Row Level Security guide, RLS performance guide, Securing your API, API keys and Advisors pages, and the PostgreSQL 18 docs for CREATE POLICY and row security policies. Every SQL block follows the documented pattern.
This guide is the mental model: what Postgres checks and in what order, one policy per operation, the auth.uid() patterns worth memorising, and the five mistakes that leak data. It sits under our hub on how to build an AI SaaS, where sign-in and RLS are week three of the build order. If your rows belong to teams or organisations rather than single users, read this first, then the design guide for multi-tenant SaaS with Supabase.
What is Supabase RLS, and why does it carry so much weight?
In a traditional app the browser talks to your server, your server checks permissions, and the database trusts the server. Supabase removes the middle step: the browser can query Postgres through the Data API with a publishable key that ships in your JavaScript bundle. Supabase's API keys page says that key is safe to expose precisely because "it only reaches what Row Level Security allows". So the permission check has to live in the database.
- 01API key
Identifies the app, not the person. The publishable key is public by design.
- 02Role
No user session: anon. Signed-in user: authenticated. Secret key: service_role.
- 03Grants
May this role run this operation on the table at all? If not, error 42501.
- 04Policies
Which rows? Each policy for this role and operation adds a filter.
- 05Rows
Only rows that pass come back or change. A filtered read is an empty result.
Every key resolves to a Postgres role, and the role is what your policies match on. There are only three to keep in your head:
| Request | Postgres role | Policies apply? | What it is for |
|---|---|---|---|
| Publishable key, no user session | anon | Yes | Signed-out visitors. Should hold grants only on data meant to be public. |
| Publishable key plus a user session | authenticated | Yes | Every signed-in user, including anonymous sign-ins. Policies decide which rows. |
| Secret key (or legacy service_role key) | service_role | No | Has BYPASSRLS. Server code, Edge Functions and scheduled jobs only. |
One naming note so older tutorials do not confuse you: Supabase is deprecating the long JWT-style anon and service_role keys by the end of 2026 in favour of publishable (sb_publishable_) and secret (sb_secret_) keys. The Postgres roles keep their names, and the security model is the same.
How does Supabase row level security work?
Four rules explain almost every surprise people hit. Learn these and policies stop feeling like guesswork.
- Postgres runs two checks: grants, then policies. Grants decide whether a role can run an operation on a table at all. Policies decide which rows. A missing grant raises error
42501before any policy runs, so when a request fails that your policy should allow, check the grant first. - RLS with no policy means no access. The Postgres docs call this a default-deny policy: enable RLS on a table, write nothing else, and no rows are visible or modifiable through the API.
- Policies add up. Policies are permissive by default and Postgres combines them with
OR. A second policy can only widen access, never narrow it, unless you declare itas restrictive, which combines withAND. - Some roles skip the whole system.
service_rolehas theBYPASSRLSattribute, and so does thepostgresrole you use for admin work. A query that works forpostgrestells you nothing about what a customer can see.
Grants deserve a second look, because the default is changing. On existing projects a new table in public starts with select, insert, update and delete granted to anon, authenticated and service_role, and adding policies does not take those back. Supabase announced in April 2026 that new tables will stop receiving those automatic grants: it became the default for new projects from May 30, 2026 and is scheduled to apply to existing projects from October 30, 2026 (existing tables keep the grants they have). Either way, the safe habit is the same: put explicit grants in the migration that creates the table, including one for service_role if server code will touch the table with a secret key.
The mental model: one policy per operation
Read every policy as a sentence: this role may do this operation on rows where this condition holds. A table needs up to four sentences, one each for select, insert, update and delete. Supabase's guide tells you to write them separately, because Postgres does not accept several operations in one for clause and a for all policy hides which operation each rule was meant to cover.
| Operation | Clause | The question it answers | What a denial looks like |
|---|---|---|---|
| select | using | Which existing rows may this role see? | Rows are filtered out. Empty result, no error. |
| insert | with check | Is this new row allowed to exist? | Error 42501. |
| update | using + with check | Which rows may change, and what may they become? | Row not visible: zero rows change, no error. Result not allowed: error 42501. |
| delete | using | Which rows may be removed? | Zero rows deleted, no error. |
Here is the whole pattern for a table where each user owns their rows. First the table, RLS, grants and index:
create table public.notes (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id) on delete cascade,
body text not null
);
-- 1. Turn RLS on. Until a policy exists, the API returns no rows.
alter table public.notes enable row level security;
-- 2. Grants: signed-in users only. Signed-out visitors get nothing.
revoke all on table public.notes from anon, authenticated;
grant select, insert, update, delete on table public.notes to authenticated;
-- 3. Every policy below filters on user_id, so index it.
create index notes_user_id_idx on public.notes using btree (user_id);Then the four sentences:
create policy "Users can view their own notes"
on public.notes for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Users can create their own notes"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );
create policy "Users can update their own notes"
on public.notes for update
to authenticated
using ( (select auth.uid()) = user_id ) -- which existing rows
with check ( (select auth.uid()) = user_id ); -- what the row may become
create policy "Users can delete their own notes"
on public.notes for delete
to authenticated
using ( (select auth.uid()) = user_id );What these four policies protect. A signed-in user can read, change and delete only rows whose user_id is their own ID. They cannot insert a row on someone else's behalf, and the update policy's with check stops them reassigning a row to another user. Signed-out visitors are stopped earlier, at the grant.
What they do not protect. They say nothing about columns: an owner can update every column of their own row, so a plan, role or credits column on a user-writable table is editable by the user. They do not apply to requests made with a secret key. And they cover this table only; a view, a function or a storage bucket that exposes the same data needs its own attention, which the section on what RLS does not cover walks through.
Two details in that SQL are easy to skim past. to authenticated names the role, so the policy never runs for anon. And Supabase's guide carries a caution that an update needs a matching select policy, or it will not work as expected, because Postgres has to read the row before it can change it.
Supabase RLS tutorial: how do you secure one table?
Supabase's documented procedure is four steps, repeated for every table in an exposed schema. The part most people skip is the last half.
- 1Enable RLS and set the grants
alter table ... enable row level security, revoke all from anon and authenticated, grant back only what each role needs. Result: the API returns nothing yet.
- 2Write a policy per operation
Select, insert, update, delete, each with a to clause. Result: the owner can work with their rows.
- 3Write a test file for the table
supabase test new notes_rls.test creates a pgTAP file under supabase/tests/. Assert allow and deny for anon and authenticated.
- 4Run supabase test db
Fix what it reports. Until the suite passes, you do not know whether the policies do what you intended.
For a quick manual check before you write the full test file, switch role and identity inside a transaction, the same way the documented tests do, then roll back. Run it in psql, where every statement prints its result:
begin;
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
select * from public.notes; -- expect: only this user's rows
update public.notes set body = 'x' returning id; -- expect: only this user's ids
rollback;Replace the UUID with a real user's ID, then run it again with a second user's ID. If the second user sees the first user's rows, the policy is wrong. If both see nothing, check that user_id is actually being filled in on insert.
Tests need care because denials are quiet. Only two of the three ways Postgres refuses a request raise an error:
| Denied by | What Postgres does | How Supabase says to assert it |
|---|---|---|
| A missing grant | Raises 42501 | throws_ok |
| A with check violation | Raises 42501 | throws_ok |
| A using clause filtering the row out | Raises nothing, matches zero rows | is_empty over the statement with returning, then a read proving the target row is unchanged |
Which auth.uid() patterns are worth memorising?
auth.uid() returns the ID of the user making the request, read from their token. Nearly every policy you write is a variation on comparing it to a column. Five patterns cover most tables:
- The owner column.
(select auth.uid()) = user_id. The row carries its owner's ID and the policy compares. Start here. - Wrap the call in a select. Written as
(select auth.uid()), Postgres evaluates the function once per statement instead of once per row. Supabase notes this is only valid when the result does not depend on the row, which is always true forauth.uid()andauth.jwt(). - Signed-out means null. With no user session,
auth.uid()returnsnull, andnull = user_idis never true, so the policy fails closed. Do not lean on that: name the role withto authenticatedso the intent is explicit and the expression never runs foranon. - Anonymous sign-ins are authenticated. A user created with
signInAnonymously()gets theauthenticatedrole, notanon. If you enable them, Supabase's anonymous sign-ins guide says to check theis_anonymousclaim in a restrictive policy wherever permanent users should get more. - Claims from the token.
auth.jwt()exposes the token's claims. Anything underapp_metadatais set by your server and safe to read in a policy, with one catch: it stays stale until the token refreshes. Anything underuser_metadatacan be edited by the user.
- user_metadata claims: the user can rewrite them with updateUser
- A role or plan column on a row the user is allowed to update
- "Is signed in" on its own, when sign-ups are open to anyone
- An ID the client sends in the request body or a header
- auth.uid() compared to an owner column
- A membership or roles table that clients cannot write to
- app_metadata claims, accepting they lag until the token refreshes
- The aal claim, when a table should require multi-factor sign-in
The five policy mistakes that leak data
Supabase's advisors have a check for each of these patterns, which tells you how often they ship. For each: what it looks like, what leaks, and the fix.
1. RLS is off, or the policies exist but RLS was never enabled
Tables created in the Supabase dashboard have RLS enabled by default. Tables created in the SQL editor, by a migration or by an ORM do not. The advisor reports this as an error, and its wording is blunt: anyone with your project URL can read, edit and delete all data in the table. A quieter version is writing policies and forgetting enable row level security; the policies sit there doing nothing. Fix both with one statement per table, and consider the event trigger Supabase documents for enabling RLS automatically on new tables.
2. An always-true policy left over from development
to authenticated using ( true ) reads as "signed-in users only", which sounds restrictive. It means every signed-in user can read every row, and if sign-ups are open, anyone can become a signed-in user in a minute. using ( true ) is correct for data that is meant to be public, such as a pricing table or published posts. On anything customer-specific it is a placeholder that shipped.
3. Write policies that do not pin the owner
Read policies get the attention; write policies leak the other way. An insert policy with with check ( true ) lets a signed-in user create rows carrying someone else's user_id, which plants data in another account. The fix is the same comparison you used for reads:
-- Leaks: any signed-in user can insert a row owned by anyone.
create policy "Users can create notes"
on public.notes for insert
to authenticated
with check ( true );
-- Fixed: the new row must belong to the caller.
create policy "Users can create their own notes"
on public.notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );The same applies to updates: using picks which rows may change, with check validates what they become. Per the Postgres docs, if you omit with check on an update policy the using expression is applied to both, so the dangerous version is an explicit with check ( true ), not a missing one.
4. Authorising from user_metadata
A policy such as (select auth.jwt()) -> 'user_metadata' ->> 'is_admin' looks like a role check. But user_metadata is designed to be edited by the user, and any signed-in user can call updateUser from the browser and set is_admin to true. The advisor flags this as an error. Keep roles in a table clients cannot write to, or in app_metadata.
5. A second policy that quietly widens the first
Because permissive policies combine with OR, two reasonable-looking policies can add up to something neither author intended. This is the one that slips through review, because each policy is correct when read alone:
-- Intended: "published AND in my organisation". As two policies, it means OR.
create policy "Published docs are readable"
on public.docs for select to authenticated
using ( status = 'published' );
create policy "Organisation docs are readable"
on public.docs for select to authenticated
using ( org_id in (select private.user_org_ids()) );
-- Fixed: one policy, one sentence.
create policy "Members read their organisation's published docs"
on public.docs for select to authenticated
using (
status = 'published'
and org_id in (select private.user_org_ids())
);With the first pair, every signed-in user can read every published document from every organisation. The fix is to write the rule as one sentence in one policy, or to declare the narrowing rule as restrictive. (private.user_org_ids() is the membership helper built step by step in the multi-tenant guide.)
What does RLS not cover?
RLS protects rows in tables, for roles that are subject to it. Several common paths to the same data sit outside that sentence. A correct policy does nothing for you if one of these is open.
| Surface | How it gets around a table policy | What to do |
|---|---|---|
| Views | Bypass RLS by default, because the view runs as its creator | Create them with security_invoker = true (Postgres 15 and later), or keep them out of exposed schemas |
| Functions | RLS does not apply to functions; a security definer function runs as its creator | Grant execute only to roles that need it, pin search_path, keep definer functions in an unexposed schema |
| Secret keys | service_role skips every policy | Server-side only; treat a leaked key as a breach and rotate it |
| Storage | Table policies do not cover files | Write policies on storage.objects for each bucket |
| Realtime | Postgres Changes checks policies per subscriber, but not for DELETE events | Delete events are not filtered by policy, so think twice before setting replica identity full on tables with private rows |
| Columns | RLS is per row; an owner can update every column of their row | Keep plan, role and credit columns in a table clients can only read |
Views are the one that catches experienced developers. Postgres creates a view that runs with its creator's privileges, and on Supabase the creator is usually postgres, which bypasses RLS. A view over a protected table therefore hands out every row. On Postgres 15 and later, make the view obey the caller's policies:
create view public.note_summaries
with (security_invoker = true)
as select id, user_id, left(body, 80) as preview from public.notes;Storage is the other frequent surprise. Files live in the storage.objects table, with their own policies. Supabase's Storage access control page states that Storage allows no uploads to a bucket until a policy exists, and gives this pattern for letting users upload only into a folder named after their user ID:
create policy "Users upload to their own folder"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'my_bucket_id' and
(storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);That policy covers uploads only. Reading, replacing and deleting files each need their own policy on storage.objects; Supabase notes that overwriting with upsert additionally needs select and update.
What are the Supabase RLS best practices?
Most of the best practices are the rules above turned into habits. The list below is the version to keep next to your migrations folder.
- RLS enabled, in the same migration that creates the table
- Grants revoked from anon and authenticated, then granted back per operation
- One policy per operation, each with a to clause naming the role
- Helper calls wrapped: (select auth.uid()), (select auth.jwt())
- An index on every column a policy filters on
- No policy reads user_metadata; no using ( true ) outside public data
- Views created with security_invoker = true
- Security definer functions in an unexposed schema, with search_path set to an empty string
- A pgTAP file per table, passing under supabase test db
- Security Advisor shows no errors for the project, and every warning is one you chose
The advisors are worth running on every schema change. They are deterministic checks you can open in the dashboard (Security Advisor and Performance Advisor) or run with supabase db advisors, and they flag all five patterns above in some form: RLS disabled, always-true using and with check expressions, user_metadata in a policy, several permissive policies on one table, plus definer views and unwrapped auth.uid() calls. What a check cannot tell you is whether a policy matches your intent. Only tests do that.
Performance belongs on this list because a slow policy tempts people into switching RLS off. Postgres evaluates a policy against each candidate row, so cost grows with the rows a query scans. Supabase published before-and-after timings for the two cheapest fixes:
Two separate tests, each a select on a 100,000-row table. Your numbers will differ; the ratio is the point. Source: Supabase, RLS Performance and Best Practices, checked October 2026
Two more habits from the same guide. Add the filter in your client query as well as the policy (.eq('user_id', userId)), because Postgres can plan a better query from an explicit filter. And when a policy has to look up another table, select the allowed IDs into a set rather than joining back to the row; the multi-tenant guide shows that pattern in full.
Knowing the rules is the smaller half; wiring them into a schema, server-side auth and a deploy is where the time goes. Our AI SaaS Builder program spends a full module on Supabase, from database design through explicit grants, RLS, auth and storage, as part of building one product end to end.
Where to go from here
If you are new to the platform, our Supabase full-stack tutorial covers project setup, auth and the client libraries around the policies. If you are still choosing a database host, Postgres hosting compared sets Supabase beside Neon, Railway and RDS on published prices. And if an AI app builder created your tables, treat its policies as a draft: our Lovable vs Base44 comparison covers how those builders use Supabase, and the checklist above is what to run before real users arrive.
Supabase RLS: FAQ
What is RLS in Supabase?
Row Level Security is a Postgres feature that Supabase relies on to control who can reach which rows. You enable it per table and write policies, which are SQL conditions the database adds to every query. A request made with the publishable key runs as the anon or authenticated role and only touches rows a policy allows. With RLS enabled and no policies, the API returns no rows at all.
Is it safe to expose the Supabase publishable key in the browser?
Yes, provided RLS is enabled on every table in an exposed schema and the policies are correct. Supabase describes the publishable key, and the legacy anon key it replaces, as safe to ship because it only reaches what grants and Row Level Security allow. Without RLS, anyone holding that key can read and write every row the role has grants on. The secret key is different: it bypasses RLS and belongs on a server.
Why does my Supabase query return an empty array after enabling RLS?
Because Row Level Security denies by default. With RLS on and no policy for that role and operation, Postgres returns zero rows instead of an error. Check three things in order: the request carries the signed-in user's session, a select policy exists for the authenticated role, and its condition matches the row, for example that user_id holds that user's ID. A missing grant looks different: it raises error 42501.
Does the Supabase service role key bypass RLS?
Yes. The service_role Postgres role has the BYPASSRLS attribute, so requests made with a secret key, or the legacy service_role key, skip every policy. Supabase documents one exception: if the request also carries a user access token, it runs under that user's policies. Keep secret keys in server code, Edge Functions and scheduled jobs, and never ship one in a browser or mobile app.
Does RLS slow down Supabase queries?
It can, because Postgres evaluates a policy against each candidate row. In Supabase's published tests on a 100,000-row table, a select fell from 171 ms to under 0.1 ms once the policy's filter column was indexed, and from 179 ms to 9 ms when auth.uid() was wrapped in a select. Index the columns your policies filter on, wrap helper functions, and name the role with a to clause.
Do I need a separate RLS policy for select, insert, update and delete?
Supabase recommends one policy per operation. Postgres does not accept several operations in one for clause, and a for all policy hides which rule was meant for which operation. Select and delete take a using expression, insert takes with check, and update takes both: using picks the rows that can change and with check validates the result. An update also needs a matching select policy to behave as expected.
Does RLS apply to Supabase Storage and Realtime?
Storage has its own policies on the storage.objects table, and by default it allows no uploads until you write them; policies on your tables do not cover files. Realtime Postgres Changes authorizes each event against each subscriber using your table policies, but Supabase warns that policies are not applied to DELETE events, because Postgres cannot check access to a row that no longer exists.
Policies are one week of the build. Ship the rest of it.
AI SaaS Builder, included in All Access, takes one product from validation to Stripe billing on Next.js, Supabase and the Claude API, with a Supabase module covering grants, Row Level Security, auth and storage. All Access adds the other three programs, live coaching and the private community.
Stuck on a policy?
Post the table, the policy and what you expected to happen in the free Discord, and ask other builders to read it with you.