The email you never want to get goes something like this. A user writes in to say they opened a link in your app, changed a number in the address bar out of curiosity, and ended up looking at a stranger’s order, with the name, the address and the list of what they bought all sitting there in front of them. It worked, and now they’re wondering what else they can reach.
This is the single most common serious flaw I find in apps built with tools like Lovable, Bolt and Replit. It has a dry name, broken access control, but the idea underneath is simple. The app is very good at showing you your own data. Nobody built the guard that stops you reaching someone else’s.
Here is why it happens so often. When you describe a feature to an AI, you describe the normal path. “A logged-in user can see their orders.” The AI builds that path well. You log in, you see your orders, everything looks correct. What you didn’t ask for, and what the AI didn’t add, is the check that runs when someone asks for an order that’s not theirs. The screen never offers you that order, so the gap is invisible from the inside. It’s only visible if you go looking for it, which is exactly what I do.
1. The interface hides things. The server does not re-check
The idea that matters most here is a simple one: hiding a button isn’t the same as preventing the action behind it.
When your app decides what to show you, that decision usually happens in the browser. If you’re not an admin, the “Delete user” button isn’t drawn on the page. The admin dashboard link doesn’t appear in your menu. From where you’re sitting, those features don’t exist.
But the browser isn’t a locked room. Every button, when clicked, sends a request to your server. Anyone can open their browser’s developer tools, watch those requests go by, and send the same request again by hand, with any values they like. The hidden button sent a request to /api/users/55/delete. There’s nothing stopping a curious user from typing that request themselves and changing 55 to any number they fancy.
So what matters is what the server does when the request arrives: does it check who is asking before it acts, regardless of what the interface chose to show? If it does what it’s told without asking, all the hiding in the world makes no difference.
To check it: log in as an ordinary user. Open the browser developer tools, go to the network tab, and click around your app while watching the requests. Then try sending one of those requests again with a different ID in it. If the app answers with someone else’s data, the server is not re-checking.
2. Changing an ID in the URL
The clearest example lives right in the address bar. Say you’re viewing your order and the URL reads:
/orders/1041
Change it to /orders/1042 and press enter. If you now see a different customer’s order, you have found broken access control in about four seconds. The server took the ID from the URL, looked it up, and handed it back without ever asking “wait, does this order belong to the person requesting it?”
This has a name in the trade: IDOR, an insecure direct object reference. In plain terms, the app points directly at a thing by its ID and trusts whoever holds the ID. It gets worse when the IDs are sequential numbers, because then they’re trivial to guess. If your order is 1041, there’s almost certainly an 1042 and an 1040, and reaching them takes counting rather than any cleverness.
Randomised IDs make guessing harder, but they’re not the fix. Even a long random ID can leak, get shared, or turn up in a browser history. The fix is the server checking ownership on every request, every time.
To check it: anywhere your app puts an ID in a URL - orders, invoices, profiles, documents, uploaded files - change the ID by hand and try to reach a record you know is not yours. Try one above and one below your own. If it loads, it loads for everyone.
3. Supabase and the row that anyone can read
A lot of AI-built apps store their data in Supabase, so this one deserves its own section.
Supabase gives your app a key called the anon key. That key ships to every single browser that loads your site. It has to, because the browser uses it to talk to the database. This is normal and fine, as long as one thing is switched on.
That thing is row-level security, usually shortened to RLS. Think of it as a set of rules on each table that say which rows a given signed-in user is allowed to touch. “A user can read a row only if the row’s user_id matches their own.” With those rules in place, the anon key can only ever reach the right rows.
With RLS switched off, there are no such rules, and the anon key sitting in every visitor’s browser can read whole tables, every row of them. The part that catches people out is that your app looks completely normal with RLS off. The interface only ever requests your own rows, so you only ever see your own rows, and nothing looks wrong. You can build, launch and run a fully working app without ever enabling RLS, and the door stays wide open the entire time.
To check it: in your Supabase dashboard, open the table editor and look at whether RLS is enabled on each table that holds user data. If it says disabled, or if there are no policies listed, treat that table as public and assume anyone can read it. This is usually the first thing I look at, and it is often the biggest single win.
4. When you serve more than one company
If your app is used by separate companies or teams, each seeing their own workspace, you have what’s called a multi-tenant app. This raises the stakes on everything above.
Now the worst case isn’t one person seeing another person’s order. It’s one company reaching another company’s entire account. Their customer list. Their revenue. Their private notes. For a business selling to other businesses, that’s the kind of leak that ends the business.
Multi-tenant separation needs the same discipline as everything else, applied at the company level. Every request has to prove two things at once: that the record belongs to the user, and that it belongs to the user’s company. It’s easy to get the user check right and miss the company one, because during testing you’re usually the only company in the system, so nothing ever crosses a boundary you can see.
To check it: set up two separate accounts in two different companies or teams, ideally with a colleague. Log in as one and try to reach the other’s records by swapping IDs in URLs and replaying requests. Nothing from company A should ever surface for company B.
5. The API behind the screen
Everything your app shows on screen is fed by requests to your server or database behind the scenes. Those requests are a door of their own, and they answer to anyone who knows how to knock, whether or not they came through the interface you built.
A person can call your API directly, without ever loading your app. They can hit an endpoint your screens use, or an admin path that no ordinary menu links to, like /admin or /api/admin/export. If those paths do the work without checking who is asking, then the fact that your menu never shows them counts for nothing.
To check it: as an ordinary logged-in user, type admin-looking paths straight into the address bar and see what loads. Replay the API calls you spotted in the network tab, changing IDs and values. Try to delete or edit something you do not own. If any of it works for you, it works for anyone who thinks to try.
Where to start
If you do two things this week, do these.
Check RLS first. Open your Supabase dashboard and confirm row-level security is enabled, with policies, on every table holding user data. This is the fastest way to find the widest hole.
Then swap some IDs. Log in as an ordinary user and spend ten minutes trying to reach data that is not yours - change IDs in URLs, replay requests from the network tab, open admin paths directly. Anything you can reach, so can your users.
This topic sits inside the bigger picture of how to keep your vibe-coded app secure, and access control is one line on the full launch checklist. Both are worth a read once you have caught your breath.
If poking at your own app makes you nervous, that’s a reasonable feeling and a good reason to bring in someone who does it for a living. Testing whether one user can reach another’s data is careful, methodical work, and it’s the kind of thing I check on every app I look at. If you would like a second pair of eyes before you launch, that’s what I’m here for.