Skip to content
RowShield
All posts

Your AI wrote the schema. Nobody wrote the policies.

We generated a Supabase backend from a prompt, then asked it as a stranger for every row in every table. Here is what came back, and the query that would have warned us.

RowShield5 min read
rlssupabase

Generated backends are good now. That is not a setup for a joke at anyone's expense — the schemas that come out of a decent prompt are better normalised than a lot of hand-written ones, with sensible types, real foreign keys and indexes in the right places.

Then we pointed an anonymous request at one and read every row it had.

Everything below is our own project, built for this post. No customer schema appears here and none ever will.

The prompt, and what it produced

We asked for a backend for a small equipment-rental marketplace: owners list items, renters book them, both sides message each other, payments are recorded. Nothing unusual, and deliberately the shape of a real product rather than a tutorial.

What came back was a complete migration. Seven tables, foreign keys throughout, timestamps with defaults, an enum for booking status, and three indexes on the columns you would actually filter by.

create table public.bookings (
  id            uuid primary key default gen_random_uuid(),
  listing_id    uuid not null references public.listings (id),
  renter_id     uuid not null references auth.users (id),
  starts_at     timestamptz not null,
  ends_at       timestamptz not null,
  status        booking_status not null default 'pending',
  total_cents   integer not null,
  created_at    timestamptz not null default now()
);

create index bookings_renter_id_idx on public.bookings (renter_id);

Read that on its own and there is nothing to object to. It is the table you would have written.

Seven tables, four relations, three indexes

The full migration created profiles, listings, bookings, messages, payments, reviews and availability. Every relationship a reviewer would expect is present and correctly typed. The enum is constrained. The money column is an integer in minor units rather than a float, which is a detail a surprising number of hand-written schemas get wrong.

If the question is "did the assistant understand the domain", the answer is plainly yes.

Zero policies

The migration contains no alter table ... enable row level security and no create policy. Not a bad policy. Not a permissive one. None.

select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
order by tablename;
 tablename     | rowsecurity
---------------+-------------
 availability  | f
 bookings      | f
 listings      | f
 messages      | f
 payments      | f
 profiles      | f
 reviews       | f

Seven tables, seven f.

This is the structural point of the whole post, and it is worth stating without any implication of carelessness. Designing a schema and deciding who may read it are two different jobs. The first is fully described by the request — an equipment-rental marketplace has listings and bookings, and that is derivable. The second is not described by the request at all. Nothing in "build me a rental marketplace" says whether a renter may see another renter's messages, and the assistant did not invent an answer, because inventing one would have been worse.

The migration is complete on its own terms. It is just that its terms did not include authorisation.

The request we made with nothing but the anon key

The anon key is designed to be public. It sits in the client bundle of every Supabase application, it is in ours, and finding it took one view-source. That is not a leak — it is the intended design. The key identifies the request as anonymous; the policies are what decide the answer.

There were no policies.

curl "https://EXAMPLE-PROJECT.supabase.co/rest/v1/bookings?select=*" \
  -H "apikey: ${ANON_KEY}" \
  -H "Authorization: Bearer ${ANON_KEY}"

What came back

Every row.

[
  {
    "id": "0f0d…",
    "listing_id": "b41c…",
    "renter_id": "9a77…",
    "starts_at": "2026-09-14T09:00:00+00:00",
    "status": "confirmed",
    "total_cents": 48000
  },
  …
]

Then we did the same to messages, which returned the full text of every conversation on the platform, and to payments, which returned amounts, statuses and the identifiers linking them to named people in profiles. Joining those three tables gives you who paid whom, how much, when, and what they said to each other about it.

No authentication. No exploit. No unusual request. This is the documented behaviour of a correctly functioning PostgREST endpoint over a table with row-level security disabled, and it returned a 200.

RLS is off. The anon key is public. Every row is readable.

That sequence is the entire failure, and it contains no bug. Every component did exactly what it is designed to do.

The query that would have told us in advance

The pg_tables query above is the whole early-warning system, and it costs nothing to run. Three follow-ups make it a real check rather than a glance:

  • Enable RLS on every table in public, then confirm it. A table with rowsecurity = f is readable by anyone holding the key that is in your bundle. Enabling it denies everything by default, which will break your application — that is correct, and the breakage is how you find out which access paths you actually rely on.
  • Write the policies the breakage reveals, then check that a policy exists for every table. A table with RLS enabled and no policies is not secure-and-working; it is secure-and-broken, and someone under deadline pressure tends to fix that with a policy that permits everything.
  • Test as an anonymous caller, not as yourself. The dashboard runs your queries as an owner and bypasses policies entirely, which means a table can look perfectly fine there and return everything to a stranger. The only answer that counts is the one that comes back over the API with the anon key.

If you run these against a project you generated and every table comes back with RLS enabled and a policy attached, you have a real answer about today and the exercise cost you ten minutes. If several come back f, the useful thing to know is that this is the normal outcome, it says nothing about your ability, and the fix is a migration you can write this afternoon.

The reason we built a product around it is the third check. Doing this once tells you about today, and the next generated migration is a new table.

Run a free audit

RowShield is an independent product with no affiliation to, or endorsement from, Supabase.

RowShield checks what a deployed Supabase app exposes: a free anonymous, read-only probe, and scheduled policy-metadata and drift checks for connected projects. Run a free audit.

RowShield is a Veristria product. More about RowShield.