IDOR: reading or changing someone else's data
The request carries an id, and the server fetches that id without checking that it belongs to the signed-in user.
Last updated
Shows up in: Next.js, Supabase, Firebase
What it is
Insecure direct object reference means the name of the object is the only thing standing between two users. A note URL like /api/notes/1842 loads note 1842. If the query is where id = 1842 and the session is only used to prove that someone is logged in, any account can change the number.
This shows up as soon as an agent scaffolds CRUD. The happy path uses the id from the link. The ownership check is the part that gets skipped.
Hiding the id, using a UUID, or hoping people will not guess it is not the fix. UUIDs are harder to guess and still leak through logs, emails, and the network panel of a shared computer. The server has to compare the row's owner to the session.
How to check in 5 minutes
Sign in as two test users. Copy a URL or an id from user A. Call it as user B. If user B sees the row, you have an IDOR.
Search queries for
.eq("id",orwhere id =that do not also constrainuser_idto the session user.Check updates and deletes, not only reads. Also check child routes:
/api/notes/1842/export, comments, and files.If you use Supabase from the browser, read the RLS policy.
auth.uid() = user_idis the check.auth.role() = 'authenticated'alone means every signed-in user.
How to fix
Next.js
Take the user id from the session. Use the object's id from the URL only as a lookup inside that user's rows.
const user = await getCurrentUser();
if (!user) return new Response("Unauthorized", { status: 401 });
const note = await db.note.findFirst({
where: { id: params.id, userId: user.id },
});
if (!note) return new Response("Not found", { status: 404 });
Return 404 when the row exists but is not theirs, so you do not confirm the id. Do the same for update and delete.
Supabase
create policy "notes_select_own"
on public.notes
for select
to authenticated
using (auth.uid() = user_id);
Repeat for insert (with check), update, and delete. Enable RLS or the policy is stored and not enforced. Supabase's linter calls that out as policies existing while RLS is disabled.
Firebase
In rules, compare request.auth.uid to the owner field on the document. request.auth != null only proves the caller is signed in.
Vercel
Nothing about the host fixes this. The check lives in the query or the database policy.
Copy-paste prompt
Paste this into Cursor, Claude Code, or whichever agent is editing the repo. Read the diff before you merge it.
You are fixing object-level authorization. Do not add features. Find every read, update, and delete that takes an id from the URL, query string, or request body. For each: 1. Load the user from the server session. 2. Scope the query to that user (user_id = session user AND id = requested id). 3. If no row matches, return 404. 4. Apply the same rule to exports, comments, and file routes, not only the main GET. If the app uses Supabase from the browser, add or tighten RLS so auth.uid() must match the owner column. Do not add a policy of using (true). Do not switch ids from numbers to UUIDs and call that the fix. List each route and the owner check you added.
Questions
We use UUIDs. Is that enough?
No. An unguessable id still needs an ownership check. The id will leak. The check is what holds.
Should I return 403 so the client knows the row exists?
Prefer 404 for objects the caller does not own. A 403 tells them the id is real.