Can one of your users read another user's data? Here is how to find out
Change the id in a URL while signed in as a second account. If you can see the first account's data, you have the flaw that leaks the most records in apps of this kind. It is invisible in normal testing because you only ever test as yourself, and as yourself everything works.
Make a second account on your own app. Log in as that account, take a URL from the first account, something like /orders/1041, and paste it in.
If you can see the first account's order, stop reading and go fix that. Everything else on this blog is less urgent than what you just found.
This flaw has a formal name, broken object level authorisation, and it sits at the top of the OWASP API security list for a reason. In apps built quickly with AI tools it is both the most common serious finding and the one people are most surprised by, because nothing about it looks wrong from the inside.
Why is it so easy to miss?
Because you only ever test as yourself, and as yourself the app is correct. There is no moment in normal use where the problem announces itself.
You open your dashboard and see your orders. You click one and see its detail page. Every screen shows the right data, the app is doing exactly what it should, and there is no test you would naturally run that reveals the problem. The bug is not that the wrong data is shown to you. The bug is that the app never checked whether it should be showing it to anyone, and you happen to be the right person.
The second reason is how the code came to exist. "Show a user their orders" produces a query filtered by the signed-in user, because the request implied it. "Add a page showing order details" produces a query filtered by the order id, because that is what a detail page needs. Both are reasonable readings of the request. Only the first one has an owner check in it, and the second one is where your data leaks.
The three places it hides
Finding one instance is easy. Finding all of them means knowing where to look, and there are three.
The URL. The obvious one, and the one people check. /orders/1041 becomes /orders/1042, /invoice/88 becomes /invoice/89, /u/gourab becomes /u/someone-else. Sequential integers make this trivial to enumerate, because an attacker does not have to guess anything, they count.
The API call underneath the page. This is the one that gets missed, and it is worse than the first. Your page may redirect an unauthorised visitor to the login screen while the fetch it fires still answers with the data. Open developer tools, go to the Network tab, and watch what the page actually requests. Then make that request yourself with the other account's session, or with no session at all. Look at the response body, not at what the screen renders.
The request body. Reads get attention and writes usually do not. If your update endpoint takes an object containing a user_id or an owner_id and trusts it, a user can send somebody else's id and write into their records. On Supabase this is specifically what an insert policy's with check clause exists to prevent, and it is routinely omitted.
How do I test it properly in twenty minutes?
Six steps. You need two accounts, and a browser with developer tools open.
- Sign in as account A and click through every screen that shows data belonging to A. Write down every URL that contains an id, and every API path in the Network tab.
- Sign in as account B in a different browser or a private window, so both sessions stay alive.
- As B, request every URL on that list. Not just one. The route somebody added later is usually the one with no check.
- As B, call every API path on that list directly. Paste it into the address bar for a GET, or use curl. Read the response body rather than trusting the rendered page.
- Call the same paths with no session at all. Log out entirely, or use curl with no cookie. A surprising number of routes turn out never to have checked anything.
- Try one write. Take an update request A can make, change the id in the body to one of B's records, and send it as A. Then look in the database.
A pass means every attempt returns 401, 403 or 404 and nothing in the response body belongs to the other account. A fail is any real data coming back, whether or not the page rendered it.
How is it fixed?
The rule is short, and the difficulty is that it has to be applied everywhere rather than in the places you happen to remember. One missed route is the whole problem again.
Every route that reads or writes a record must confirm the signed-in user owns that record, on the server, before returning or changing anything. Not in the component, not in a redirect, not by hiding a button. The check belongs next to the query, because that is the only place it cannot be skipped by a client that chooses not to run your JavaScript.
If you are pasting this back into the tool that built the app, describe the whole shape rather than the one route you found:
Add server-side authorisation to every route that reads or writes user data. For each one, verify the signed-in user's id matches the owner of the requested record before returning or modifying it, and reject requests where the body contains an owner id that does not match the session. Return 404 rather than 403 for records the user does not own, so the response does not confirm the record exists. List every route you changed and every route you found that already had a check.
Two details in that prompt earn their place. Returning 404 rather than 403 matters because 403 tells an attacker the record exists, which turns a blocked request into a working enumeration oracle. And rejecting a mismatched owner id in the body is the write-side half that people forget after fixing all the reads.
If your data is in Supabase, the durable version of this fix is a row-level security policy, because it applies at the database rather than at each route, and a route added next month inherits it automatically. That is worth doing properly, including the grant revocation that most guides leave out.
After you have fixed the ones you found
The uncomfortable part of this flaw is that the test above only covers routes you knew to write down. Your click-through is a list of the app you remember building.
The route that leaks is usually the one added three weeks after launch for a single customer, or the export endpoint nobody uses, or the second admin page that exists because the first one was awkward. Those do not appear in your click-through, because you do not click through them.
That is the case for having something read every route rather than the ones you remember. Sentrint does exactly that: it reads your whole repository, finds the handlers that touch user records without an ownership check, and gives you a fix written for the tool that built the app. A code scan, not a pentest, and the first one is free with no card.
Do the two-account test tonight either way. It takes twenty minutes and it is the single highest-value twenty minutes described anywhere on this blog.