scribase
All posts
·Scribasesecurityrlssupabaseai-coding

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.

In September 2026 Cybernews reported on an UpGuard scan that found 16,326 Supabase-hosted databases publicly readable with no login (Cybernews). 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). Supabase's own CEO has described users disabling row level security as "a battle they are constantly fighting" (Hacker News).

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).

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:

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: 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 does the same for a schema dump, and the 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, 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 and row level security docs.