All posts
VibeZero Team6 min read

IDOR, explained

Change an id in the URL, get someone else's record. Why AI-generated apps ship IDOR by default, what the one-line fix is, and how to test it in five minutes.

  • security
  • vibe coding
  • engineering

An Insecure Direct Object Reference is what you get when an app checks who you are and forgets to check what you are allowed to have. You log in legitimately, change a number in a URL, and receive a record belonging to someone else. No exploit, no tooling, no payload. The request is well-formed and the server answers it.

It is the single most likely serious flaw in an app an AI wrote for you, and the reason has more to do with how features get described than with how models generate code.

The shape of it

GET /api/invoices/1041   -> your invoice
GET /api/invoices/1042   -> somebody else's invoice

The server verified your session on both requests. It never asked whether invoice 1042 is yours. In the terms OWASP uses, authentication passed and authorization was never performed, and the two words being nearly identical is part of why this keeps happening.

The class it belongs to sits at the top of the list. A01:2025 Broken Access Control retained the number one position in the OWASP Top 10, with 40 mapped CWEs, 1,839,701 recorded occurrences, and the finding that 100 percent of applications in the dataset had some form of broken access control. IDOR maps most cleanly to CWE-639, Authorization Bypass Through User-Controlled Key.

It is also one of a very small number of specific web bugs to get its own government advisory. In July 2023, CISA, the NSA, and the Australian Signals Directorate’s ACSC published Preventing Web Application Access Control Abuse, describing IDOR flaws as frequently exploited in data breach incidents and responsible for compromising the personal, financial, and health information of millions of people. Three national agencies do not co-sign an advisory about a bug class that is hard to exploit.

Why generated code has it by default

Three reasons, and none of them is that the model is bad at security.

Authentication is a feature and authorization is a property. “Add login” is a prompt with a visible, testable result: a screen appears, a session exists. “Check ownership” is not one feature, it is a condition on every data access in the app forever, and there is no screen where you can see it working. Models build what was asked for, and this is systematically not asked for.

The bug is invisible in every test you would run. You log in as yourself, click around, see your own data. Everything is correct. The failure only appears when you send an id you were not given, which is not a thing the UI ever does, so no amount of clicking finds it.

The happy path is genuinely simpler. Scoping a query to the current user means threading the session into the data layer. Without that requirement, where id = ? is the obvious code, and it is the code you get.

The fix, and where to put it

One predicate. What matters is that it lives in the query rather than in a check after it, so there is no path where the row was fetched and the comparison was skipped:

// Vulnerable: authenticated, unauthorized.
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  const result = await db.query(
    "select * from invoices where id = $1",
    [req.params.id],
  );
  res.json(result.rows[0]);
});

// Fixed: the scope is part of the question.
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  const result = await db.query(
    "select * from invoices where id = $1 and user_id = $2",
    [req.params.id, req.user.id],
  );
  if (result.rowCount === 0) return res.sendStatus(404);
  res.json(result.rows[0]);
});

Two details in there are worth stating explicitly.

Return 404, not 403. A 403 tells the caller the record exists and belongs to someone else, which turns a failed attempt into a working enumeration oracle for finding valid ids. Not found is both truthful (to that user, it is not found) and silent.

The same rule covers writes, and writes are worse. An IDOR on a DELETE or an UPDATE is not a disclosure, it is an attacker editing another account’s data. Audit POST, PATCH, and DELETE handlers first if you have limited time, because the blast radius is larger and people instinctively check reads.

In a Next.js app the same fix belongs inside the Server Action, because a page-level guard does not extend to it and the action is reachable by direct POST. In a Supabase app it belongs in the RLS policy as well as the query, so that the database refuses even when the code forgets. Two layers failing in the same direction is cheap here, and the RLS explainer covers the database half.

Why UUIDs are not the fix

The most common wrong answer is to swap sequential integers for UUIDs so ids cannot be guessed. It does raise the effort of blind enumeration and it is worth doing, and it is not a fix, for one reason: ids leak constantly through ordinary use. They appear in share links, in list endpoints, in webhook payloads, in exported CSVs, in email notifications, and in the URL a user pastes into a support ticket.

The moment an attacker has one valid id belonging to someone else, an unguessable id and a sequential id are the same id. Unpredictability makes discovery harder; only the predicate makes the answer wrong.

Testing for it in five minutes

You need two accounts. Everything else is one command.

  1. Sign in as user A. Create a record. Note its id.
  2. Sign in as user B in a separate browser profile and copy B’s session token or cookie.
  3. Request A’s record with B’s credentials:
curl -s -o /dev/null -w "%{http_code}\n" \
  "https://your-app.example/api/invoices/1041" \
  -H "Authorization: Bearer <user-B-token>"

404 or 403 is a pass. 200 is a launch blocker.

Then repeat for the write path, which is the one nobody checks:

curl -s -o /dev/null -w "%{http_code}\n" -X DELETE \
  "https://your-app.example/api/invoices/1041" \
  -H "Authorization: Bearer <user-B-token>"

Do this against three or four endpoints. If any of them answers, the finding is not “that endpoint,” it is the app: the pattern that produced one missing predicate produced the others, and the remaining routes have simply not been tested.

Where it shows up

Everywhere generated code meets a database, which in practice means Replit apps where the Express route takes :id straight from the URL, v0 apps where the Server Action trusts its argument, and Lovable and Bolt apps on Supabase, where the client-side query is scoped by id alone and the database policy that should have caught it was never enabled.

Every platform’s version is the same bug with different syntax. That is the useful thing about it: it is not a fact about a vendor, it is a fact about how software gets described to something that writes it, and the remedy is one predicate in a place a person actually looked.

ShareXLinkedIn