Supabase RLS setup: secure the table, then prove the policy

By Defencecore Research · · Updated

Supabase Row Level Security is not finished when you switch it on. A secure setup combines table grants, policies for each operation, realistic tests as signed-out and signed-in users, and monitoring after deployment. This guide walks through the complete setup and the failures to watch in production.

Short answer

Row Level Security is the real security boundary in a Supabase project, because the anon key is public by design and ships in every browser bundle. A table with RLS enabled and no policy denies everything; a table with grants and no RLS is readable by anyone who finds your project URL.

The failure modes are asymmetric. A denied write fails loudly with SQLSTATE 42501, but a denied read returns HTTP 200 and an empty array that is indistinguishable from an empty table — which is why broken read policies survive for months, and why a single ALTER TABLE ... DISABLE ROW LEVEL SECURITY can go unnoticed.

The anon key is public. RLS is the actual security boundary.

Supabase's legacy anon key—and its newer publishable key replacement—is designed to be shipped to browsers. Anyone who opens devtools on your site can copy it. That is not a leak. The key identifies the project; Postgres grants and Row Level Security decide what the request may do. A secret or legacy service-role key is different: it is server-only and can bypass RLS.

Supabase RLS setup, step by step

Supabase's current RLS guidance treats grants and policies as two separate checks. Grants decide whether a role may run SELECT, INSERT, UPDATE, or DELETE. Policies decide which rows that operation may touch. Configure both for every table exposed through the Data API.

  1. Enable RLS. Do this for every table in an exposed schema, including tables created by migrations.
  2. Remove unnecessary grants. The anon role should only keep operations intended for signed-out visitors.
  3. Create one policy per real action. Separate read, insert, update, and delete rules so each is easy to reason about.
  4. Test as the real roles. Test signed-out access, a normal signed-in user, another tenant's user, and an administrative server path.
  5. Check views and functions. A view can bypass the policies beneath it unless it is configured to use the caller's security context.
  6. Monitor after release. A later migration can disable RLS, broaden a policy, or create a new exposed table.
-- Enable the security boundary.
alter table public.projects enable row level security;

-- Signed-in users may read only rows they own.
create policy "users read their projects"
on public.projects
for select
to authenticated
using ((select auth.uid()) = owner_id);

-- Do not grant signed-out visitors access unless the product needs it.
revoke all on public.projects from anon;

A passing owner test is not enough. The important test is the negative one: user B must not see or change user A's row. Run that test in the same change that creates the policy, then repeat it after schema migrations.

The four ways RLS fails in practice

1. RLS was never enabled on a new table

New tables created through the SQL editor do not have RLS on by default. A table added during a late-night feature push is the most common exposure there is: the code works, nothing errors, and every row is public.

2. RLS is enabled, but no policy grants access

This is the silent one. With RLS on and no SELECT policy, a query returns HTTP 200 and an empty array — not an error. Your app renders an empty list and you conclude the table is empty. A write, by contrast, fails loudly with SQLSTATE 42501. The asymmetry is why teams find broken write policies in minutes and broken read policies in months.

3. The service_role key is used where the anon key belongs

A secret or legacy service-role key can bypass RLS. When it ends up in a client bundle or an unauthenticated Edge Function, every policy you wrote stops applying to that path. A request carrying service_role with a browser user-agent is one of the highest-confidence signals in Supabase logs — there is no legitimate reason for it.

4. A migration leaves the policy behind

Add an org_id column, ship a policy that scopes rows by it, and forget to backfill existing rows — now the policy denies legitimate users. This shows up as a spike in denials on one table from authenticated users, which looks identical to enumeration until you correlate it with the migration that preceded it.

What to monitor

How Defencecore catches it

Defencecore reads your Supabase Postgres logs continuously and runs detection rules over them. When database.rls_disabled matches, it opens a CRITICAL incident with the statement, the table, and the surrounding log lines attached, so you can see who ran it and when. Denial spikes and service-key misuse open their own incidents with the same evidence trail. Everything is read-only: Defencecore reads logs and holds no path into your database.

Static tests prove the policy at deployment time. Defencecore covers the time after deployment: it watches for RLS being disabled, repeated policy violations, and privileged-key use that suggests a route is bypassing the policy. The two controls solve different parts of the same problem, and a production project needs both.

Next, read why anon-key exposure is expected and how to apply the broader Supabase security checklist.

Frequently asked questions

Does enabling Row Level Security protect a table on its own?
Yes, and more completely than most people expect. A table with RLS enabled and no policies denies every row to anon and authenticated roles — reads come back as an empty result rather than an error. That silence is the trap: an empty array is indistinguishable from an empty table, so a broken policy can look like working code for a long time.
Why do my queries still return everything with RLS on?
Almost always because the request is authenticated with the service_role key, which bypasses RLS by design. The same happens for a direct Postgres connection as the postgres superuser. RLS only constrains the anon and authenticated roles going through PostgREST, so testing with a service key proves nothing about what your users can see.
What does a Row Level Security denial look like in the logs?
A denied write raises SQLSTATE 42501 with the message "new row violates row-level security policy" in your Postgres logs. A denied read raises nothing at all — it is filtered silently. Detection has to treat a sudden change in denial rate, or the absence of rows where rows are expected, as the signal.
How do I know if someone disabled RLS on a table?
ALTER TABLE ... DISABLE ROW LEVEL SECURITY appears in your Postgres logs. Defencecore's database.rls_disabled rule opens a CRITICAL incident when it sees one, because from that statement onward every row in the table is readable by anyone holding your anon key, which is public by design.