Skip to main content
NinjaNext
AI and automation

Why Your AI-Built App Lets One User Read Another User's Data

Security scanners check your dependencies. They do not check whether a logged-in user can read someone else's records. That gap is the most common serious flaw in AI-built apps, and you can test it yourself in two minutes.

By NinjaNext TechnologiesPublished 4 min read

Key takeaways

  • Logging in proves who someone is. It does not decide which rows they are allowed to read.
  • A dependency scan cannot find this: nothing is out of date, the app is simply doing what it was told.
  • Test it by hand in two minutes, by requesting another account's record ID while logged in as yourself.
  • The fix belongs in the database, through row-level security, not in the screens that hide the button.

There is a particular kind of flaw that AI builders produce often, that automated tooling almost never catches, and that is genuinely serious once real people use your app: a logged-in user can read, and sometimes change, another user's records.

It is worth being precise about why this happens, because the usual advice does not help. Your dependencies are fine. Nothing is out of date. The app is doing exactly what it was built to do. The problem is a question nobody asked while it was being built.

Logging in is not the same as being allowed

Two different things get confused here. Authentication answers who is this person. Authorisation answers which records may this person touch. AI builders handle the first one well: ask for sign-up and login and you will get a working, sensible implementation, often with a dedicated auth provider wired in correctly.

The second question usually never comes up. You asked for a dashboard that shows the user's invoices, and the generated code asks the database for the invoices, filtering by the current user's ID in the query. On your screen it looks perfect. You only ever see your own invoices, because the query asks for your own invoices.

But that filter lives in code the browser is running, and the browser belongs to the user. Change the ID in the request and the database, which was never told to care, hands over someone else's row.

Why your security scan came back clean

Dependency scanners compare the packages you use against lists of known vulnerabilities. That is useful work, and you should keep doing it. But this flaw is not in a package. It is in your application's own logic, and it is unique to your data model, so no list can contain it.

Asking an AI agent to review its own codebase has the same limitation, for a subtler reason. It is reasoning about what the code appears to intend, and the code intends to show the user their own invoices. Intent is not the test. The test is what the server actually returns when someone asks for a row they should not have.

Test it yourself in two minutes

You do not need tooling for this, and you do not need to be the developer. Create two accounts in your own app, then:

  1. Sign in as the second account and create something that belongs to it: an order, a note, a booking. Anything with its own page.
  2. Copy the record's ID from the address bar.
  3. Sign out, sign back in as the first account, and open the same URL with that ID in it.
  4. If the record appears, the data is protected only by your screens. If you get an error or an empty page, check one more thing: open the browser's network tab and look at what the server returned, not just what was displayed.

That last step matters more than it sounds. A common outcome is that the server sends the full record and the page simply declines to draw it. The data left the building; the user just has to look slightly harder to find it.

The fix belongs in the database

If your app talks to a hosted database directly from the browser, as many Lovable and Bolt apps do with Supabase, then the database is your actual security boundary. Everything in front of it is a suggestion.

Row-level security is how that boundary gets enforced. You enable it per table, then write policies describing which rows each user may read, insert, update and delete. Once enabled, a table denies everything by default and only the policies open it up, which is the right way round: a table you forget to write a policy for returns nothing, rather than returning everything.

Two details catch people out. Enabling row-level security on a table without adding policies will appear to break your app, because it is now correctly refusing requests that used to succeed, and that is a sign it is working. And a service-role key bypasses every policy by design, so if one has reached the browser, your policies are decoration. That one is worth checking at the same time, along with the rest of the seven problems we look for before an AI-built app meets real users.

If the browser does not talk to the database directly, the same principle applies one layer up: every request arrives at a server endpoint that checks the signed-in user against the record being asked for, on every call, with no exceptions for endpoints that seem harmless. In the platforms we build for clients, roles and permissions are enforced on the server and every change is written to an audit log, so who changed what is never a matter of opinion.

What to do about it

Run the two-minute test above. If the record comes back, do not panic and do not rebuild: this is a contained problem with a known fix, and almost everything you have built is worth keeping. Enable row-level security on the tables holding user data, write the policies, rotate any key that should not have been public, and re-run the test for each table.

If you would rather have someone check it properly, send us the app. We review existing code before changing any of it, and tell you what to keep, fix or rebuild, with a fixed quote for the work.

Let's build it

Want help putting this into practice?

Tell us what you are building and where you are stuck. We will reply with questions, a suggested approach and next steps.