top of page

The 6 Security Holes in Almost Every AI-Generated App

1 hour ago
7 min read

Isometric Database Security Icon

What we find when we audit apps built with Lovable, Bolt, Replit, Base44 and Cursor — and how to check your own in an afternoon.


We started keeping a tally about a year ago, mostly out of curiosity. Someone would send us a repo built on one of the AI builders, we'd look through it, and the same handful of problems would be sitting there. Different tools, different founders, different industries, same six things.


That consistency is the interesting part. These aren't creative failures. They're not the result of one person prompting badly. They're what happens when code gets generated to satisfy a description of a feature, and nobody involved is thinking about a stranger with a browser console and twenty minutes.


Here they are, ordered by how often we find them and how much damage they do. Each one comes with a way to check your own app without needing a developer.



1. Every user can read every other user's data

This is the big one. It shows up in roughly nine out of ten apps we look at that use Supabase, Firebase or a similar hosted database.


The pattern is simple. Your app has a table of orders, or documents, or messages. The frontend fetches them filtered by the logged-in user's ID. On screen, everything looks correct: each person sees only their own records. But the filtering is happening in the request the browser sends, and the database was never told to enforce it. Row-level security is either disabled entirely, or a policy was written that grants access to any authenticated user rather than to the owning user.


So the security boundary is a number in a URL or a request body. Change it, and you get someone else's data. No hacking required, no tools beyond the network tab that ships with every browser.


The reason this happens is structural. When you build the app, you are logged in as yourself, testing with your own records. There is no moment in the building process where you experience the app as someone else. The AI has no reason to raise it, because the feature you asked for works.


Check it in five minutes: open your database dashboard and look at your tables. If RLS says "disabled" on any table holding user data, that's the hole. If it's enabled, read the actual policy text. A policy whose condition is true, or that only checks auth.role() = 'authenticated', is doing nothing useful. You want policies that compare the row's owner column against the current user's ID.



2. Your keys are in the page source

Every hosted backend gives you at least two keys: a public one, safe to ship to browsers and constrained by your security rules, and a secret one that bypasses all rules for server-side use.


In a lot of AI-built apps, the secret one ends up in the frontend. Sometimes because the generated code needed an operation the public key couldn't do and reached for the key that could. Sometimes because an environment variable got the wrong prefix, and frameworks like Next.js and Vite will happily inline anything prefixed for client use straight into the bundle you ship.


Same story with third-party keys. Payment provider secret keys, email service keys, OpenAI or Anthropic keys. An exposed model provider key in particular tends to be discovered by automated scrapers within days, and the first sign is a bill.

Once a key is in a deployed bundle, it is public. Removing it from the code later doesn't undo that. It has to be rotated.


Check it in two minutes: open your live site, view page source, and use Ctrl+F for service_role, sk_live, sk-, SECRET, and PRIVATE. Then do the same in the JavaScript files your page loads. Anything you find is compromised and needs rotating, not just deleting.



3. The admin panel is protected by a hidden button

Here's a pattern we see constantly. The app checks whether the current user is an admin, and if they aren't, it doesn't render the admin link. Job done, as far as the interface is concerned.


But hiding a link doesn't protect the page, and protecting the page doesn't protect the data behind it. If the route still loads when typed directly, or if the API endpoint it calls doesn't independently verify that the caller is an admin, then the entire admin surface is available to anyone who knows the URL. And URLs are guessable. /admin is not a secret.


The deeper version of this is role checks living entirely in frontend state. If your app decides what someone can do based on a role field it read into the browser, that value can be edited. Authorization has to be re-checked on the server for every request that matters, every time, regardless of what the interface showed.


Check it in five minutes: log in as a normal, non-admin user. Type your admin URL directly into the address bar. If the page renders, even partially, even with errors, you have a problem. Then check whether the data loads, which is the more serious question.



4. Anyone can upload anything, forever

File uploads are where AI builders are most likely to produce something that works perfectly and validates nothing.


The typical generated upload handler accepts the file, puts it in storage, and returns a URL. What it usually doesn't do: limit the file size, so someone can upload a 4GB file and run up your storage bill. Restrict the file type by actual content rather than by the extension in the filename, which anyone can change. Rename the file, so an uploaded name can't collide with or overwrite something else. Scan or sandbox what gets stored.


Require authentication on the upload endpoint at all, in a surprising number of cases.

Public storage buckets compound it. If the bucket is world-readable and filenames are predictable, every file any user has ever uploaded is enumerable by a stranger. Identity documents, invoices, private photos.


Check it in ten minutes: try uploading a large file, well past what your app would ever legitimately need. Try renaming a text file to .jpg and uploading it. Then take the URL of a file you uploaded while logged in, open it in a private browsing window, and see if it loads. If it does, your storage is public.



5. Nothing is rate limited

Rate limiting almost never appears unless you ask for it, because it isn't a feature — it's the absence of an abuse path, and absences don't get generated.


The consequences are spread across your app. Login endpoints with unlimited attempts can be brute-forced. Password reset and signup endpoints with no throttle become a way to send thousands of emails through your provider, which ends with your domain flagged for spam. Contact forms become spam relays. And if your app calls an AI model on the user's behalf, an unthrottled endpoint is a direct line from a stranger's script to your model provider bill.


That last one has become the most expensive version of this problem. We have seen a chatbot feature with no auth and no limits generate four figures of API charges over a weekend.


Check it: try your login form with a wrong password fifteen times in a row. If the sixteenth attempt behaves exactly like the first, there's no limiting in place. Do the same with any endpoint that sends an email or calls a model.



6. Your errors are telling strangers how it's built

The last one is quieter and rarely causes the breach by itself. It just makes every other hole easier to find.


Generated apps tend to ship with verbose error handling, because verbose errors are useful while you're building. In production, a stack trace returned to the browser tells an outsider your framework, your database, your table and column names, your file structure, and sometimes fragments of the query that failed. A failed login that says "no account with that email" rather than a generic failure message confirms which addresses are registered. Source maps left enabled hand over your original, unminified code.

None of this is dramatic on its own. It's reconnaissance. It turns guessing into knowing.


Check it: trigger an error on your live site deliberately. Submit a form with obviously invalid data, or request a record ID that doesn't exist. Read what comes back in the browser's network tab. If it names your database, your tables, or your file paths, that detail belongs in your logs, not in the response.



The pattern underneath all six

Look at them together and they share a shape. Every one is an assumption that the person using your app is using it the way the interface intends.


That assumption is baked into how these tools work. You describe a feature, the model builds the feature, you click through it as yourself and confirm it does what you said. At no point in that loop does anyone ask what happens when the request doesn't come from your interface at all, but from a script pointed at your endpoints.


It's worth being clear that none of this makes AI builders unsafe to use. Traditional development produces these same six flaws constantly, which is why they've had names and rankings in security literature for two decades. The difference is that a development team usually contains at least one person who has been burned before and knows to check. When the tool builds it and the founder ships it, there's often nobody in the loop who has ever seen what a breach looks like from the inside.


So the fix isn't to stop building this way. It's to run the checks above before real customers and real data arrive, because all six are considerably cheaper to close before launch than after.


Start with the first one. If you only do one thing after reading this, open your database dashboard and read your row-level security policies. That's where we find something almost every single time.


Comments


bottom of page