Security · Authorization · AI codegen

IDOR: a logged-in user is not an authorized user

Your API confirms the session is valid, then fetches whatever record ID it was handed. Change the ID in the URL and you get somebody else's invoice. AI code generators reproduce this on nearly every resource endpoint they write, which is why it is almost never one endpoint. It is all of them.

What is an IDOR vulnerability?

An Insecure Direct Object Reference is an endpoint that checks whether you are logged in, then returns whatever record ID you asked for, without checking whether that record belongs to you. It affects any application that fetches, updates or deletes a resource by an ID taken from the URL, the query string or the request body, which is essentially every CRUD app ever built. The attacker does not need a tool, a payload, or any skill: they need a valid account of their own and the ability to change a number in an address bar.

The developer who described the effect most usefully put it in one line on dev.to: "Any authenticated user can read any other user's data... AI editors reproduce IDOR on every resource endpoint they generate." That second clause is the part worth internalising. A human who forgets an ownership check forgets it on one endpoint, on a bad day. A code generator that does not write ownership checks does not write them anywhere, because it applies the same shape to orders, invoices, documents, messages and uploads in the same session.

This is also the failure that survives everything you did right. Strong passwords, two-factor, HTTPS, a well-configured session library, rate limiting: none of it touches IDOR, because the attacker is a legitimate, fully authenticated user doing something your API is willing to do.

How do I test whether my app has IDOR?

Create two accounts, take a record ID belonging to the second one, and request it while holding the first one's token. If you get data back, you have it. That test takes ten minutes and no tooling, and it is the only test whose result you should trust.

TEST 01

The two-account test

10 minutes

Sign up as user A in your normal browser and user B in a private window. As B, create something: an order, a document, a note. Copy its ID out of the URL or out of the network tab. Then, as A, ask for it.

Run this
# Copy A's access token out of dev tools: Application > Local Storage,
# or the Authorization header on any request the app already makes.
export TOKEN_A="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
export BASE="https://your-app.com"
export B_RECORD_ID="7f3a1c04-9b55-4f0e-8a51-2d6c0b1e4a77"

curl -s -w '\nHTTP %{http_code}\n' \
  -H "Authorization: Bearer $TOKEN_A" \
  "$BASE/api/orders/$B_RECORD_ID"

A 404 or an empty result is correct. A 200 with B's data in it is an IDOR, confirmed, on that endpoint. Now repeat for PATCH and DELETE on the same record, because read-only IDOR and write IDOR are separate findings and the write one is worse.

TEST 02

If your IDs are sequential, enumerate them

2 minutes

Integer primary keys make this trivial, which is why they are the classic case. You do not need to know anyone else's IDs, you just count.

Run this against your own app only
for id in $(seq 1 50); do
  code=$(curl -s -o /dev/null -w '%{http_code}' \
    -H "Authorization: Bearer $TOKEN_A" \
    "$BASE/api/orders/$id")
  printf '%s %s\n' "$id" "$code"
done | awk '$2 == 200 { print }'

Count the 200s. If that number is larger than the number of orders user A actually placed, every extra one belongs to somebody else. This is the version of the test you can hand to a non-technical founder and have them understand the result immediately.

TEST 03

Find every endpoint that needs testing

15 minutes

The two tests above prove one endpoint at a time. This finds the list. Grep for every place a record is fetched by an ID, then read each one and ask a single question: does the query that retrieves the record also filter by the current user?

Run this
grep -rnE "findUnique|findFirst|findById|findOne|\.eq\('id'|where: *\{ *id" \
  --include="*.ts" --include="*.tsx" --include="*.js" \
  app/ pages/ src/ server/ api/ 2>/dev/null | grep -v node_modules

Then list the endpoints themselves, so you can see how many you are actually dealing with:

# Next.js App Router
find app -name "route.ts" -o -name "route.js" | sort

# Next.js Pages Router
find pages/api -name "*.ts" -o -name "*.js" | sort

# Express and friends
grep -rnE "app\.(get|post|put|patch|delete)\(|router\.(get|post|put|patch|delete)\(" \
  --include="*.ts" --include="*.js" src/ server/ | grep -v node_modules

Do not forget Next.js Server Actions. They are not in any route file listing, and they are public HTTP endpoints that anybody can invoke directly with whatever arguments they like. Every 'use server' function needs the same ownership check as a route handler:

grep -rn "'use server'" --include="*.ts" --include="*.tsx" app/ src/ | grep -v node_modules

What does the vulnerable handler actually look like?

It looks correct, which is the problem. There is a session check at the top, the code reads clearly, and it passes every manual test you will ever run on it, because you only ever test it logged in as yourself, looking at your own data.

