What the bug looks like
A logged-in user requests /api/orders/1041 and gets an order. They change the number to 1042 and get someone else's order, because the route only checks that a session exists.
Fix it with an ownership filter
const order = await db.order.findFirst({
where: { id: params.id, userId: session.user.id },
});
if (!order) return new Response('Not found', { status: 404 });Test it with two accounts
- Create two test users on your own app and place data under each.
- Log in as user A, copy a request that reads or changes user A's record, and replay it with user B's session.
- The response must be 403 or 404. Repeat for read, update and delete on every resource type.
- Do the same for admin routes using a normal user.
Common blind spots
- List endpoints that return all rows instead of the caller's rows.
- Nested resources where the parent is checked but the child id is not.
- Functions that use a service-level database key and skip row-level rules.
- Buttons hidden in the UI while the underlying route stays open.
Frequently asked questions
Do random UUIDs prevent IDOR?
They make guessing harder but do not enforce access. If a UUID leaks, an unchecked route still serves the data.
Should I return 403 or 404?
Returning 404 for records the caller may not see avoids confirming that the record exists.