On September 25, 2026, security firm UpGuard published a scan that found 16,326 Supabase-hosted Postgres databases with tables anyone on the internet can read. No exploit was involved. The requests were signed with each project's anon key, the credential every Supabase app ships in its frontend bundle by design. The mechanism behind the number is Supabase RLS, row level security, and specifically the fact that whether it is enabled depends on which of two paths created the table. A table made in the dashboard gets the guard rail. A table made through SQL or the API, the path AI coding tools take, gets the Postgres default, which is off. That gap is mechanical, it is concentrated in generated apps, and you can audit it from your own catalog in five queries.
The scan used the key every app publishes
UpGuard's method matters because it establishes how low the bar is. Sites built on Supabase can be fingerprinted from their public JavaScript, which contains the Supabase key names and database addresses. Working from BuiltWith technographic data and the Chrome UX Report dataset on BigQuery, the researchers assembled roughly 300,000 unique domains with Supabase indicators and queried each one for a users table, exactly as the site's own frontend would. The responses either returned data or leaked table names as hints. Everything in the study was reachable by construction: if the scan could read it, so could anyone who opened the site's source.
What came back was not test data. Over half of the readable databases showed indicators of personal information. One platform in India exposed records on 65,467 people, including driver's license, passport, PAN, and Aadhaar details, alongside 100,000 private messages. A Philippine OTP service exposed 100,000 SMS messages containing one-time codes. A US valet startup exposed 100,000 customers. An African consulate in France exposed 25,000 users with physical addresses, and a Canadian immigration service exposed 5,000 records, 884 of them with plain text passwords.
Supabase's response, given to TechCrunch by CISO Bil Harmer, was that "we provide secure defaults and tooling, and customers control how their own projects are configured." Read that as the position every Postgres-as-a-service vendor will take when this recurs, because on the narrow facts it is accurate: nothing in the study was a platform vulnerability. The interesting question is why the same misconfiguration appeared sixteen thousand times, and the answer sits in how Postgres itself splits authorization.
Grants and policies are two separate switches
Postgres authorizes a query in two stages. Grants decide whether a role may run an operation against a table at all. Policies, the row level security layer, decide which rows that operation touches once it is allowed, working like a WHERE clause appended to every query. The two are independent switches, and RLS is the opt-in one: a table enforces no policies until ALTER TABLE ... ENABLE ROW LEVEL SECURITY runs against it. Once RLS is enabled, Postgres applies a default-deny rule, so a table with RLS on and no policies returns nothing at all.
Supabase wires its API onto this model. An unauthenticated request runs as the anon role, a signed-in user's request runs as authenticated, and on existing projects a new table in the public schema grants full privileges, select through delete, to anon, authenticated, and service_role alike. The anon key that authorizes the first role is embedded in the client bundle on purpose; it was never a secret. The entire protection model for a Supabase table therefore rests on RLS being enabled, and Supabase's docs are explicit that adding policies later does not take the default grants back.
Which leaves the creation paths. Supabase turns RLS on automatically for tables created in the dashboard's Table Editor. Tables created programmatically, through the SQL editor, migrations, or the API, keep the Postgres default. UpGuard's report draws exactly this line: the protected path is the one a human clicks through, and the unprotected path is the one code takes.
The enforcement default follows the creation path, and the anon role's grants exist on both tables either way.
A table has four states, and only the safe one is loud
Walk the combinations and the shape of the incident falls out.
RLS off. The default for any table created through SQL. The anon role's grants apply without a row filter, so every row is readable and writable with the public key. Supabase's Security Advisor flags this as rule 0013, rls_disabled_in_public, an error-severity lint whose description says it plainly: anyone with the project URL can read, edit, and delete all data in the table.
Policies written, RLS never enabled. The trap state. CREATE POLICY succeeds on a table where RLS is off, and does nothing. A developer, or an agent, that wrote careful policies believes the table is protected while it remains fully open. The advisor has a dedicated rule for it, 0007, policy_exists_rls_disabled.
RLS on, no policies. Default deny. The app's first query against the table comes back empty and someone investigates within the hour. This is the only misconfigured state that announces itself.
RLS on, permissive policies. Enforcement is active but the predicate gives access away: a policy with USING (true) for anon (rule 0024, permissive_rls_policy), or one keyed on user-editable metadata that an end user can rewrite to satisfy it (rule 0015). The table looks governed in every schema diff and behaves like state one.
Three of the four failure states keep the application working normally while the data sits open, which is how thousands of production apps ran exposed for months without anyone noticing. The failure mode selects for silence.
The audit is five catalog queries
Everything above is visible from inside the database, in the same catalogs Postgres uses to enforce it. Run these in the SQL editor of any project you own:
-- 1. Tables in the exposed schema with RLS off
select schemaname, tablename
from pg_tables
where schemaname = 'public' and not rowsecurity;
-- 2. Policies sitting on tables where RLS is off (they enforce nothing)
select p.tablename, p.policyname
from pg_policies p
join pg_tables t
on t.schemaname = p.schemaname and t.tablename = p.tablename
where p.schemaname = 'public' and not t.rowsecurity;
-- 3. Policies whose predicate is literally true
select tablename, policyname, roles, cmd
from pg_policies
where schemaname = 'public' and qual = 'true';
-- 4. What the API roles may do, table by table
select table_name, grantee, privilege_type
from information_schema.role_table_grants
where table_schema = 'public'
and grantee in ('anon', 'authenticated');
-- 5. Roles that skip RLS entirely
select rolname from pg_roles where rolbypassrls;
Query one is the incident: any row it returns is a table readable with the anon key. Query two finds the trap state, policies that exist but bind nothing. Query three surfaces the permissive predicates, though only the literal case; a policy that is effectively true through a tautology needs a human read of pg_policies.qual. Query four shows the grant surface those policies are supposed to filter, and query five lists the roles, service_role among them, for which none of this applies, which is the concrete reason that key must never reach a browser.
One nuance worth knowing before you write policies from the results: a policy comparing auth.uid() = user_id fails closed for anonymous requests, because auth.uid() returns null and a null comparison is never true. The leaks in UpGuard's dataset are almost never subtle predicate bugs. They are enforcement that was never switched on.
Generated schemas concentrate the failure
The split between creation paths would matter less if the paths were used evenly. They are not. AI app builders and coding agents create tables the programmatic way, through generated SQL and API calls, which is precisely the path that skips the RLS default. TechCrunch's coverage frames the exposed projects as largely vibe-coded, and the framing is structural rather than pejorative: a tool that scaffolds an app in minutes emits the schema, the grants, and sometimes the policies as generated code, and nothing in that loop verifies the permission model does what the prompt assumed. The human who would have clicked through the Table Editor, and collected its guard rail, was never in the flow.
This is the same class of problem we keep meeting wherever agents touch production data. An agent holding a write path it was never meant to use is how a Perplexity browser agent ended up with write access to a production Supabase. Permissions that were correct at grant time and wrong by use time are the subject of stale authorization in agent systems. The RLS incident adds the third face: authorization that was never verified in the first place, generated at the same speed as the schema it fails to protect.
The audit queries above are necessary and bounded, and it is worth being precise about the boundary. They verify that enforcement exists, not that it is right: a policy can pass every advisor rule and still encode the wrong predicate for your tenancy model, and a clean run today says nothing about the migration an agent applies tomorrow. Checks like these only hold if they run wherever schema changes ship, not once, after a headline.
The durable lesson from the 16,326 is about defaults under generation. Every default that relies on a human being present at creation time quietly stops working when creation is automated. Supabase's dashboard default is well designed for the hand-built app, and the ecosystem building on top of it moved to a path where the default never fires.
Where Datapace fits
A permission model nobody has verified is context an AI should not be trusted to assume. Datapace is building the context layer between your databases and your AI: resolved meaning validated by the people who own the data, the workload evidence beside it (cost, performance, usage and freshness, lineage), and a policy gate over what an agent may do and access, served over MCP. Whether a table enforces what its owners believe it enforces is exactly the kind of fact that layer exists to resolve and keep resolved. If agents are creating or changing schema anywhere in your stack, safe agent access to a production database is where our thinking on the enforcement side starts, or book a call.
Sources
- UpGuard, Everything Everywhere: Systemic Data Exposure in Supabase Apps, Greg Pollock (scan methodology, 300,000 domains, readable-table count, exposure examples, creation-path RLS gap).
- TechCrunch, Some Supabase customers are publicly exposing reams of people's data to the web, September 25, 2026 (exposure examples, vibe-coding framing, Bil Harmer statements).
- Supabase Docs, Row Level Security (grants vs policies, default grants on new public tables, anon and service_role keys, auth.uid() null behavior).
- Supabase Docs, Database advisors (lint rules 0007, 0008, 0013, 0015, 0024, advisor surfaces).
- PostgreSQL Documentation, Row Security Policies (RLS off by default, ENABLE ROW LEVEL SECURITY, default deny, BYPASSRLS, permissive vs restrictive combination).
- PostgreSQL Documentation, pg_tables and pg_policies (rowsecurity column, policy view columns).