VULNERABLE

Next.js App Router, the shape a model generates by default

Critical
app/api/orders/[id]/route.ts
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';

export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
  const session = await auth();
  if (!session) {
    return new Response('Unauthorized', { status: 401 });   // authentication: fine
  }

  // and that is the last time the current user is mentioned in this function
  const order = await prisma.order.findUnique({
    where: { id: params.id },
  });

  return Response.json(order);
}

Read the two halves separately. The top half asks "is there a valid session?" and answers it correctly. The bottom half asks "what is the order with this ID?" and answers that correctly too. Nothing in the function ever asks whether those two things have anything to do with each other. session.user.id is fetched and then never used.

There is a specific reason findUnique shows up here so reliably. Prisma's findUnique only accepts unique fields in its where, so you cannot add userId to it even if you want to. A model reaching for the most obvious "get one record" call lands on the one API that structurally cannot express the ownership filter, and then has no reason to reconsider.

The same bug in raw SQL and in Express
-- vulnerable
select * from orders where id = $1;
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const { rows } = await db.query('select * from orders where id = $1', [req.params.id]);
  res.json(rows[0]);
});
And the write version, which is worse
// anyone with a valid session can change the status of anyone's order
await db.query('update orders set status = $2 where id = $1', [req.params.id, status]);

This is the shape behind the r/lovable report: "I could upgrade myself to premium, delete other users' data, or tamper with core records, all because PUT or PATCH endpoints were wide open." Reading someone else's data is a breach. Writing to it is a breach plus a data integrity problem you may never be able to unwind, because you will not know which rows were touched.

Why does authentication middleware not solve this?

Because middleware answers a different question. Authentication asks "who is this?" and can be answered from the request alone, before anything else runs. Authorization asks "may this specific principal perform this specific action on this specific object?" and cannot be answered until you know which object, which the middleware never sees.

Middleware runs before the handler. At that point the request is a method, a path and some headers. The record has not been fetched, its owner is not known, and nothing in scope could compare them. A middleware could only close IDOR by knowing every route's resource type, how to load it, and what ownership means for it, at which point you have written all the ownership rules anyway, just somewhere less obvious.

Next.js makes this particularly easy to get wrong, because middleware.ts with a matcher feels like a global security control. It is usually configured to protect page routes, so the app looks locked down when you click around, while every handler under /api/ is individually reachable with a valid session and the right URL. The pages are gated. The data is not.

The practical rule is simple and it is worth stating flatly: the ownership check belongs in the same query that retrieves the record. Not in a separate if after the fetch, which can be forgotten or short-circuited by an early return. Not in a middleware, which cannot see the object. In the query, so that a record you are not allowed to see is never loaded into memory in the first place, and so that the failure mode of forgetting is an empty result rather than a leak.

Why do AI code generators produce IDOR on almost every endpoint?

Because the instruction is "make an endpoint that returns an order by ID", and the vulnerable version satisfies that sentence completely. The model writes the smallest correct implementation of what was asked, and nothing in the feedback loop ever distinguishes it from the safe version.

Three things compound, and understanding them tells you where else to look.

The specification never mentions ownership. Nobody prompts "return the order by ID, but only if it belongs to the caller, and return 404 otherwise so existence does not leak." They prompt "add an orders endpoint." The ownership rule lives in the founder's head as an assumption so obvious it never gets said, and a model cannot infer a requirement from an assumption it was never given.

The test loop cannot see the bug. You build it, you log in, you look at your orders, they are correct. The vulnerable implementation and the correct implementation produce byte-identical output for the only test anyone runs. It takes a second account to tell them apart, and a second account is not part of anyone's inner loop.

The mistake is applied uniformly. This is the part that is genuinely specific to AI-generated code rather than a repackaged beginner mistake. An agent generating a CRUD layer stamps the same template onto every resource in one pass. So the finding is not "the orders endpoint is vulnerable", it is "orders, invoices, documents, messages, uploads and team members are all vulnerable in exactly the same way." Fixing the one you found and stopping is the single most common mistake people make after reading an article like this one.

It is also a bug class that gets proportionally worse as an application ages and accumulates endpoints, which is the opposite of how people assume security debt behaves. New endpoints get added by prompt, they inherit the same pattern, and nobody re-runs the sweep.

How do I fix IDOR properly?

Put the ownership filter inside the query, return 404 rather than 403 when it matches nothing, and back it with a database-level policy so a handler that forgets still cannot leak. Three layers, and the first one is a one-line change per endpoint.

FIX 01

Ownership goes in the query, not in a check afterwards

