On 25 September, UpGuard published the largest study yet of Supabase data exposure: it found 16,326 Supabase-hosted databases with readable tables exposed to the open web, drawn from roughly 300,000 candidate domains. More than half showed indicators of personal data. The victims included a valet service with 100,000 customer records and licence plates.
Supabase’s response was that its projects are “secure by default” and that security is a shared responsibility. Both halves of that sentence are true. The interesting part is that the default is real but path-dependent, and the path your AI coding agent takes is the one without it.
TL;DR
- UpGuard found 16,326 exposed Supabase databases; over half carried personal data indicators.
- Row Level Security genuinely is on by default in the Table Editor, but that default is a pre-ticked field in one React form, not a property of the table-creation call.
- The table-creation API has no RLS parameter at all. Creating a protected table is two operations, and anything that does the first without the second ships a public table.
- Supabase’s MCP server blocks destructive SQL pending human confirmation, but only advises on tables with RLS disabled. Losing data halts the machine; exposing data prints a note.
- The fix is a five-line query and a linter in CI, not a policy document. Run both this week.
The default is a pre-ticked box, not a property of the table
In the Supabase dashboard, the new-table form is built by a function called getDefaultTableField(), and it sets isRLSEnabled: true. Tick nothing, change nothing, click save, and you get a protected table. That is a real default and it was a real improvement.
Now look one layer down. The Studio’s own create-table mutation types its request body as exactly three fields: name, schema and comment. The same shape appears in postgres-meta, the service that fronts every programmatic table operation, where postgresTableCreateSchema accepts name, an optional schema and an optional comment, and nothing else. The function underneath emits a bare CREATE TABLE schema.name ();.
rls_enabled exists only on the update schema, and it is applied only when explicitly supplied. In other words, the API cannot create a protected table. It can create a table, and then you can protect it. The dashboard does both steps. The pre-ticked box is what makes the second call happen.
So the safe default does not live in the database, in the API, or in the platform. It lives in the initial state of a form component. Every client that does not render that component inherits the Postgres default instead, which is that row security is off.
Your agent walks in through the other door
This is where the exposure comes from, and it is not a subtle mechanism. Supabase’s MCP server gives agents execute_sql and apply_migration, both of which run raw SQL. An agent asked to build a sign-up flow writes create table public.users (...), because that is what the training data says a table looks like. Postgres does exactly as asked. No box, pre-ticked or otherwise, is anywhere in that path. The same is true of a migration file, the CLI, psql, and every scaffold an agent has ever generated.
Then the second half of the model does the damage. The publishable key in your front-end bundle is designed to be public; it identifies the project, it does not authorise the caller. Row Level Security is the only thing standing between that key and the table. With RLS off, the key is a full read grant, and UpGuard did not need to guess table names to prove it: query a Supabase project for a table that does not exist and PostgREST helpfully returns the name of one that does.
Two risks, two very different kinds of control
Supabase has put real engineering into stopping an agent from destroying data. There is a dedicated 136-line module whose only job is to classify SQL as destructive. It catches DROP, DELETE, TRUNCATE, ALTER TABLE ... DROP COLUMN and UPDATE without a WHERE clause. It is hardened against obfuscation, chasing destructive verbs through EXECUTE format(), concat() and dollar-quoted strings. When it fires, the server raises an elicitation: the run stops and a human is asked to confirm before the statement executes.
Supabase has also shipped an RLS control. It is called buildRlsDisabledAdvisory, it is marked level: 'critical' and priority: 1, and its message instructs the model in capitals that it must surface the issue to the user. It is a good warning. It is also a string appended to the response of list_tables, and it is explicitly marked do-not-auto-apply, because switching RLS on without a policy locks everyone out and breaks the application.
That reasoning is sound. But look at the shape of the two controls side by side. One is enforced by the harness: the SQL does not run until a person answers. The other is enforced by the model’s willingness to follow an instruction, and it only appears at all if the agent happens to call list_tables. An agent that creates a table and moves on never sees it.
Destroying data stops the machine. Exposing data prints a note, and only if somebody asks. That asymmetry is not unique to Supabase; it is the default posture of nearly every agent integration we review. Reversible-looking actions get gates, and irreversible disclosure does not.
The guidance is good. It is also optional.
Credit where it is due. Supabase publishes an official agent skill, and the security content in it is better than most internal standards we see. It tells the agent to enable RLS on every table in an exposed schema, warns that views bypass RLS unless created with security_invoker, flags that TO authenticated alone is authorisation theatre, and notes that an UPDATE policy without WITH CHECK lets a user reassign a row to somebody else. That is genuinely expert material.
The companion Postgres skill is weaker at the point of use. Its always-loaded file is a router: it lists “Security & RLS” as CRITICAL in a priority table, then points at a folder of 31 rule files, three of which are security. The rule that says “enable row level security” only reaches the model if the model chooses to open that file.
A skill must be installed, triggered, loaded and then obeyed. Four probabilistic steps stand between the advice and the table. The CREATE TABLE statement needs none.
What to do this week
Ask which door your tables came through. If any table in your project was created by an agent, a migration file or a script rather than by a human clicking in the dashboard, assume RLS is off until you have checked. Five lines settle it:
select n.nspname as schema, c.relname as table_name,
c.relrowsecurity as rls_enabled,
(select count(*) from pg_policy p where p.polrelid = c.oid) as policies
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where c.relkind = 'r' and n.nspname not in ('pg_catalog','information_schema')
order by c.relrowsecurity, policies;
Two rows sort to the top and both are bad: RLS disabled, and RLS enabled with zero policies attached.
Put the linter in CI, not in a review. Supabase open-sources its database linter, splinter, with 29 checks. Four of them cover this ground: rls_disabled_in_public is classified ERROR and externally facing, and it is joined by rls_enabled_no_policy, policy_exists_rls_disabled and rls_policy_always_true. That last one matters, because a permissive policy that evaluates to true passes every “is RLS on?” audit while protecting nothing. Run it as a pipeline gate that fails the build. A check that runs when somebody remembers is not a control.
Close the two-step window in the migration. Because creation and protection are separate operations, there is always a gap. Put alter table ... enable row level security and the policy in the same migration as the create table, and review them as one change.
The wider lesson
None of this is an argument against Supabase, which is a well-built product, or against agents, which we use daily. It is an argument about where a default has to live. A default enforced in a user interface protects the users of that interface. The moment the work moves to an API, an agent or a pipeline, the only defaults that survive are the ones built into the mechanism itself. Sixteen thousand exposed databases is what that gap looks like at scale.
When we audit an agent integration, the first question is not what the tools can do, it is which failures are gated and which are merely documented. If your team is shipping AI-assisted features on a client-direct database, or you want a second pair of eyes on what your agents have been allowed to create, get in touch. We build this sort of thing, and we break it on purpose first.
📷 Photo by Angelica Hasbon on Unsplash


