Skip to content
Lab / Building AllStillGood / 01
SecurityAIBuild in public

The one way AI-built apps leak data most often — and how to check yours in two minutes

If you shipped with Lovable, Bolt, Cursor, Replit or v0 and real people are using it, this is the check we'd run first. What Row Level Security actually is, how to look at yours without reading code, and why fixing it once isn't enough.

Jul 27, 2026Daniel5 min readAllStillGood

The check that finds the most dangerous thing in an AI-built app is embarrassingly small.

We know because we wrote it. It's about thirty lines. It asks your database for a list of the tables it will talk about, then asks each one for a single row — using the key that's already sitting in your app's public JavaScript. If a row comes back, the check is done. That table is readable by anyone on the internet.

There's no exploit in that. No payload, no clever trick, nothing that would make a security researcher raise an eyebrow. It just asks politely, in the same way your own app asks, and the database answers. Which is the whole problem: if a thirty-line function can read your customers table, so can a curious teenager with the browser dev tools open.

If you built something with Lovable, Bolt, Cursor, Replit or v0 and real people are using it, this is the thing we'd check first. Here's what it actually is, and how to look at yours without reading any code.

What's actually going on

When your app uses Supabase, it isn't talking to a database the old-fashioned way, through a server you control. Supabase puts a public API in front of your tables, and your frontend talks to it directly using a key called the anon key.

That key is public on purpose. It's in your shipped JavaScript, visible to every visitor, and that's fine — it's supposed to be. The anon key doesn't grant permission. It just identifies your project.

What grants permission is a per-table setting called Row Level Security. Think of it as a lock on each table, plus a rule about who gets in: you can read a row if it belongs to you. When the lock is on and the rule is written, a stranger holding the anon key gets nothing back. When the lock is off, the anon key can read everything in that table — names, emails, phone numbers, whatever you're storing.

Nothing about your app looks different in either case. Your login page still works. Your dashboard still shows the right data to the right person. The app behaves exactly the way you designed it, because your app is asking politely too. The difference only shows up when somebody asks without logging in, and nobody in your normal life does that.

Why it ends up off

It's tempting to blame the AI tool, but the mechanism is more boring than that.

A table starts with the lock off. Everything works with the lock off — that's the state where you never hit a permissions error while you're building. You ask for a feature, the tool builds the feature, the feature works, you ship. At no point does anything fail in a way that would send you looking.

And the tool that wrote your schema isn't keeping a running list of which tables ended up public. It's answering the request in front of it. Ask it to add a table for customer records, and you'll get a table for customer records. You'd have to know to ask the second question — the one you don't know exists.

This is why "my app works" and "my app is safe" aren't the same sentence, and why the gap is almost impossible to notice from the inside.

How to check yours

Two routes, depending on how much you want to poke at it. Both take a couple of minutes.

The dashboard route. Open your Supabase project and go to the Table Editor. Supabase marks tables that have no Row Level Security with a warning label, so you're looking for tables flagged as unrestricted. Then open the Authentication → Policies view, which lists every table alongside whether it has any policies at all. A table with the lock off, or with the lock on and no policies written, is a table to look at closely.

The "what does a stranger see" route. This is what our check does, described plainly: take the Supabase URL and anon key out of your own app (they're already public — they're in the page source), open a private browser window so you're definitely logged out, and ask the API for one row from a table. Whatever comes back is what an anonymous visitor can see. If you get an empty list or a permissions error, the lock is doing its job.

What "wrong" actually looks like

Not every readable table is a problem. A blog's posts table is supposed to be readable by strangers — that's the point of a blog. A customers table is not.

So the check tells you what's readable, and you decide what should be. In practice the sort that matters is quick: does this table contain anything that belongs to one specific person? If yes, and a stranger can read it, that's the one to fix today.

The fix itself is usually two statements. Here's the shape of it:

sql
alter table public.customers enable row level security;

create policy "own rows" on public.customers
  for select using (auth.uid() = user_id);

You don't need to understand that. You need to be able to hand it to the tool that built your app and say "do this to my customers table." That's a genuinely different skill, and it's the one you already have.

The part that catches people

Here's what we'd want you to take away, more than the check itself.

You can fix this today and have it back next month. Turning on a lock is a change to your database, and the next time you ask your AI tool to restructure something, add a feature, or "fix" a permissions error that's blocking it, the fast path back to working code often runs straight through the lock you just closed. There's no alarm when that happens. You'll get a working app and a green deploy, same as always.

The failure this post is really about: a fix that held for two deploys, then quietly came off on the third. A scanner gives you a report; the thing you need is something that remembers the last one.

That's the uncomfortable bit about one-off security checks in general, and the reason we stopped thinking about this as a scan and started thinking about it as a watch.

A scan tells you the state of your app on a Tuesday. It says nothing about Wednesday.

If you take one thing from this: after the next time an AI tool touches anything near your database, check again. Even just the dashboard route. It takes two minutes and it's the difference between finding out from a screen and finding out from a customer.

AllStillGood is what we're building because we got tired of remembering to do that check by hand. It runs this one, and the rest of them, against an app you've proven you own — read-only, never with your service_role key — and tells you in plain English when something that was fine yesterday isn't anymore. The first baseline scan is free, and it'll tell you where you stand right now.