Row Level Security is one SQL statement away from off

Row Level Security is the only thing standing between your anon key — which ships in every browser bundle and is meant to be public — and every row in your database. It is also a single ALTER TABLE away from being switched off, and a broken policy fails silently on reads. Here is how RLS actually breaks in production, and what each failure looks like from the outside.

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

Supabase's anon key is designed to be shipped to browsers. Anyone who opens devtools on your site has it. That is not a leak — it is the intended design — but it means the key grants whatever the anon role is allowed to do, and Row Level Security is what defines "allowed". If RLS is off on a table that anon holds grants on, that table is world-readable to anyone who finds your project URL.

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

The service key bypasses RLS entirely. 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.

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.