Users can see other users' data by changing an ID: how to fix it
By Obscura Security · Updated · Next.js, Supabase, Prisma, Lovable, Bolt, Cursor
Short answer
If swapping an ID in the URL or API request returns another user's data, your server isn't checking who owns it. This is called IDOR or broken object level authorisation. Fix it by looking up every record with both the record's ID and the logged-in user's ID, on the server, for every read, update and delete.
You’re logged in as yourself, you open /invoices/1042, and everything works. You change the URL to /invoices/1043 and see someone else’s invoice. That’s the whole bug.
Why this happens
When an app is built quickly, the code that loads a record often does the simplest thing: take the ID from the request and fetch that row. It checks that you’re logged in, because that’s what the login system gives you for free. It doesn’t check that the record is yours.
This applies to every place an ID appears: the URL, a query string, a JSON body, a file name. It also applies to every action on it. Reading is the most visible one, but update and delete are often left open too, and those are worse.
Frontend code can’t fix this. A page that only shows your own invoices in a list still loads anyone’s invoice if the API is asked for it.
How to check your own app
Use two test accounts that you control, Account A and Account B. Don’t use real customers’ accounts.
- Log in as Account B. Create a record the way a normal user would (an invoice, a project, a profile edit). Note its ID.
- Log in as Account A in a different browser or a private window.
- Open the browser developer tools, go to the Network tab, and use the app normally. Look at requests that contain an ID, such as
GET /api/invoices/1042. - Change the ID to one belonging to Account B and send it again. In the Network tab you can right-click a request and copy it as
curl, then edit the ID:
curl "https://your-app.example/api/invoices/ID_FROM_ACCOUNT_B" \
-H "Cookie: YOUR_ACCOUNT_A_SESSION_COOKIE"
- If you get Account B’s data back, you have the bug. Repeat with the other methods:
PUT,PATCHandDELETEon the same URL, and with IDs placed in a request body ({"userId": "..."}).
Also test a logged-out request. An endpoint that returns data with no cookie at all is a separate problem and often the same cause.
The fix
The rule is to never trust an ID from the request alone. Always combine it with the identity of the logged-in user, taken from the session on the server.
In a Next.js route handler
This example uses Prisma, but the idea is the same with any database. getCurrentUser() stands in for whatever your auth library (Auth.js, Clerk, Supabase Auth and so on) gives you on the server.
// app/api/invoices/[id]/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/auth'
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Unauthorised' }, { status: 401 })
const { id } = await params
// The record must match BOTH the ID and the owner.
const invoice = await prisma.invoice.findFirst({
where: { id, userId: user.id },
})
if (!invoice) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(invoice)
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'Unauthorised' }, { status: 401 })
const { id } = await params
const result = await prisma.invoice.deleteMany({
where: { id, userId: user.id },
})
if (result.count === 0) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return new NextResponse(null, { status: 204 })
}
Note that userId comes from the session. When creating records, set it the same way. Never read userId from the request body.
In Supabase, with RLS
If your frontend queries Supabase directly, the check belongs in a Row Level Security policy on the table, because the browser can send any query it likes:
alter table public.invoices enable row level security;
create policy "Owners read their invoices"
on public.invoices for select
to authenticated
using ( (select auth.uid()) = user_id );
create policy "Owners update their invoices"
on public.invoices for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
create policy "Owners delete their invoices"
on public.invoices for delete
to authenticated
using ( (select auth.uid()) = user_id );
Add a matching insert policy with with check as well. The full walk-through is in Supabase RLS disabled.
Fix every route, not one
An app usually has dozens of routes that take an ID. Fix the one you found, then search your code for every place that reads an ID from params, searchParams or the request body and loads or changes a record. Each one needs the same ownership check. If your app has teams, the check is “does this user belong to this record’s team”, not just “is this record’s owner this user”.
Test again
Repeat the two-account test for every route you changed, including PUT, PATCH and DELETE.
Mistakes to avoid
- Checking only on the page, not the API. The API is the thing that has to refuse.
- Fetching first, then checking. Loading the record and comparing
record.userIdafterwards works, but it’s easy to forget the comparison. Putting the owner in the query itself is harder to get wrong. - Trusting
userIdin the request body or a hidden form field. The user controls it. - Only protecting GET. Update and delete need the same check.
- Relying on hard-to-guess IDs. They slow down guessing, not someone who has one ID from a link or an email.
Common questions
- What does IDOR mean?
- Insecure Direct Object Reference. It means your app uses an ID from the request (like an invoice number) to fetch a record without checking that the logged-in user is allowed to see that record. OWASP calls the same problem Broken Object Level Authorization (BOLA), and it sits at the top of its API security list.
- Are random UUIDs enough to stop this?
- No. UUIDs are hard to guess, which helps a little, but IDs leak through emails, shared links, logs and API responses. The check on the server is what makes it safe.
- Should I return 403 or 404 when the record belongs to someone else?
- Prefer 404. A 403 confirms to an attacker that the record exists. Looking up by ID and owner together gives you a 404 naturally.
- Does hiding the link in the interface fix it?
- No. The API is what matters, not the buttons. Anyone can send the same request by hand, so the server has to refuse it.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].