# 16,326 exposed Supabase databases: how RLS gets turned off by AI coding tools, and how to catch it before deploy

> Why a public table with row level security off is readable by anyone holding your anon key, the four ways generated code switches it off, a SQL query and a free linter that catch it, and what a backend that refuses the migration looks like.

Source: https://scribase.com/blog/exposed-supabase-databases-how-rls-gets-turned-off-and-how-to-catch-it

By Scribase, 2026-11-12.

In September 2026 Cybernews reported on an UpGuard scan that found 16,326
Supabase-hosted databases publicly readable with no login
([Cybernews](https://cybernews.com/news/16000-supabase-databases-exposed/)).
A year earlier, one disclosed pattern in apps generated by AI coding tools
exposed 303 API endpoints across 170 apps, tracked as CVE-2025-48757
([GuardLayer](https://www.guardlayer.io/blog/supabase-security-breaches)).
Supabase's own CEO has described users disabling row level security as "a
battle they are constantly fighting"
([Hacker News](https://news.ycombinator.com/item?id=46662304)).

None of these are Postgres bugs, and none of them are Supabase bugs. They are
the same default, repeated: a table in the `public` schema with row level
security off, behind an API that is reachable with the anon key your client
ships. This post explains the mechanism, the four ways AI-generated code
turns RLS off, and how to catch it before the migration runs.

## Why "RLS off" means "public"

On a Supabase-style backend every table in `public` is exposed through
PostgREST at `/rest/v1/<table>`. The anon key is a public JWT; it is in your
JavaScript bundle by design. The `anon` role has `SELECT`, `INSERT`,
`UPDATE` and `DELETE` grants on `public` tables by default. The only thing
standing between the internet and every row is row level security:

- RLS **on** with no policy: nobody reads anything (deny by default).
- RLS **on** with a policy like `USING (user_id = auth.uid())`: each user
  reads their own rows.
- RLS **off**: anyone with the anon key reads, edits and deletes every row.

So "RLS off" is not a weaker setting. It is the same as publishing the table.

## The four ways generated code turns it off

Watching what coding tools produce when asked for "a table for orders", the
leak arrives in one of four shapes:

1. **`CREATE TABLE` without `ENABLE ROW LEVEL SECURITY`.** Tables created
   through the SQL editor, a migration file or an ORM start with RLS off.
   The dashboard's table creator turns it on by default; `psql`,
   `supabase db push` and an agent's `sql.execute` call do not. The model
   writes the DDL it has seen most often, which is the bare `CREATE TABLE`.
2. **A policy that is `USING (true)`.** The agent enabled RLS, the app
   returned an empty list, so the agent "fixed" it with a permissive policy
   for `anon`. RLS is on; the table is still public.
3. **Disabling RLS to make a server job work.** A cron or a backend route
   failed with a permission error, and the quickest patch was
   `ALTER TABLE ... DISABLE ROW LEVEL SECURITY`. The right patch was the
   `service_role` key on the server, which bypasses RLS without opening the
   table.
4. **A view that bypasses RLS.** Postgres views run with the owner's
   privileges unless created with `security_invoker = true`, so a view over a
   protected table can hand the rows out anyway
   ([Supabase: Database Advisors](https://supabase.com/docs/guides/database/database-advisors)).

All four pass the test suite, because the test suite uses the service key.

## How to catch it before deploy

**Check the live schema.** One query lists every public table with RLS off:

```sql
select tablename
from pg_tables
where schemaname = 'public' and rowsecurity = false;
```

Anything in that result set is readable by anyone holding the anon key.
Supabase's Security Advisor shows the same list in the dashboard (lint
`0013 rls_disabled_in_public`, plus `0007 policy_exists_rls_disabled` for
the "I wrote policies but never turned RLS on" case), and the CLI exposes it
as `supabase db advisors`.

**Lint the migration before it runs.** The advisors tell you after the
table exists. A migration linter tells you before. We publish a free one at
[scribase.com/tools/migration-lint](https://scribase.com/tools/migration-lint):
paste the migration, no login, and it returns a finding per statement.
`missing_rls` is a **BLOCK** for a `CREATE TABLE` in `public` without
`ENABLE ROW LEVEL SECURITY`; a permissive `USING (true)` policy on a
sensitive table is flagged; and the lock-safety rules catch the migration
that is safe for data but takes the table down (`CREATE INDEX` without
`CONCURRENTLY`, a `NOT NULL` added with a full table scan). The
[RLS checker](https://scribase.com/tools/rls-checker) does the same for a
schema dump, and the
[policy generator](https://scribase.com/tools/rls-policy-generator) writes
the `auth.uid()` policies for a table so the agent does not have to invent
them.

**Keep the service key off the client.** Grep the bundle for `service_role`
and for any `VITE_`/`NEXT_PUBLIC_`/`EXPO_PUBLIC_` variable that holds a
secret. The anon key is the only key that belongs in a browser or an app.

**Test as a stranger.** With RLS on and the policies written, call the REST
API with the anon key and no session and confirm that private tables return
nothing. This is the one check that does not depend on reading SQL.

## What "secure by default" has to mean

Advice is not a default. The honest version of this post ends with the
observation that every item above is something a person remembers to do,
which is exactly the step that fails when a coding agent is writing the
migrations at two in the morning.

On [Scribase](https://scribase.com/security), the same lint runs inside the
migration API: a migration that would leave a public table without row level
security is refused with a `409` before it executes, not flagged afterwards.
The free tools above are that rule, exposed to anyone, for any Postgres.
And because the project also carries auth, storage, realtime, functions and
a Resend-compatible email API behind the same URL and anon key, the usual
reason to hand an agent a second vendor's secret key goes away with it.

Sources, each read on 2026-11-12 except where noted: Cybernews on the UpGuard
scan (September 2026, the article returned an access error to automated
readers on the day of writing; the figure is quoted from the report as cited
on scribase.com/security), GuardLayer on CVE-2025-48757, the Hacker News
thread above, and Supabase's
[Database Advisors](https://supabase.com/docs/guides/database/database-advisors)
and
[row level security](https://supabase.com/docs/guides/database/postgres/row-level-security)
docs.