GUIDE · SECURITY
Supabase: how to enable Row Level Security properly
Without Row Level Security, any role with a grant can read and write a table. How to enable RLS and add one policy per action.
4 min readLast checked
Why “no policy” does not mean safe
According to the Supabase documentation, a table in an exposed schema without RLS is readable and writable by any role that has a grant on it. That is why every table in those schemas needs RLS.
Adding policies does not remove existing grants. The documentation suggests revoking the rights of the anon and authenticated roles after you enable RLS, and allowing only what your policies provide.
Enable RLS and add one policy per action
You enable RLS per table with alter table <name> enable row level security. After that, every action needs its own policy:
select: condition inusinginsert: condition inwith checkupdate:usingfor existing rows andwith checkfor the resultdelete: condition inusing
Roles, and a trap: anon with “true”
Supabase maps every request to a Postgres role: anon for visitors who are not signed in, authenticated for signed-in users. A policy for anon with using ( true ) lets every visitor read every row. That is only right if the data really is public.
RLS is on and still open
Enabled RLS only protects you if no policy lets everyone read. AI tools create such policies so data shows up without login. Check pg_policies for anon or public with the condition true. More in our guide on the Lovable data leak.
Views bypass RLS
From Postgres 15 on, a view can follow the RLS rules of its underlying tables with security_invoker = true. On older versions you revoke access to the view for anon and authenticated, or place it in a schema that is not exposed.
The secret key bypasses RLS
A secret key works through the service_role role, which has the bypassrls attribute. Requests with that key therefore see every row. It never belongs in the frontend; see the guide on the exposed Supabase key. According to the docs, it bypasses RLS only when the request carries no user access token. If it carries one, the policies of the signed-in user apply, even when the client was initialised with a secret key.
How to check
Open the app without signing in and fetch a table with the publishable key that only signed-in users should see. If rows come back, a rule is missing. The LaunchCheck check looks passively from the outside and does not detect open tables; this self-test fills that gap.
For automated tests the Supabase docs point to pgTAP. The tests run with supabase test db; for policies there are check functions such as policies_are, policy_roles_are and policy_cmd_is.
Common mistakes
The Supabase docs name these mistakes around policies:
- Relying on RLS alone and not checking the grants. With RLS on and no matching policy, an action is blocked. Revoking unneeded rights from
anonanyway is a second safeguard in case RLS is ever turned off. - Leaving out the
to <role>clause. The docs recommend always naming the role of a policy. - Without a signed-in user,
auth.uid()returnsnull. Check that explicitly, for example withauth.uid() is not null and auth.uid() = user_id, instead of relying on a comparison withnullfailing silently. - A missing
selectpolicy forupdate. The docs require a matchingselectpolicy as well. - Two policies that read each other. Postgres then raises error
42P17(infinite recursion in the policy). The docs solve this with security definer functions that setsearch_path = ''and use schema-qualified names. - Tying permissions to
raw_user_meta_data. The signed-in user can change that data. For authorization,raw_app_meta_dataworks, which the user cannot change. - Forgetting views. They bypass RLS, see above.
Example: profiles belong to their owner
From the Supabase docs: users read, create and update only their own profile. The user_id column points to the owner.
The docs also advise indexing every column a policy filters on (here user_id). Otherwise each query gets more expensive the more rows the table has.
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 );Frequently asked questions
Is enabling RLS enough?
For security, initially yes: without a policy the table is blocked for anon and authenticated. For your app to read and write again, add one policy per action.
Why does my update do nothing?
An update also needs a matching select policy.
Do views bypass RLS?
Yes, by default, because views are usually created with the postgres user. From Postgres 15 on you can change that with security_invoker = true.
How do I test policies?
By hand as described above, or automated with pgTAP through supabase test db.
Sources
Check your app
The free check loads your app like a visitor and shows what is visible from the outside. You confirm beforehand that you are responsible for the app or authorised to test it.