Someone Could Change the User ID in My API Request and See Other People's Data
6 min read
What stops a user from changing the ID in your API call to read someone else's
data? Only a server-side authorisation check. If your backend takes the user
ID or record ID from the request and trusts it, the answer is nothing: a
signed-in user can edit /api/orders/1043 to /api/orders/1042, or swap
userId=me for someone else's, and read the account back. This flaw is called
IDOR (Insecure Direct Object Reference), a form of broken access control, and
it is one of the most common holes in vibe-coded apps.
Why this happens
The request from the browser is fully under the user's control. The URL, the query string, the JSON body, the headers: all of it can be changed with browser dev tools in seconds. Your app's UI only ever shows a user their own data, so in testing everything looks fine. But the UI is not a fence. The API behind it is the fence, and if that API reads an ID from the request and looks it up without asking "does the logged-in user actually own this?", the fence has a gap.
An AI agent asked to "make an endpoint that returns an order by ID" writes exactly that: it takes the ID, fetches the row, returns it. You never asked it to check ownership, so it didn't. The code works in the demo because you only ever click your own orders. The gap only shows when someone types a different number.
How to check
You can find this yourself in a few minutes, no tools beyond your browser.
- Sign in as a normal user and open any page that loads a specific record: an order, a profile, a document, an invoice.
- Open the browser network tab and watch the API call. Note the ID in the URL
or body, for example
GET /api/invoices/5001. - Change the ID to a neighbouring number (
5000,4999) and replay the request. In most browsers you can right-click the request and choose "Copy as cURL", paste it into a terminal, edit the ID, and run it. - If you get someone else's data back, you have IDOR. If you get
403 Forbiddenor404 Not Found, that route is doing the check.
Repeat for every route that takes an ID: read, update, and delete. Delete is the worst case, because a swapped ID there lets a stranger destroy another user's records.
The fix
- Never trust an identity sent from the browser. The logged-in user comes
from the verified session or token on the server (
auth.uid(), the decoded JWT,req.session.userId), never from auserIdfield in the request body. - Scope every query by the owner. Instead of "fetch invoice 5001", write
"fetch invoice 5001 where
user_id = <session user>". If it returns nothing, return 404. The record ID from the request is allowed; the ownership filter is what makes it safe. - On Supabase, let Row Level Security do it. A policy like
user_id = auth.uid()enforces ownership at the database, so even a direct API call cannot cross accounts. See can anyone read my Supabase database from the browser. - Cover write and delete routes too, not just reads. Each one needs the same ownership check before it acts.
- Re-run the ID-swap test from the check above. A fixed route returns 403 or 404 for someone else's ID, every time.
The trap to avoid
Do not rely on IDs being hard to guess. Swapping to UUIDs or random strings feels safer, but it only hides the door, it does not lock it. IDs leak through shared links, referrer headers, exports, and old emails, and once one leaks the route is wide open again. The fix is the ownership check on the server, not an unguessable number. Hiding the ID in the UI does nothing either, since the real request still carries it.
Where this fits
Broken access control is invisible in every demo and obvious to the first curious user, which makes it exactly the kind of gap to close before beta. The free Readiness Report walks your routes and flags the ones that trust a client-supplied ID, and the Finishing Pass hands you the ownership checks to add, route by route. If you are on Supabase, pair this with getting your access rules right, since the same anon key everyone can see is what an attacker would use to hit those routes directly.