Dev.to Security 🔐 Cybersecurity 👁 0 📖 4 min read

16,000 databases, and the four ways row-level security silently fails

In late September 2026, UpGuard researchers reported finding more than 16,000 Supabase databases exposing readable tables containing personal data, passwords, or authentication tokens. They had analysed roughly 300,000 d

In late September 2026, UpGuard researchers reported finding more than 16,000 Supabase databases exposing readable tables containing personal data, passwords, or authentication tokens. They had analysed roughly 300,000 domains for the signature of a Supabase project, then checked whether a users table — or something with another name — answered.

The attribution matters more than the number. In the reporting, the researchers point at "poor application security configurations, including missing or ineffective row-level security policies and misuse of public keys", and add: "The common thread is that these sites are created by AI coding agents and the humans are unaware of the configuration." (BleepingComputer)

That last sentence is not an accusation about AI. It is a statement about a specific property of Postgres: row-level security fails silently. A missing WHERE clause returns an error you can see. A missing policy returns data, and a 200.

Here are the four ways it happens, in the order I find them, with the query that detects each one.

1. RLS was never enabled on the table

Row-level security is opt-in, per table. CREATE TABLE does not enable it. A table created through a dashboard editor may arrive with RLS on; a table created by a SQL migration — which is how a real project creates tables — arrives with relrowsecurity = false. A policy cannot exist on a table that has RLS disabled, and nothing warns you.

select c.relname
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'r'
  and not c.relrowsecurity;

Every row this returns is a table where anon and authenticated can read whatever the API exposes, policy or no policy.

2. The policy exists, but not for the role that is asking

A policy can be scoped to authenticated, to anon, to a custom role — or to PUBLIC, which is the default and is the one people forget.

select tablename, policyname, roles, cmd
from pg_policies
where schemaname = 'public'
order by tablename, policyname;

Read roles first. A policy granted {public} applies to every role that has table privileges — including the two the Data API speaks for. Policies and grants are two separate layers, and a table can be protected by one and open at the other. Supabase's 2026-10-30 change to automatic grants exists because those two layers were being confused in production.

3. A security definer function runs as its owner and steps around RLS

This is the one that survives a clean-looking schema. A function declared security definer executes with the privileges of the role that created it. If that role owns the table — usually postgres — the function reads across every tenant, and any caller who can execute it inherits that ability. Your policies are still in place; they are simply not in the path.

select p.proname, r.rolname as owner
from pg_proc p
join pg_namespace n on n.oid = p.pronamespace
join pg_roles r on r.oid = p.proowner
where n.nspname = 'public' and p.prosecdef
order by 1;

Each row needs reading line by line: does it take the tenant from an argument or from the authenticated claim, and does it verify membership before returning anything?

4. The view on top of the correct policy is not correct

A view runs with the privileges of its owner by default. So a view that joins two tenant tables can hand back rows the caller's policies would have blocked: the tables are protected, the view is a hole in the same database.

select c.relname, c.reloptions
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public'
  and c.relkind = 'v'
  and not coalesce(array_to_string(c.reloptions, ',') like '%security_invoker=true%', false)
order by 1;

security_invoker = true makes the view run as the caller. A view without it deserves a test, not a glance.

The test that settles it

Reading the schema tells you what was declared. It does not tell you what your application does. The only way to know whether tenant A can reach tenant B is to be tenant A and try — as the role your application actually uses, with the same session variable it actually sets.

In a Postgres app that pins the tenant with SET LOCAL, that is one transaction and one assertion:

begin;
set local role app_rw;
set local app.current_tenant = '<tenant-A-uuid>';

-- returns rows belonging to anyone other than A? then it leaks.
select count(*) from notes
where tenant_id is distinct from current_setting('app.current_tenant', true)::uuid;
rollback;

Run it against a seeded database. On an empty table both a correct policy and a broken one return zero, which is why "0 rows" on its own is not evidence of anything — that is the mistake a scanner makes and a probe must not.

Then put the probe in CI, and the next policy regression fails a build instead of leaking quietly.

What "done" looks like

Not a grade, and not a PDF. Four artefacts:

  1. every policy listed with a verdict — enforced, bypassable, or absent;
  2. the cross-tenant probes committed to the repository, so they run on every pull request;
  3. the missing or wrong policies as a reviewable migration diff;
  4. before/after output of the same probes, which is the only thing that answers a security questionnaire.

I wrote the pattern up, with the probe and the four queries above as a runnable kit: verdantstack.dev/services/rls-audit. If you would rather do it yourself, the queries in this post are the ones to start with, and they cost nothing.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.