Skip to content
ShipSecurelySecurity reference
Menu
Your tool here — sponsor ShipSecurely

Issues

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.

Share link · Share on X

Last updated

criticalAccess control

Shows up in: Next.js, Supabase, Firebase

Markdown version

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

  1. 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.

  2. Search queries for .eq("id", or where id = that do not also constrain user_id to the session user.

  3. Check updates and deletes, not only reads. Also check child routes: /api/notes/1842/export, comments, and files.

  4. If you use Supabase from the browser, read the RLS policy. auth.uid() = user_id is 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.

Sources

Related