Field Notes

Row-level security is the first thing we check

Sheet N-102 · By the Oracis engineering team · · 4 min read

When we assess an app built with an AI builder on top of Supabase, the first thing we check is row-level security. Not because the tools are careless, but because of how the pieces fit together: the browser is allowed to talk to the database directly, and one setting decides whether that is safe.

Why the browser can reach your database

A Supabase front end ships with a public key, the anon key, inside its JavaScript bundle. That is by design: the key identifies the project, and anyone can find it with one search in the browser's dev tools. What stands between that key and your data is row-level security (RLS): per-table policies, enforced by Postgres itself, that decide which rows a request may read or write.

With RLS on and the policies right, the public key is harmless. With RLS off, or with a policy that allows everything, the public key reads the table. No login, no exploit.

Three ways the same public key meets the same table A browser holds the project's public anon key. Three requests go into Postgres, each to an orders table. With row-level security off, every row is readable. With a policy of using (true), the table has a policy but it protects nothing. With a policy of user_id = auth.uid(), the request reads only the signed-in user's rows. The key is public by design; the policy is the lock. Browser public anon key in the bundle Postgres policies enforced by the database orders table orders table orders table RLS off every row readable policy: using (true) has a policy, protects nothing policy: user_id = auth.uid() only the signed-in user's rows The key is public by design. The policy is the lock. Three ways the same public key meets the same table A browser holds the project's public anon key. Three requests go into Postgres, each to an orders table. With row-level security off, every row is readable. With a policy of using (true), the table has a policy but it protects nothing. With a policy of user_id = auth.uid(), the request reads only the signed-in user's rows. The key is public by design; the policy is the lock. Browser public anon key in the bundle Postgres policies enforced by the database RLS off every row readable orders table policy: using (true) has a policy, protects nothing orders table policy: user_id = auth.uid() only the signed-in user's rows orders table The key is public by design. The policy is the lock.
Fig. N-102Three ways the same public key meets the same table

What happened in 2025

In March 2025 a security researcher scanned 1,645 projects built with one popular AI app builder and found 303 endpoints across 170 of them, about 10.3%, with inadequate row-level security. The exposed data included names, phone numbers, payment details and API keys. It was assigned CVE-2025-48757. The same statement notes that the builder's later security scanner checked whether a policy existed, not whether it was correct.

That last point is the heart of it. A policy that exists can still be wrong.

Three ways it goes wrong

  1. RLS never switched on. Tables created in Supabase's table editor get RLS enabled by default; tables created with SQL do not, and generated migrations are SQL. Those are the ones that get missed.
  2. A policy that allows everything. using (true) satisfies "has a policy" and protects nothing.
  3. A policy that checks the wrong thing. Rows filtered by a user_id the client supplies, or a tenant check that looks at a column the user can update themselves.

How to check your own project

In the Supabase SQL editor, these two queries find the first two problems in under a minute:

-- Tables in the public schema with RLS off
select tablename
from pg_tables
where schemaname = 'public' and rowsecurity = false;

-- Policies that allow every row
select tablename, policyname, cmd
from pg_policies
where schemaname = 'public'
  and (qual = 'true' or with_check = 'true');

Any row in either result is worth a look. An empty result is a good sign, not a clean bill of health: the third problem, a policy that checks the wrong thing, only shows up when you read each policy against what the table holds and who should see it.

What we do in an assessment

  • List every table and view the public key can reach, and what each one holds.
  • Read every policy against the business rule it should enforce: whose rows, which operations, which tenant.
  • Prove it from the outside: sign in as one test account and try to read, change and delete another account's rows, with the public key alone and with a real session.
  • Fix it in migrations with tests that fail when isolation breaks, so the next change can't quietly undo it.

The same pattern, access enforced where the user cannot reach it and proven by tests rather than trusted on inspection, is how the founding team's own multi-tenant telehealth platform keeps each organization's patients apart.

Want this kind of engineering on your system?

Next sheet · N‑103 How we counted the tests