How to Fix Supabase RLS (With Copy-Paste Examples)

Row Level Security off means anyone can read your tables with the public key. Here is how to turn RLS on and write safe policies, step by step.

By Artem Vysotskyi · Updated

Row Level Security is off by default on every new Supabase table. Until you turn it on, anyone holding your public anon key, which ships to every visitor's browser, can read and write the entire table through the data API. The fix: enable RLS, then add a per-table policy that scopes access to the row's owner.

What RLS is and why it matters

Row Level Security is a Postgres feature that filters which rows a query is allowed to touch, based on rules you define. Supabase exposes every table in the public schema through its data API, so without RLS, that API has no concept of "your data" versus "someone else's data." It just returns rows.

The anon key that your frontend uses is meant to be public. It is not a secret, it is an identifier. The thing that is supposed to stand between that key and your users' rows is RLS. If it is off, the key alone is enough to read or write everything.

How to check if RLS is off

Run this in the Supabase SQL editor to list every public table where RLS is not enabled:

select tablename
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;

Anything that comes back is currently readable and writable by anyone with your anon key, no login required. You can also see this per-table in the dashboard, under Table Editor, where tables without RLS show an unlocked icon.

Enable RLS on a table

For each table the query above returned, turn RLS on:

alter table public.<t> enable row level security;

This does not add access, it removes it. The moment RLS is enabled with no policies, every request to that table is denied by default, including your own app's requests. That is expected, and it is why the next step is not optional.

Write a safe owner-scoped policy (with SQL)

The standard pattern ties a row to the user who owns it, usually through a user_id column, and checks that column against the currently authenticated user.

create policy "Users can read own rows"
on public.todos
for select
using (auth.uid() = user_id);

create policy "Users can insert own rows"
on public.todos
for insert
with check (auth.uid() = user_id);

create policy "Users can update own rows"
on public.todos
for update
using (auth.uid() = user_id);

create policy "Users can delete own rows"
on public.todos
for delete
using (auth.uid() = user_id);

Write one policy per operation instead of a single for all policy. using controls which existing rows a query can see or touch, with check controls what a new or updated row is allowed to contain, and the two are not interchangeable, an insert policy needs with check, not using.

If a table needs to be readable by anyone but only writable by its owner, split it: a permissive select policy with using (true), and owner-scoped policies for insert, update and delete.

Common mistakes

The same handful of mistakes cause most RLS bugs:

  • Enabling RLS but only writing a select policy, then wondering why inserts fail. Every operation your app performs needs its own policy.
  • Using for all with a single loose condition, which is easy to write wrong and hard to audit later.
  • Checking the wrong column, for example auth.uid() = id on a todos table where id is the todo's own id, not the owner's.
  • Forgetting that Storage has its own, separate set of policies. Enabling RLS on your tables does nothing for a public Storage bucket.
  • Assuming a policy that "looks right" is actually enforced without testing it as an unauthenticated or wrong-user request.

How to verify you are safe

Test as an outsider would, with only the anon key:

curl "https://<project>.supabase.co/rest/v1/todos?select=*" \
  -H "apikey: <anon-key>" \
  -H "Authorization: Bearer <anon-key>"

An unauthenticated request should return an empty array or a 401/403, never rows. Then sign in as two separate test users and confirm each one only ever sees their own data. Repeat this for every table you fixed, not just the first one.

Check your whole app in 30 seconds with a free LeakRank scan

FAQ

Does enabling RLS break my app?

Temporarily, yes, if you have not written matching policies yet. The moment RLS is on, every operation is denied until a policy explicitly allows it. Enable RLS and add policies in the same change, ideally tested on staging first, so your app is never left in a state where reads and writes are silently blocked. #

Is the anon key safe to expose?

Yes, by itself. It is designed to be public and ships in every client bundle. It is only as safe as the policies behind it: with RLS off, or with a policy that is too permissive, the anon key can read or write anything the table allows. The key being public is fine, the policies are the actual boundary. #

How do I test my policies?

Call the REST API directly with only the anon key and confirm you cannot read rows that are not yours. Then test as two different signed-in users and confirm each only sees their own rows. The Supabase SQL editor can also impersonate a role with `set role anon;` for a quick check without leaving the dashboard. #

What does a good RLS policy example look like?

A good policy is narrow and matches one operation: `using (auth.uid() = user_id)` for reads and updates, `with check (auth.uid() = user_id)` for inserts, applied per table and per operation rather than one broad rule covering everything.

Find out in 30 seconds

Paste your URL and LeakRank shows what a stranger can actually read from your app. Free, no signup.

Scan my app free →

Related guides