app/api/orders/[id]/route.ts, fixed
import { NextRequest } from 'next/server';
import { auth } from '@/lib/auth';
import { prisma } from '@/lib/prisma';

export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
  const session = await auth();
  if (!session?.user?.id) {
    return new Response('Unauthorized', { status: 401 });
  }

  // findFirst, not findUnique, because the filter is no longer only the primary key
  const order = await prisma.order.findFirst({
    where: {
      id:     params.id,
      userId: session.user.id,   // the whole fix
    },
  });

  if (!order) {
    // 404, not 403: a 403 would confirm the record exists
    return new Response('Not found', { status: 404 });
  }

  return Response.json(order);
}
Raw SQL, and the write case
-- reads
select * from orders where id = $1 and user_id = $2;
// writes: filter in the statement, then confirm it actually applied
const { rowCount } = await db.query(
  'update orders set status = $2 where id = $1 and user_id = $3',
  [req.params.id, status, req.user.id]
);
if (rowCount === 0) return res.sendStatus(404);
res.sendStatus(204);

The rowCount === 0 check is not optional. Without it the update quietly affects zero rows, returns 200, and your UI cheerfully tells the attacker their change was saved, which hides the attempt from your logs and from you.

Make the safe version the easy version

A per-endpoint fix that depends on remembering will eventually stop being remembered. Put the ownership filter in one place and route everything through it:

// lib/orders.ts
export function ordersFor(userId: string) {
  return {
    findOne: (id: string) =>
      prisma.order.findFirst({ where: { id, userId } }),
    list: () =>
      prisma.order.findMany({ where: { userId } }),
    update: (id: string, data: Prisma.OrderUpdateInput) =>
      prisma.order.updateMany({ where: { id, userId }, data }), // returns { count }
  };
}

Now the handler cannot express the unsafe query without deliberately going around the helper, and a reviewer grepping for prisma.order. outside lib/orders.ts has a complete list of the places to look. Note updateMany rather than update: Prisma's update has the same unique-field constraint as findUnique, so updateMany is what lets the ownership filter into the statement, and its count is what tells you whether it applied.

FIX 02

Push the rule into the database so forgetting is not fatal

Application-level checks are per-endpoint, which means they are per-opportunity-to-forget. A row-level security policy is per-table, and it applies to every query from every route, including the one somebody adds next month without reading this.

Postgres and Supabase
alter table public.orders enable row level security;
alter table public.orders force  row level security;

create policy "orders_select_own" on public.orders
  for select to authenticated
  using ( (select auth.uid()) = user_id );

create policy "orders_update_own" on public.orders
  for update to authenticated
  using      ( (select auth.uid()) = user_id )
  with check ( (select auth.uid()) = user_id );

Two caveats that decide whether this actually helps you.

  • If your server connects with the Supabase service role key, RLS does nothing. That key carries the Postgres bypassrls attribute and ignores every policy. A route handler using it must do its own ownership check, exactly as in fix 01. This is the most common reason people write policies and remain vulnerable.
  • If your frontend queries Supabase directly, a client-side filter is not a control. Writing .eq('user_id', user.id) in browser code is a convenience for you, not a constraint on an attacker, who can open dev tools and issue the request without it. The rule has to be the policy.

If you have not enabled row level security at all, that is a bigger and more urgent problem than IDOR on its own, and it is covered in full in the Supabase RLS article.

FIX 03

Be consistent about what you return

403 leaks, 404 does not

A 403 on a record that exists and a 404 on a record that does not is an oracle: an attacker can enumerate which IDs are real without ever seeing the data. Return 404 for anything the caller may not see, and return it identically whether the record is missing or merely not theirs.

The part people get wrong is consistency. If nineteen endpoints return 404 and one returns 403, the one is a free existence check for every ID in the system. Pick the behaviour once, put it in the shared helper, and do not let individual handlers improvise.

Do random UUIDs prevent IDOR?

No. Unguessable IDs raise the cost of finding a target, which is worth having, but they are obscurity rather than access control. The vulnerability is that your endpoint does not check ownership, and that remains exactly as true when the ID is a UUID.

The reason this fails in practice is that IDs leak constantly through paths you fully intended:

  • A list or search endpoint the user is legitimately allowed to call returns other people's record IDs in a related field: a sharedWith array, an authorId, a team roster, an activity feed.
  • URLs get pasted into Slack, into support tickets, into shared documents, into browser histories on shared machines.
  • IDs appear in error messages, PDF and CSV exports, webhook payloads, email footers and client-side analytics events.
  • Anything that has ever been shared with a collaborator who later left is an ID that person still has.

