The dashboard says protected, but the table is still readable
The dashboard shows the shield, the flag, the word protected. Then a request with no credentials returns the table anyway, and the two facts refuse to fit together. They fit once you know the third fact the dashboard does not show: a policy whose condition is always true. Row Level Security is enabled, the machinery runs on every query — and the machinery has been instructed to grant everything.
This is the tautological policy, usually born as USING (true) during a debugging session when something needed to work immediately. It is among the most common ways a Supabase table leaks while every configuration view insists it is safe. This page shows what such a policy does mechanically, why protection indicators stay green beside it, the queries that find it, and the rewrite that ends the argument.
Rules that check this
- criticalPolicy always evaluates to true
RLS_TAUTOLOGY
What an always-true policy does, and why indicators stay green
Policies are grants layered onto a default denial: with RLS enabled, a role sees a row only if some permissive policy's condition holds for it. A condition of true holds for every row and every caller the policy names, so the effect equals having no policy at all — with one cruel difference. No policy leaves the setup visibly unfinished; USING (true) makes the configuration look finished, audited and green.
The usual origin story is benign. A preview renders empty, someone asks how to let the demo read its data, and the fastest answer is a permissive policy intended as scaffolding. Scaffolding in databases does not announce itself later; it becomes the load-bearing wall, because nothing distinguishes it visually from a real rule once the session that wrote it has closed.
Protection indicators read the flag and the presence of policies, and both are satisfied: RLS is enabled, a policy exists, the coverage looks complete. None of these readers evaluate what the policy actually grants. Configuration views answer "is the machinery switched on"; behaviour is "what does the machinery do", and those two questions diverge exactly here.
Credit where due: the built-in Security Advisor flags several constant-true forms and is worth running today. It examines state when invoked, though, and a placeholder policy installed after its last run sits undetected until someone opens the page again. The advisor is a good tool; continuous monitoring exists for the interval it cannot see.
Find the policy yourself
The catalog stores every policy's full condition text, so the search is direct: list the policies on public tables and read the qual column for conditions that cannot fail. Constant-true shapes include true itself, 1 = 1, NOT false, and any OR arm built from those — RowShield normalises the deparsed expression to catch each of them.
SELECT schemaname, tablename, policyname, cmd, roles, qual FROM pg_catalog.pg_policies WHERE schemaname = 'public' ORDER BY tablename, policyname;
Prove it, then rewrite it
Behaviour closes the argument. The request below uses only the public anon key — the same access any visitor already has — and a table behind a tautological policy answers it with rows despite every indicator saying protected. Substitute your hostname and a real table name, and prefer the table the dashboard vouches for loudest:
curl -s "https://your-project.supabase.co/rest/v1/orders?select=*&limit=5" \ -H "apikey: $SUPABASE_ANON_KEY" \ -H "Authorization: Bearer $SUPABASE_ANON_KEY"
The rewrite that aligns flag and behaviour
Rows confirm the diagnosis. The rewrite replaces the constant with the real condition — who owns each row in your schema — keeping RLS enabled throughout, so the indicator finally describes reality. Apply enablement and the honest policy in one change, then re-run the request above and expect refusal:
ALTER TABLE public.orders ENABLE ROW LEVEL SECURITY; ALTER TABLE public.orders FORCE ROW LEVEL SECURITY; CREATE POLICY "orders_owner_read" ON public.orders FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);
Keeping the flag and the behaviour aligned
Two look-alikes deserve deliberate exemption, and honest tools ignore them on purpose. A RESTRICTIVE policy narrows whatever else applies, so USING (true) on one grants nothing by itself; and a policy scoped entirely to service_role decorates nothing, because that role bypasses RLS regardless. Reporting either would be noise, and suppressing them is part of what keeps a first scan believable.
RowShield detects tautological policies as their own rule, RLS_TAUTOLOGY, by normalising the deparsed condition and recognising the constant-true family — including OR-arm cases hiding behind one honest clause. Findings arrive with remediation generated from your actual columns, and scans diff between runs, so a placeholder that sneaks back raises a regression alert instead of passing unnoticed.
RowShield is an independent product, unaffiliated with and not endorsed by Supabase; it reads pg_catalog and bucket metadata only, never table rows, and references to Supabase and Postgres are descriptive. Run a free audit — no account — at rowshield.dev/audit, and see which of your tables behave differently from how the dashboard describes them.
Frequently asked
- Is USING (true) ever legitimate?
- Occasionally, and only deliberately: a genuinely public lookup table, or a RESTRICTIVE policy, which can only narrow other policies and grants nothing on its own. Outside those cases a constant-true condition is scaffolding that became the security model.
- Should I rotate my keys after finding one?
- No. The anon key behaved as designed and no credential needs replacing; the flaw is a policy granting too much. Rewrite the condition, verify with an unauthenticated request, and keep the keys where they are.
- Why did the advisor not catch mine?
- The advisor recognises many constant-true forms and is worth consulting, but it reports state when opened. A placeholder installed after its last run waits invisibly, which is precisely the gap continuous scanning exists to close.
Check your project in about ten seconds
Paste a URL. No signup, no writes, nothing stored.
Run the free audit