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 in using
  • insert: condition in with check
  • update: using for existing rows and with check for the result
  • delete: condition in using

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 anon anyway 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() returns null. Check that explicitly, for example with auth.uid() is not null and auth.uid() = user_id, instead of relying on a comparison with null failing silently.
  • A missing select policy for update. The docs require a matching select policy 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 set search_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_data works, 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

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.

More guides

All guides →