There is also a detail people miss: not all UUIDs are random. Version 1 UUIDs encode a timestamp and a node identifier, and several popular sortable ID schemes deliberately encode creation time, which makes neighbouring records partially predictable. If you are relying on unguessability, at minimum know which generator you are using. But the better answer is not to rely on it at all: use whatever ID format suits your database, and write the ownership check.

What related bugs show up alongside IDOR?

Three, and they come from the same root cause: the server trusting an identifier or a field that the client controls. If you found one IDOR, check all three, because the same generation pass produced them.

RELATED 01

Identity taken from the request instead of the session

Critical
The pattern
// vulnerable: the caller tells the server who they are
const orders = await prisma.order.findMany({
  where: { userId: req.nextUrl.searchParams.get('userId') },
});

// fixed: identity comes from the verified session, always
const orders = await prisma.order.findMany({
  where: { userId: session.user.id },
});

The rule: a user ID that arrives in a query string, a request body, a header or a hidden form field is an input, not an identity. The only identity is the one derived from a verified session or token.

RELATED 02

Mass assignment: writing whatever the client sent

Critical
The pattern
// vulnerable: the body goes straight into the update
await prisma.user.update({ where: { id: session.user.id }, data: req.body });
// client posts {"name":"Dee","role":"admin","credits":999999}

// fixed: an explicit allowlist, nothing else can reach the database
const Body = z.object({ name: z.string().min(1).max(120) });
const parsed = Body.safeParse(await req.json());
if (!parsed.success) return new Response('Bad request', { status: 400 });
await prisma.user.update({ where: { id: session.user.id }, data: parsed.data });

Note that this one is an ownership check away from being invisible: the user is updating their own row, which every ownership rule permits. What they are not permitted to do is decide which columns. That distinction also has a database-level answer, revoke update (role, credits) on ... from authenticated, which is worth having as well.

RELATED 03

Nested resources checked only at the parent

High
The pattern
// GET /api/projects/:projectId/tasks/:taskId
// vulnerable: the project is verified, the task is not bound to it
const project = await prisma.project.findFirst({
  where: { id: projectId, ownerId: session.user.id },
});
if (!project) return notFound();
const task = await prisma.task.findUnique({ where: { id: taskId } }); // any task at all

// fixed: the child is bound to the parent, and the parent to the user
const task = await prisma.task.findFirst({
  where: { id: taskId, project: { id: projectId, ownerId: session.user.id } },
});

This one is easy to miss in review because the handler visibly contains an authorization check. It is just checking the wrong object.

How do I stop it coming back next week?

Write one automated cross-user test per resource type and run it in CI. It is the only thing that survives the next prompt, because the next endpoint an agent generates will have the same shape and nobody will re-read this article before merging it.

TEST

One test per resource, seeded with two real users

Vitest or Jest, against a running app
const RESOURCES = ['orders', 'invoices', 'documents', 'messages', 'uploads'];

