RLS disabled on a public table: the most common Supabase leak
Every Supabase project ships two facts together: a Postgres database whose interesting tables live in a schema called public, and an anon key that is published to every browser that loads your app. The anon key is not a secret. It is a identity of last resort — "this caller has not signed in" — and its power is supposed to be bounded by one mechanism: Row Level Security on every table PostgREST can reach.
When a table in public has RLS switched off, that bounding mechanism is absent, and the table answers any unauthenticated request with every row it holds. This is not an exotic misconfiguration. It is the single most common way a Supabase backend leaks, it produces no error, no warning in most dashboards you have open, and no failed request in your logs — because every request succeeds.
This page walks through what the failure actually looks like at the protocol level, why nothing alerts you, a query you can run yourself in thirty seconds, the SQL that fixes it properly, and how RowShield keeps it from coming back after the next migration.
Rules that check this
- criticalRow Level Security disabled
RLS_DISABLED - criticalTable readable with the anon key
ANON_TABLE_READABLE
What actually happens
Row Level Security is a property of a table, set per table with ALTER TABLE. When it is enabled, Postgres consults the table's policies for every query from every role except the table owner and roles carrying BYPASSRLS. When it is disabled, policies are irrelevant — there are none to consult, and the role's ordinary GRANTs decide everything. In a Supabase project the anon and authenticated roles hold broad grants on the public schema by design, because that is what makes PostgREST useful.
So the sequence is mechanical. A migration creates a table. The migration forgets the two policy lines. PostgREST exposes the table, because exposing tables in public is its job. A request arrives carrying the anon key — from your own frontend, or from anyone who copied the key out of view-source months ago. PostgREST asks Postgres for the rows; Postgres sees RLS disabled and ordinary grants; the rows come back. A `curl` one-liner replaces your access-control system.
The request looks completely normal in every log. Status 200, a JSON payload, sensible latency. Nothing fails, which is precisely why nothing gets noticed. The average time between a table leaking and someone noticing tends to be measured in weeks or months, and frequently the notice arrives from outside.
Why nothing errors
Three separate systems would have to catch this, and each has a blind spot. Your application cannot catch it, because reads through the anon session return exactly the shape the frontend asked for — many apps never exercise the distinction between "the server sent me these rows" and "the database judged I may see these rows".
The dashboard reports configuration, not behaviour. A green indicator next to a table generally means the advisor found nothing wrong at the moment you opened it — and the advisor is a page you open when something has already drawn your attention. Between visits there is no watcher: a migration that lands on Friday afternoon is invisible until Monday, and invisible forever if nobody re-opens the page.
And deploy pipelines do not gate on database posture. Migrations run; if they succeed syntactically, they ship. A missing ENABLE ROW LEVEL SECURITY line is not a syntax error, so the pipeline's only signal — did the SQL run — stays green.
How to check it yourself
The catalog knows the answer directly. relrowsecurity is the flag RLS sets, and pg_class has it for every table in your project. This is the same query RowShield runs — reading metadata only, never rows:
SELECT n.nspname AS schema,
c.relname AS table_name,
c.relrowsecurity AS rls_enabled
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind IN ('r', 'p') -- tables and partitioned tables
AND n.nspname = 'public' -- what PostgREST exposes
ORDER BY c.relname;Reading the result honestly
Any row where rls_enabled is false is a finding, full stop. There is no context that makes a public-schema table without RLS safe while the anon key exists — even "it only holds lookup values" fails, because lookup values tell an attacker what your product does and often enumerate your real users' choices.
But rls_enabled being true proves less than it appears to. The flag says policies exist to consult; it says nothing about what those policies grant. The two classic follow-on failures are a policy whose condition is a constant true (which grants every row to everyone) and a table with RLS enabled but zero policies (which denies everything to everyone, breaking your app quietly instead of leaking it loudly). They fail in opposite directions and need different fixes, which is why RowShield reports them as separate rules rather than lumping them into "RLS problem".
The behavioural test closes the loop: ask PostgREST itself. With your project URL and anon key, a GET against /rest/v1/<table>?select=* either returns rows (exposed), returns an empty array (ambiguous — filtered or empty), or returns a permission error (policies engaged). All three outcomes are informative, and the ambiguity of the empty array is why RowShield records it as reachable rather than clean.
What fixing it looks like
The fix is three statements, and all three matter. Enable turns the machinery on. Force makes it apply to the table owner too, which closes the gap where psql sessions and migrations bypass every policy you just wrote. Policies then define who may see and touch which rows — scoped to the column that actually expresses ownership in your schema, not a copy-pasted placeholder:
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY; ALTER TABLE public.projects FORCE ROW LEVEL SECURITY; CREATE POLICY "owners_read_own_projects" ON public.projects FOR SELECT TO authenticated USING (user_id = (SELECT auth.uid())); CREATE POLICY "owners_write_own_projects" ON public.projects FOR INSERT TO authenticated WITH CHECK (user_id = (SELECT auth.uid()));
Keeping it fixed
Fixing today proves little about next week, because the failure re-enters the way it entered: another generated migration, another table, another missing pair of lines. This is why RowShield treats the finding as a monitored state rather than a task. A scan records the finding; later scans diff against it. Resolve it and the alert says resolved. It reappears and the alert says regressed — a different word doing deliberate work, because a regression tells you something about your process that a new finding does not.
Scans run on a schedule matching your plan — daily on Free, hourly on Indie, every fifteen minutes on Team — and alert destinations receive transitions only, so a project sitting broken pages once rather than hourly. Remediation SQL is generated from your actual columns, with FORCE ROW LEVEL SECURITY always included, because the version without it is the version that silently protects nothing.
RowShield reads pg_catalog and storage bucket metadata only, never your rows, and is an independent product unaffiliated with Supabase. The probe half of this check — asking PostgREST whether the anon key can read the table — issues GET requests only and refuses private network addresses before connecting.
Frequently asked
- Is the anon key being public the problem?
- No — the anon key is designed to be public, and treating it as a secret leads nowhere. The problem is a table whose policies do not bound what that public key can read. Fix the policies, keep the key public.
- Does enabling RLS break my app?
- It denies every row until a policy grants access, so yes, briefly, until you add the policies your app needs. Generate them from your real columns first, apply both in one change, and the break window is one deploy.
- Why FORCE ROW LEVEL SECURITY as well?
- Without FORCE, the table owner still bypasses every policy — and migrations, psql sessions and several admin paths run as the owner. FORCE closes the gap for the cost of one statement.
- Does the dashboard not warn about this automatically?
- The dashboard Security Advisor flags RLS-disabled tables in the public schema, and it is worth opening. It reports state at the moment you look; RowShield exists for the interval between looks, when migrations land and fixes regress.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit