The Security Problem With AI-Generated Apps
A product manager at a fifty-person company spends a Saturday afternoon describing an internal tool to an AI builder. By Sunday she has a working customer health dashboard with a login screen, a table of accounts, revenue numbers and a churn risk column. It looks real because it is real. It queries a real database, renders real customer names, and has a URL anyone can visit.
On Monday she shares the link in Slack. Nobody asks the question that matters, which is the whole of ai generated app security in one sentence: what stops a person who is not supposed to see that data from seeing it anyway?
The answer, in a large number of these projects, is nothing. Not because the AI wrote bad code. The code usually runs. The problem is that the security boundary in a generated app lives in a place the generator was never really asked to think about, and the person who prompted it has no way to tell, by looking at the app, whether that boundary exists.
This article is about where those boundaries actually live, what the public evidence shows about how often they go missing, and why the risk profile changes fundamentally depending on whether the AI generated code or generated a definition.
The failure is not bad code, it is a missing layer
Every app that shows data to more than one person has to answer two separate questions.
The first is authentication: who is this? The second is authorization: what is this particular person allowed to see and do?
AI builders are very good at the first one. Authentication is a well-defined, heavily documented, extremely common pattern. Ask for a login screen and you get a login screen, usually wired to a real identity provider, usually working correctly on the first try.
Authorization is different. Authorization is not a feature you can see. It is a property of every single query the app makes, forever. There is no screen that shows it. There is no visual state where it looks broken. An app with no authorization at all and an app with perfect authorization look identical when you, the owner, are logged in and looking at your own data.
That asymmetry is the entire problem. The part the AI is good at is visible. The part it is bad at is invisible, and it is invisible specifically to the person best positioned to catch it.
Here is the concrete shape of it. Modern AI builders commonly generate apps on top of a hosted Postgres backend, most often Supabase, where the browser talks to the database more or less directly using a public anonymous key. That architecture is fine, and it is used in production by serious companies. It works because of one thing: row-level security policies on every table, which the database enforces regardless of what the client asks for.
Turn RLS off, or never turn it on, and the public key becomes a full read of the table. Not a read of your row. The table. Anyone who opens developer tools, finds the endpoint and the key in the JavaScript bundle, and issues the same request without your session gets the rest of the rows.
The AI did not write a vulnerability. It wrote a query. The vulnerability is the absence of a policy the AI was never asked to create, in a database console the prompter may never have opened.
What the public evidence actually shows
There is real, published research on this now, and it is worth being precise about what it says rather than reaching for a scary number.
Security researchers have run automated scans across large samples of publicly reachable apps built with AI app generators, Lovable being the most-scanned because it publishes projects on predictable public domains. The consistent published finding across these efforts is that a meaningful share of scanned projects exposed database tables without row-level security enabled, meaning personal data, contact records and in some cases credentials were readable by an unauthenticated request. Lovable publicly acknowledged the class of issue and shipped a security scanner into its product in response, which is the correct response and worth crediting.
Two things follow from that, and both matter.
The first is that the researchers were only scanning what was reachable and detectable from outside. Internal tools built by a company for its own staff, which is the majority of what people build with these tools, were not in the sample. There is no reason to think they are safer. There is good reason to think they are less scrutinized, because nobody publishes them and nobody scans them.
The second is that this is not one vendor's bug. It is a pattern of the architecture. Any builder that generates a client-side app talking to a database with a public key inherits the same failure mode. The vendor that got scanned is not necessarily the worst one. It is the one with the most discoverable URLs.
The other widely reported incident worth knowing is the authentication bypass found in Base44 in mid-2025. Researchers found that registration and email verification endpoints for private applications could be reached without authorization using an application identifier that was itself exposed in the app's own public assets. In effect, an attacker could register themselves a legitimate account on a private internal app and then walk in the front door with valid credentials. Wiz published the research, the vendor patched it quickly, and there was no evidence of exploitation in the wild.
That one is a different animal from the RLS problem, and the difference is instructive. The RLS gap is a mistake in what got generated. The Base44 issue was a flaw in the platform itself, above every app it hosted. No amount of careful prompting by a user would have prevented it. When you build on a hosted builder, you inherit that platform's security posture whether you review your own app or not.
So there are three distinct risk surfaces, and people routinely conflate them:
- The app's own logic and data access rules, which the generator produces and you are responsible for.
- The platform hosting and executing the app, which you inherit.
- The credentials and connections the app holds to your other systems, which are usually the most valuable thing in the building.
Why generated code carries risk that generated definitions do not
This is the structural point, and it is not a marketing distinction. It is a difference in what the surface area is made of.
When an AI generates code, the output is arbitrary. It can contain any query, any endpoint, any string concatenation, any dependency, any secret in any variable. The set of things that could go wrong is the set of things a program can do, which is unbounded. Review is your only defense, and review means someone reading source files with the specific intent of finding a missing check. That is a skilled, slow, unrewarding job, and the people generating these apps are frequently not the people who can do it.
When an AI generates a declarative definition instead, the output is a structured description of components and data bindings that a fixed runtime renders. The runtime is written once, reviewed once, and hardened once. The AI cannot introduce a new query path, because it does not write query paths. It picks from a vocabulary.
Skopx works this second way. A person describes an internal tool in chat and Skopx produces a definition made of known components: metric, table, chart, list, text, filter, action button, section, divider, stat grid, kanban, timeline, progress, callout, image. Skopx's own runtime renders it. Database reads are read-only SQL against a connected database. Actions run through connected tools, and every action button is an explicit click with a confirmation, never something that fires because a page loaded. Apps are private to you or shared with your organization, and data is isolated per organization at the row level. Whether that specific product fits your case is a separate question, but the architectural property is the point: the blast radius of a bad generation is bounded by the vocabulary.
The honest limit on that approach, and it is a real one, is capability. A definition-based app can only do what its component vocabulary allows. Skopx apps read from connected systems and take actions through connected tools. They do not store their own records, and there is no form component that creates new data. That means they work as consoles, dashboards, review queues and admin views over data that already lives in a database or a SaaS tool. If what you need is an app that IS the system of record, an invoicing system that issues invoices, an applicant tracker that owns the applicant, a delivery system that creates deliveries, that is not what this is, and no amount of framing changes it. Generated code can build that. It just hands you the security problem along with it.
The trade is real in both directions and you should make it deliberately. There is a longer treatment of the mechanics in declarative apps vs generated code.
| Risk | Generated code app | Declarative app on a fixed runtime |
|---|---|---|
| Missing row-level security | Very common. Policies live in a separate database console the prompter may never open, and the app looks correct without them | Not applicable in the same way. The runtime holds the connection and enforces isolation, but a broad read-only credential still over-exposes, so the grant is the control |
| SQL injection or unsafe query construction | Possible whenever the AI concatenates input into a query. Depends entirely on what it happened to write that day | Structurally excluded. The AI selects components and bindings, it does not author query strings |
| Secrets in the client bundle | A recurring finding. API keys land in front-end code because that is where the fetch call is | Credentials sit in the platform connection, not in a shipped bundle the browser can be asked to hand over |
| Destructive action fired without intent | Real. Generated buttons can call a delete or a send with no confirmation step, and an agent loop can trigger one | Bounded. Actions are explicit clicks with confirmation, and the tool grant limits what is reachable |
| Vulnerable or hallucinated dependencies | Inherited from whatever packages the generation pulled in, including packages that do not exist and could be squatted | No per-app dependency tree. One runtime is patched centrally |
| Platform-level flaw above your app | Applies to hosted builders. You inherit the vendor's posture | Applies equally. This risk does not go away, it just has one owner instead of one per app |
| Capability ceiling | None. It can build anything, including a real system of record | Real and hard. No stored records, no create-data forms, so system-of-record apps are out of scope |
Read the last row against the rest. Neither column wins outright. The code column buys unlimited capability and pays in unbounded review surface. The definition column buys a bounded surface and pays in things it simply cannot build.
The permissions question nobody asks until later
Even with a perfectly secured app, there is a second layer people skip: who inside your company should see this.
The default in a hurry is "everyone at the company," because that is one setting and it makes the sharing problem go away. It is also how a support console ends up showing every customer's billing history to the intern who joined last week, and how a revenue dashboard built for the leadership team ends up circulating in a channel with contractors in it.
A few habits that hold up in practice:
- Decide the audience before you build, not after. The audience determines the query, and a query written for the wrong audience is hard to narrow later without breaking the layout.
- Prefer the narrow database grant. If the app only needs six columns from three tables, give it a role that can read six columns from three tables. The most reliable way to prevent an app from exposing a column is for the app's credential to be unable to see it.
- Treat action buttons as a separate permission from viewing. Reading a list of refunds and issuing a refund are different privileges, and they should not travel together just because they appear on the same screen.
- Log who ran what. Not for compliance theater. For the Tuesday when someone asks why a record changed and the honest answer needs to be a name and a timestamp.
- Re-check when people leave. Access granted to a shared internal tool is exactly the kind of thing that outlives an offboarding checklist.
There is a fuller walkthrough of how to structure this in internal tools permissions.
The checks that actually find problems
If you have already built something with an AI builder and you want to know whether you have a problem, these are the checks that find real issues, roughly in order of how often they pay off.
Open the app in a private window with no session. Not logged out. A fresh browser profile that has never seen the app. If any data renders, you have your answer immediately.
Read the network tab. Find the request the app makes to fetch data. Copy it as a curl command, strip the authorization header, and run it. If it returns rows, the database is not enforcing anything and the login screen is decoration.
Search the shipped JavaScript for keys. Open the bundle and search for common prefixes and the word "key". Anything that looks like a service role key, a private API key or a webhook secret is public the moment the page loads. Rotate it, do not just delete the line.
Query the database's own metadata for RLS status. In Postgres you can list tables and check whether row-level security is enabled, and whether any policies exist on each. A table with RLS enabled and zero policies is a different bug from RLS disabled, and both show up.
Try someone else's identifier. If a page loads a record from a URL parameter or a stored identifier, change it to another plausible value. If you get back a record you should not own, that is a direct object reference problem and it does not require any tooling to exploit.
Click every destructive button and watch what fires. Confirm that a confirmation exists, that cancelling actually cancels, and that nothing sends or deletes on page load.
Check what the credential can reach, not what the app shows. The app might display three columns. The credential might be able to read the whole schema. The second number is the one that matters in an incident.
That list is deliberately mechanical. None of it requires a security background, and it will find the large majority of what the published scans found.
Prototype and production are different animals
A lot of the damage here comes from a single unmarked transition: the moment a demo starts being used for real work.
Nothing announces it. There is no deploy step, no review gate, no change in the URL. Someone builds a thing to show an idea, someone else finds it useful, and within a fortnight three people rely on it daily and one of them has started making decisions from it.
The security posture that was completely fine for a prototype with three fake rows is now completely wrong, and nobody chose that. It just happened.
The practical fix is to make the transition explicit. Fake data until it is reviewed. A named owner before it gets shared beyond the person who built it. A short list of what it reads and what it can do, written down somewhere a colleague could find. This is not process for its own sake, it is the minimum that lets a second person understand the thing without reading its source. The failure modes of that transition are covered in more depth in ai prototype to production.
The related discipline, once you have more than a handful of these, is knowing what exists at all. A company that has built forty internal tools by prompting has forty data access paths and usually no list of them. Building that list is unglamorous and it is the single highest-value thing most teams can do here. See ai app governance for how to keep an inventory that stays current.
What good looks like, concretely
Picture a twelve-person operations team that decided to be careful about this. Their setup is not sophisticated. It is just deliberate.
Every internal app reads through a database role that is read-only and scoped to specific tables. Nobody hands an app an admin connection because it was faster to set up.
Every app has a named owner. Not a team. A person, whose name is on it, who gets asked when it breaks or when someone requests access.
Sharing is a decision with two options: private to me, or shared with the organization. There is no accidental public link, because public was never an option in the tooling they chose.
Actions are separated from views by default. The dashboard that shows failed payments does not also retry them unless someone specifically decided that the people who can see the list should also be able to change the outcome.
And once a quarter someone spends an hour opening each app in a clean browser and reading its network tab. It takes an hour. It has caught things.
None of that is a product. It is a set of habits, and it works on any platform, including one you built entirely yourself. If you want a sense of what these apps look like when they are done well, apps on connected data walks through the read-path design, and why AI dashboards look wrong covers the related problem of layouts built before anyone looked at the actual data.
Where Skopx fits, and where it does not
Skopx builds apps as definitions rather than code, which removes the specific failure modes in the table above that come from arbitrary generated queries and client-side secrets. Before designing a layout, it runs the query and reads a profile of the real data, column types, value ranges, text lengths, so the app fits what actually came back rather than what the model guessed. Data isolation is per organization at the row level, transport is TLS 1.3, storage is AES-256, SOC 2 controls are in place, and customer data never trains models. Team is $16 per seat per month with 2.3 million AI tokens included per seat, Solo is $5 per month with your own API key, and there is zero markup on AI. The details are on the pricing page.
What it does not do is remove your responsibility for the grant. If you connect a database credential that can read everything, an app can surface everything it is asked for. The narrow grant is still yours to configure, and it is still the strongest control you have.
And it does not build systems of record. No stored records, no create-data forms. Consoles, dashboards, review queues and admin views over data that already lives somewhere else. If your requirement is an app that owns the data it displays, you need generated code or a purpose-built product, and you should go in knowing you are taking on the review burden that comes with it.
FAQ
Is AI-generated app security actually worse than what a junior developer would write?
In one specific way, yes, and it is not about code quality. A junior developer who forgets row-level security still has a pull request, a reviewer and a deploy pipeline between them and production. A prompted app frequently has none of those, because the person who built it does not think of themselves as shipping software. The generated code is often comparable in quality. The process around it is where the gap opens.
If I turn on row-level security, am I safe?
You are past the most common failure, not all of them. RLS enabled with no policies on a table behaves differently from RLS with correct policies, and it is easy to end up with the wrong one. Beyond that, you still have secrets in the client bundle, direct object reference problems where changing an identifier in a URL returns someone else's record, over-broad credentials, and destructive actions with no confirmation. Run the full check list rather than treating RLS as the finish line.
Does using a declarative platform mean I can skip security review entirely?
No, and anyone telling you otherwise is selling. A definition-based runtime removes whole categories of risk: no arbitrary query authoring, no per-app dependency tree, no secrets shipped in a browser bundle. It does not decide who at your company should see a revenue dashboard, and it does not narrow a database credential you granted too broadly. The review that remains is shorter and more about access decisions than about code, but it does not go to zero.
What is the single fastest check I can run right now?
Open your app in a browser profile that has never authenticated to it. If you can see data, stop and fix that before anything else. The second-fastest is copying the data request out of the network tab, removing the auth header, and replaying it. Between them those two take about five minutes and find the majority of what published scans have found.
How should I think about apps that connect to Slack, Stripe or a CRM?
Shift your attention from the app to the connection. An app reading a database can expose data. An app holding a write-capable connection to Stripe or a CRM can change the world. Grant the narrowest scope the app genuinely needs, keep every action behind an explicit click with confirmation, and keep a log of who triggered what. The connection is almost always more valuable to an attacker than the dashboard on top of it.
We already have a dozen of these. Where do I start?
Inventory first, fixes second. List every AI-built app, who owns it, what it reads and what it can change. Most teams discover in that exercise that two or three apps account for nearly all the exposure, usually the ones holding the broadest credentials. Fix those, retire whatever nobody has opened in a month, and then work through the rest at a sane pace.
The short version
The security problem with AI-generated apps is not that AI writes insecure code. It is that the security-relevant part of an application is invisible in the running app, so the feedback loop that makes prompting work so well for everything else, look at it, see if it is right, is exactly the loop that fails here.
Published scans of AI-built projects found missing row-level security at real scale. A platform-level auth bypass at Base44 showed that even a carefully built app inherits its host's flaws. Both point in the same direction: the surface you cannot see is the one that hurts you.
Reducing that surface is the whole strategy. Narrow credentials over broad ones. Explicit confirmed actions over silent ones. A bounded component vocabulary over arbitrary generated code, when the capability trade is acceptable. And an hour a quarter in a clean browser window, which is still the highest-yield security work most teams are not doing.
Skopx Team
The Skopx engineering and product team