describe.each(RESOURCES)('cross-user access on /api/%s', (resource) => {
  it('user A cannot read a record owned by user B', async () => {
    const res = await fetch(`${BASE}/api/${resource}/${seeded[resource].ownedByB}`, {
      headers: { Authorization: `Bearer ${tokenA}` },
    });
    expect(res.status).toBe(404);
  });

  it('user A cannot modify a record owned by user B', async () => {
    const res = await fetch(`${BASE}/api/${resource}/${seeded[resource].ownedByB}`, {
      method: 'PATCH',
      headers: { Authorization: `Bearer ${tokenA}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'tampered' }),
    });
    expect(res.status).toBe(404);
    // and assert the record is genuinely unchanged, not just that the status looked right
    const asB = await fetch(`${BASE}/api/${resource}/${seeded[resource].ownedByB}`, {
      headers: { Authorization: `Bearer ${tokenB}` },
    });
    expect((await asB.json()).title).not.toBe('tampered');
  });
});

The second assertion in the write test matters more than the status code. A handler can return 404 and still have performed the update, if the check and the write happen in the wrong order. Verify the object, not the response.

Add the resource name to the RESOURCES array every time you add an endpoint, and the test suite fails loudly the first time someone generates a handler without an ownership filter. That is worth more than any one-off audit, including ours.

What does fixing IDOR not cover?

Ownership is not the same as permission, and an endpoint-by-endpoint fix only covers the endpoints you enumerated. Four gaps remain after every handler you found is correct.

  • Roles inside a shared tenant. "This record belongs to your organisation" is a different question from "you are allowed to delete it." Once you have teams, an ownership filter on orgId lets a read-only member do everything an admin can. That needs a real permission model, not a wider filter.
  • The endpoints you did not find. GraphQL resolvers, tRPC procedures, Next.js Server Actions, Supabase Edge Functions and background jobs are all reachable and none of them show up in a listing of route files. Each needs the same check, and the sweep is only as good as the inventory it started from.
  • Anything already taken. Fixing the handler stops the next request. It says nothing about whether anyone made the previous ones. That is a log question, and on most hosting platforms access logs have a short retention window, so if you have real users the time to export them is before you deploy the fix, not after.
  • The rest of the list. IDOR travels with the other patterns AI codegen produces in the same session: unverified webhooks, client-trusted paid flags, secrets in the bundle. Our security checklist covers fourteen of them with the same check-and-fix structure.

When should I stop and hire an engineer?

If you can list your endpoints, you have fewer than about ten resource types, and there is a single notion of ownership, fix this yourself. Run the grep, add the filter to each query, write the cross-user tests, ship. That is a day of unglamorous work and it does not need a specialist. Do it before your next feature.

Get help when one of these is true:

  • You cannot enumerate your own endpoints. If the app has Server Actions, GraphQL, Edge Functions and route handlers mixed together, the inventory is the hard part and getting it wrong means you fixed nine of eleven and believe you are done.
  • There are roles, teams or tenants. The moment "who owns this" has more than one answer, you need an access model rather than a filter, and retrofitting one across an existing schema is a design job.
  • Money or regulated data moves through it. Payments, health information, identity documents and anything covered by a customer contract change the stakes of a missed endpoint from embarrassing to legal.
  • You already found one and you have real users. Then the questions are what else has the same shape, and whether anyone used it. The first is a sweep, the second is log forensics on a clock.

And the honest version of the sales pitch: an audit is worth money when it tells you something your own two-account test could not. If your app is five endpoints and one user type, your own test is the audit. Run it today rather than booking anything.

If a cross-user request returned data

Export your access logs before you deploy the fix, because retention windows are short and you will want to know which IDs were requested by which accounts. Then fix every endpoint of that shape, not only the one you tested. Our AI app security audit is exactly this sweep across every reachable endpoint, with the findings written so you can hand them to whoever is doing the work, including yourself.

Free, no course attached

The rescue playbook.

Real prompts, security rules, and teardowns of broken AI-built apps, sent when we publish something worth your inbox. No fluff, no drip sequence.

One or two emails a month. Unsubscribe anytime.

Want every endpoint tested, not just the one you found?

We enumerate every reachable endpoint, including Server Actions and Edge Functions, run cross-user access tests against each, and hand you a prioritized list with the fix for every finding.

Questions

Common questions about IDOR.

What is an IDOR vulnerability?

An Insecure Direct Object Reference is an endpoint that checks whether you are logged in, then returns whatever record ID you asked for, without checking whether that record belongs to you. It affects any application that fetches, updates or deletes a resource by an ID taken from the URL, the query string or the request body. The attacker does not need a tool or any skill: they need a valid account of their own and the ability to change a number in an address bar.

Does adding authentication middleware fix IDOR?

No, because middleware answers a different question. Authentication asks who this is and can be answered from the request alone, before anything else runs. Authorization asks whether this specific principal may perform this specific action on this specific object, and cannot be answered until you know which object, which the middleware never sees. The ownership check belongs in the same query that retrieves the record, so that a record you are not allowed to see is never loaded into memory in the first place.

Do random UUIDs prevent IDOR?

No. Unguessable IDs raise the cost of finding a target, which is worth having, but they are obscurity rather than access control. IDs leak constantly through paths you fully intended: list endpoints that return other people's record IDs in a related field, URLs pasted into Slack and support tickets, error messages, CSV exports, webhook payloads and email footers. Note also that not all UUIDs are random, since version 1 UUIDs encode a timestamp and a node identifier.

Should the API return 403 or 404 when a user requests someone else's record?

Return 404, and return it identically whether the record is missing or merely not theirs. A 403 on a record that exists and a 404 on a record that does not is an oracle: an attacker can enumerate which IDs are real without ever seeing the data. The part people get wrong is consistency. If nineteen endpoints return 404 and one returns 403, the one is a free existence check for every ID in the system.

How do I test my own app for IDOR?

Create two accounts, take a record ID belonging to the second one, and request it while holding the first one's token. A 404 or an empty result is correct. A 200 with the other user's data in it is an IDOR, confirmed, on that endpoint. Then repeat for PATCH and DELETE on the same record, because read-only IDOR and write IDOR are separate findings and the write one is worse. Finally, grep the codebase for every place a record is fetched by ID and ask, for each one, whether the query that retrieves the record also filters by the current user.