I Read 145 Supabase Projects Built by AI Builders. One Policy Keeps Showing Up.
A read through 1,086 public repositories built with Lovable and Bolt: what the generator writes into access policies, how often it matters, and a query to check your own project.
Vibe coding works. People with no engineering background ship real apps now: booking systems, loyalty programs, internal tools for actual companies. I have been reading the source of those apps, and there is a pattern in them that the builder writes, not the person.
What I did
I searched GitHub for the fingerprints these builders leave behind (the
lovable-tagger dev dependency, bolt.new in the README) and got about 1,086
public repositories. Of those, 145 ship Supabase migrations, which is where
access rules live. I read those migrations. Nothing else: no requests to any
app, no database connections, no trying keys. Published source only.
Of the 145, 27 have a real problem. That is 19%, roughly one in five.
Pattern 1: the policy your backend never needed
This exact shape appears 59 times across 10 different projects:
CREATE POLICY "System can insert payments" ON public.payment_transactions
FOR INSERT WITH CHECK (true);
The name says "System", so it reads like a rule for your backend. It is not.
There is no TO clause, which means the policy applies to PUBLIC, and
PUBLIC includes anon, the role behind the publishable key that ships in
your browser bundle. Anyone who opens your site's source can read that key.
The irony is that your backend never needed this policy. The service_role
key bypasses row level security entirely. So the policy grants your server
nothing it did not already have, and grants everyone else write access to the
table.
I found this shape on tables named payment_transactions, membership_upgrades,
tips_transactions, order_items, audit_logs. Money and audit trails.
Pattern 2: the table that looks protected
Of the 155 permissive write policies I found, 49 sit on tables that also have correct, owner-bound policies. That is what makes them hard to catch:
-- this one is right
CREATE POLICY "Users can view own payments" ON public.payment_transactions
FOR SELECT USING (auth.uid() = user_id);
-- this one, three lines down, is not
CREATE POLICY "Service can update payments" ON public.payment_transactions
FOR UPDATE USING (true);
You read the first policy, see auth.uid(), and conclude the table is locked
down. An external scanner that probes your API from outside cannot see this at
all. The migration file can.
Pattern 3: keys that made it into the repository
Seven, across the corpus. Two shapes, both produced by the same workflow:
A cron job calling an edge function, with the project's service_role JWT
pasted into the SQL:
SELECT net.http_post(
url := 'https://<project>.supabase.co/functions/v1/cleanup',
headers := '{"Authorization": "Bearer eyJhbGciOi..."}'::jsonb
);
And a committed .env holding a live Stripe secret key. Deleting the line does
not help: the key stays in git history. Only rotating it does.
What is not a problem, and why that matters
My first pass flagged 138 policies that let anon insert rows. Almost all of
them were newsletter signups, contact forms, support tickets. A public form is
supposed to accept writes from strangers. Reporting those as vulnerabilities
would have been noise, and noise is how a security report gets ignored.
So I narrowed the rule: an open INSERT counts only on money and audit tables,
where a forged row hurts regardless of intent. UPDATE, DELETE and ALL
still count everywhere, because "a stranger may modify other people's rows" is
not a design anyone chose.
There was a second correction, bigger than the first. Migrations are a journal, not a pile of files: a policy created in October and dropped in November is not a hole. Reading them flat, I got 24 false findings out of 77. Almost a third of my report was about problems the owners had already fixed. Had I sent those emails, the first line of each would have been wrong.
Check yours in thirty seconds
Run this in the Supabase SQL editor. It reads the policies your database actually has right now, which is better than reading migrations:
SELECT tablename, policyname, cmd, roles, qual, with_check
FROM pg_policies
WHERE schemaname = 'public'
AND cmd <> 'SELECT'
AND (qual = 'true' OR with_check = 'true')
ORDER BY tablename;
Every row it returns is a table a stranger can write to. If the roles column
says {public} or {anon}, that stranger is anyone with your publishable key,
which is everyone who opened your site.
For most rows the fix is deleting the policy, because it exists for a backend that bypasses RLS anyway:
DROP POLICY "System can insert payments" ON public.payment_transactions;
If the write really happens as the signed-in user, bind it to them instead:
CREATE POLICY "Users insert own payments" ON public.payment_transactions
FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
If you would rather I looked
I built a small tool for this and I run it by hand: send a public repository link and I will read it and reply, usually the same day. It reads published source only, and it will never tell you "your project is fine" as a verdict, because silence where a tool is blind is more honest than a green checkmark.
https://plumbline.nzt108.dev/check?ref=blog
I am nzt108, a full-stack developer. No company, no sales pitch, and the first ten are free.