RLS enabled, zero policies: silently everything-denied
This is the opposite disaster from a leak, and harder to notice. Enable RLS on a table and write no policies, and Postgres denies every row to every non-owner role. Your data is safe. Your app renders an empty list, returns zero search results, and fails without an error anywhere.
Teams hit this during hardening: switch RLS on everywhere in one sweep, intend to write policies tomorrow, ship the sweep today.
Rules that check this
- highRLS enabled but no policies
RLS_NO_POLICIES
What actually happens
Under RLS, default deny is the law: no policy grants, no rows flow. PostgREST honours that faithfully with a 200 and an empty array — indistinguishable at a glance from a table with no rows or a filter that matched nothing. The dashboard reports the table as protected, which is technically accurate and operationally misleading.
Severity here is high rather than critical: nothing is exposed. But availability loss is loss, and empty-render bugs caused by policy sweeps have a way of being misdiagnosed for days.
The evaluation is unforgiving by design: with zero applicable policies, the policy predicate reduces to false for every row and every role short of the owner, superusers, and roles carrying BYPASSRLS. Writes deny identically, so inserts fail outright rather than misfile. One subtlety worth knowing before you debug: TRUNCATE is not governed by policies at all, only by grants, so a deny-all table can still be truncated by a role holding that privilege.
Finding every deny-all table
Join the RLS flag against policy counts; anything enabled with zero policies is currently denying the world:
Partitioned parents appear in this result as well: zero policies on the parent denies every query arriving through that path, even when partitions carry their own coverage. Read the output as per-relation truth rather than per-table convenience.
SELECT c.relname AS table_name,
COUNT(p.policyname) AS policy_count
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
LEFT JOIN pg_catalog.pg_policies p
ON p.schemaname = n.nspname AND p.tablename = c.relname
WHERE c.relkind IN ('r', 'p')
AND n.nspname = 'public'
AND c.relrowsecurity
GROUP BY c.relname
HAVING COUNT(p.policyname) = 0;Resolving it
Grant deliberately, starting from the app's actual access patterns. Owner-scoped reads and writes cover the common case:
Order the work by blast radius: restore read-only screens first with their SELECT policies, then follow with write paths once the application handles denials gracefully. Resist writing one catch-all policy per table to finish quickly — the sweep that produced this state should not be repeated at the policy layer.
ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY; CREATE POLICY "owners_read_tasks" ON public.tasks FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id); CREATE POLICY "owners_write_tasks" ON public.tasks FOR ALL TO authenticated USING ((SELECT auth.uid()) = user_id) WITH CHECK ((SELECT auth.uid()) = user_id);
Monitoring the state, not the incident
RowShield reports deny-all tables as high-severity findings and tracks their transition: resolved when a first policy lands, regressed if a later migration recreates the table bare. On Free the finding appears in every daily scan; paid plans shorten the loop to hours or minutes. Plans limit frequency and projects, never what a scan tells you.
Treat the first policy landing as the moment to re-run the two-request verification described above, since a policy with the wrong predicate can flip a table from everything-denied to over-exposed in one statement.
Common false leads
The dashboard SQL editor is the classic misdirection. It connects as the privileged postgres role, which bypasses RLS, so selecting from the table there shows rows while the application sees none — proof, apparently, that the data exists and the bug lies elsewhere. The same applies to any session connecting as the table owner. Always reproduce through the roles your application actually presents.
Frontend theories come next: stale caches, pagination bugs, wrong filters. They share a signature — every query returns cleanly shaped empties with status 200 — which is exactly why this state hides so well. One catalog query resolves what hours of client debugging cannot: whether any policy exists behind the enabled flag.
Verifying the fix from outside
Verification is a two-request story. Through the anon or a real user key, the endpoint returned 200 with an empty array before; after the first policy lands it should return that principal's rows, and a different principal should still see none. Watching the response shape change — not merely stop being empty — confirms the policy binds the right caller rather than reopening the table wholesale.
Frequently asked
- Why is this high severity and not critical?
- Nothing leaks — default deny holds without any policy present, so exposure is zero. What fails is availability and trust: the application misbehaves silently while every security surface reports healthy, and teams can lose days attributing empty renders to the wrong layer.
- My probe showed reachable-but-empty. Is this the same?
- Related but distinct. The probe cannot distinguish filtered-empty from policy-denied, so an empty response is recorded as reachable rather than leaking — honest, and incomplete. Catalog introspection resolves the ambiguity directly by pairing the enabled flag with the per-table policy count.
- Should I disable RLS until policies are ready?
- Never. Deny-all is recoverable within one deploy; disabled is a leak from the first request. Stage the change with policies written first, or sequence the sweep table by table so breakage arrives in reviewable slices rather than one silent morning.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit