Your AI-built app's database is probably public. A 20-minute audit.
Cursor, Lovable, Bolt and v0 write row-level security policies the way a contractor hangs a door: it opens, it closes, and nobody checks whether the lock engages. In the last few months I have opened a lot of client proj
Cursor, Lovable, Bolt and v0 write row-level security policies the way a contractor hangs a door: it opens, it closes, and nobody checks whether the lock engages. In the last few months I have opened a lot of client projects that came out of these tools, and the same three leaks show up again and again. All of them can be found and closed in about twenty minutes.
Here is the audit, in the order that finds the most damage fastest.
1. Log out and ask the API directly
Your UI is not the security boundary; the API is. So ignore the app and call the backend with an unauthenticated request and then with a second user's token.
For Supabase, the anon key is in the client bundle, which is normal and expected. What matters is whether it can read rows it should not:
# the anon key is public by design โ using it is the point
curl "https://<project>.supabase.co/rest/v1/orders?select=*" \
-H "apikey: $ANON" -H "Authorization: Bearer $ANON"
If that returns rows, either RLS is off for the table or there is a policy that allows anon. Both happen constantly in generated projects.
Then repeat with a signed-in user who owns none of the rows:
curl "https://<project>.supabase.co/rest/v1/orders?select=*" \
-H "apikey: $ANON" -H "Authorization: Bearer $USER_B_TOKEN"
200 with somebody else's rows is the failure. 200 with [] or a 401/403 is what you want.
2. Turn RLS on, then check that it is actually on
Generated migrations sometimes enable RLS and immediately add a permissive policy "so the app works", which is the same as no RLS with extra steps:
-- reads fine, protects nothing: every signed-in user can read every row
create policy "Enable read" on public.orders
for select using (auth.role() = 'authenticated');
-- what it should be
create policy "read own orders" on public.orders
for select using (auth.uid() = user_id);
Check the whole schema in one query instead of clicking through tables:
select tablename, rowsecurity
from pg_tables
where schemaname = 'public' and rowsecurity = false;
Every table in that list is readable by anyone holding the anon key unless a policy says otherwise โ and with RLS off, policies are not even consulted.
3. Look for the admin key in the client
This one is fatal and common. service_role bypasses RLS completely. If the AI put it in .env and the bundler inlined it into a client-side call, anyone can read, write, or drop your data.
Two checks:
# 1. did the key ship to the browser?
curl -s https://your-app.com/_next/static/ | grep -o "service_role" | head
# 2. does the local build contain it?
grep -r "service_role" .next/ dist/ build/ 2>/dev/null | head
Any variable that is prefixed for client exposure (NEXT_PUBLIC_, VITE_, PUBLIC_, EXPO_PUBLIC_) and contains a service key is a breach waiting for a crawler. Move it to a server function or edge function and rotate the key.
4. Storage buckets: the quiet one
Files are usually the second-most-damaged table. A bucket created as "public" serves every upload to anyone with the URL โ which includes profile photos, invoices, ID documents, and screenshots that customers assumed were private.
-- find public buckets
select id, name, public from storage.buckets where public = true;
Then check the storage policies the same way you checked the tables: an upload policy without an owner path (storage.foldername(name)[1] = auth.uid()::text) lets user A overwrite user B's file if the path is guessable.
5. The functions nobody re-read
SECURITY DEFINER functions run with the definer's rights. A generated helper that returns "the current user's dashboard" and takes a user_id argument is an IDOR with a bow on it. Search for it and check the arguments:
select n.nspname, p.proname, p.prosecdef
from pg_proc p join pg_namespace n on n.oid = p.pronamespace
where p.prosecdef and n.nspname = 'public';
If the function trusts a client-supplied id, change it to auth.uid() and let the database decide who the caller is.
6. The same audit on the other side of the glass
If the app is Firebase, the equivalent is the rules file: allow read, write: if request.auth != null; on a collection that holds orders is the same bug in a different syntax. If it is a custom Express/FastAPI backend, the equivalent is whether every route that takes an id also checks ownership โ and whether the id comes from the URL (attacker-controlled) or the session (not).
What to do when you find one
Rotate the key, fix the policy, and re-run the two curl calls above โ the fix is only real when the second user gets an empty array. Then write the check down somewhere, because the next AI-generated feature will add a table, and the table will arrive with RLS off.
I do this for a living: reading generated codebases and closing the gap between "works" and "safe to ship". The database layer is usually a day of the work, and it is the part founders cannot see in a demo.
CPC AI Studio (CPC AI Inc., Seoul) โ we finish AI-built apps: auth, payments, store submission. 355 projects to date.
Studio: https://cpcai-studio.netlify.app/en/ ยท Contact: [email protected]
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.