Get listed

IDOR: put authorization in the query, not the URL (2026)

A filing cabinet with a key that only fits a blank tag.

Most IDOR bugs are a missing ownership check, not a broken encryption scheme.

A user asks for /api/invoices/1842. The server looks up invoice 1842 and returns it because the requester is logged in. It never asks whether 1842 belongs to that user. Change the number, get someone else’s invoice. That is the whole class.

The usual mistake is hiding the identifier or switching to a UUID and calling the bug gone. A guessable integer makes the attack easy. An unguessable identifier only slows an attacker who already has a valid id from another response, a webhook, or an email. Authorization still has to run on the object.

This page is the check that belongs on every object fetch, the tests that fail when you only try your own ids, and the production incidents that keep repeating the same miss.

More than 64 million McHire applicants sat behind a login that still answered any inbox id. A 2026 preprint then counted 107 HackerOne reports tagged IDOR or Improper Access Control: 84 were BOLA, and 41.7 percent of those were writes or deletes, not reads. Insecure direct object reference is still a missing authorization check on an object the client named.

The control is the tenant in the same WHERE as the id, a schema that will not let the client pick a new owner, and a two-account replay you run on your own app before you ship.

Keep the secure coding checklist next to this page. Read injection when the query string is the problem, and CSRF when the browser is the one attaching the cookie. This page is the object the cookie is already allowed to name.

Session A names invoice B. The query that only binds the id returns B’s memo with 200. The query that also binds the tenant returns 404.

SecureCoding

IDOR is an authorization bug, not an ID format bug

CWE-639 is authorization bypass through a user-controlled key. The client sends a reference. The server uses it. Nobody asks whether that principal may see or change that row. The reference can live in the path, the query, a header, or a JSON body. The object can be an invoice, a file, a VIN, a GraphQL document id, or a chat thread.

OWASP API1:2023 Broken Object Level Authorization. Three sentences on that page do the work this article is for:

  • Object ids can be sequential integers, UUIDs, or generic strings. Regardless of type they are easy to identify.
  • Comparing the user id from the current session (including a JWT) with the parameter is not a sufficient fix.
  • Every function that uses client input to access a record has to run the authorization check. Prefer random GUIDs and those checks.

API1 also draws the line with broken function level authorization. If the user was never supposed to reach the endpoint, that is BFLA. If they were supposed to reach it, and they pointed it at someone else’s object, that is BOLA. Most Express CRUD is the second case. The route is correct. The row is wrong.

Authentication answered “who is this.” IDOR is the next question: “which rows.” A valid session only answers who. It does not pick the row.

Four incidents, one missing check

The opener named two of these. The other two sit later on the page. The shared column is the same: a caller the app already knew, pointed at an object the query did not scope.

CaseWhenWhat leakedThe miss
McHire / Paradox.aiJun 202564m+ applicant inboxesAuthenticated inbox API took any object id
Experian credit-report bypassJan 2023Other people’s reportsThe id was enough once you were in
Suno.com disclosureOct 2025Private songs and promptsValid user, wrong song id
BOLA preprint, 107 HackerOne reports2021 to Jan 202684 confirmed BOLA41.7% were writes or deletes, not reads

Three questions that keep coming back

Do UUIDs stop IDOR?

No. They make guessing harder. API1 lists UUIDs next to sequential integers as identifiers an attacker can still pick up from another response. Keep random ids as depth. Put org_id in the query anyway.

Is comparing the JWT sub to the path enough?

OWASP API1 says no. That compare only covers the case where the object id is the user id. An invoice, a file, a VIN, or a chat thread needs a check on that object. Session user id versus req.params.id is the small subset.

Should a foreign id be 404 or 403?

404. Same status as a row that does not exist, from the same query, with no extra lookup. 403 on a known id is an existence oracle. If a UI needs a different human sentence, decide after the scoped query. Do not load the row unscoped to pick a status.

Why UUIDs don’t fix anything

UUIDs are obscurity, not access control. IDs leak through CSV exports, webhooks, support tickets, other endpoints’ responses, email links, and browser history. A random primary key slows a stranger who is guessing. It does nothing once the id is in any of those places.

The OWASP IDOR cheat sheet says complex identifiers can make guessing impractical and still requires the access check, because a leaked URL is enough. If knowing an id grants access, the bug is already there. That rule does not care whether the column is an integer, a UUID v4, or a slug.

Opaque ids are still worth using as defense in depth. They shrink the blast radius of a missed check. They are not a substitute for the WHERE clause below. Do not encrypt the primary key “so the URL looks random.” You now have a ciphertext you have to rotate, and a missed check still opens the row.

Put the tenant in the WHERE clause

The failure mode in Node is almost always the same shape. The handler reads req.params.id, loads the row, then compares row.owner_id to the session. Reads sometimes get that second line. Updates and deletes often do not. A 403 on a found row and a 404 on a missing one also tell a caller which ids exist.

The query that closes the class makes an unauthorized request look like a missing row. Tenant and id in the same predicate, on every verb.

// BAD: load by id, then maybe compare. easy to skip on PATCH/DELETE.
// const { rows } = await pool.query(
// "SELECT * FROM invoices WHERE id = $1",
// [req.params.id]
// );

// FIX: pg 8. The tenant comes from the session, never from the client.
const { rows } = await pool.query(
 `SELECT id, memo, amount_cents
 FROM invoices
 WHERE id = $1 AND org_id = $2`,
 [req.params.id, req.actor.orgId]
);
if (rows.length === 0) return res.status(404).end();
res.json(rows[0]);

Writes use the same predicate. updateMany / RETURNING so a foreign id is zero rows. A 200 that changed someone else is the miss.

// Prisma 6: tenant in the where. Never update by id alone after a find.
const result = await prisma.invoice.updateMany({
 where: { id: req.params.id, orgId: req.actor.orgId },
 data: { memo },
});
if (result.count === 0) return res.status(404).end();
res.status(204).end();

Do not take org_id from the body or the path and “check it later.” Mint req.actor from the session once, in middleware, and pass that object into the query helper. A helper that still accepts orgId from the caller is how a future route puts the client back in charge.

404 for both “missing” and “not yours” is the position on this page. A 403 on a known id is an existence leak. Some compliance reports want 403. If you need that for a human UI, keep the query the same and only change the status after you have already filtered. Do not run a second unscoped SELECT to decide which status to send.

Writes: mass assignment and owner swapping

Read IDOR leaks a row. Write IDOR moves the row. The under-discussed half is a PATCH that accepts owner_id, org_id, or role because the handler spread req.body into the update.

That is how an object-level miss becomes a tenant takeover. Pair this page with the input validation guide: parse a schema, do not cast. On the write path, the schema must not contain the ownership columns, and it must reject keys it does not name.

const { z } = require("zod");

const PatchInvoice = z.object({
 memo: z.string().max(500).optional(),
 amountCents: z.number().int().nonnegative().optional(),
}).strict();

// FIX: unknown keys throw. owner_id / org_id / role never enter data.
const body = PatchInvoice.parse(req.body);
const result = await prisma.invoice.updateMany({
 where: { id: req.params.id, orgId: req.actor.orgId },
 data: body,
});
if (result.count === 0) return res.status(404).end();

zod’s default z.object strips unknown keys. That is safer than a spread, and it will hide a client that is probing for role. .strict() makes the probe a 400 you can alert on. Apply it on nested objects too. The modifier does not recurse.

Create paths have the same rule. The new row’s org_id comes from req.actor. A body field that sets the tenant is a write IDOR on insert.

Centralize policy so a new route cannot skip it

Route-local if (row.orgId !== req.user.orgId) does not scale. The next intern adds /internal/invoices/:id/pdf and copies the fetch without the compare. Put the predicate in one helper and make the handler boring.

function actorFrom(req) {
 const orgId = req.session && req.session.orgId;
 const userId = req.session && req.session.userId;
 if (!orgId || !userId) return null;
 return { orgId, userId };
}

function invoiceWhere(actor, id) {
 return { id, orgId: actor.orgId };
}

async function requireInvoice(actor, id) {
 const row = await prisma.invoice.findFirst({
 where: invoiceWhere(actor, id),
 });
 if (!row) {
 const err = new Error("not found");
 err.status = 404;
 throw err;
 }
 return row;
}

A policy module (can(actor, "invoice.read", row)) is fine for the cases the query cannot see: “finance role may export,” “the ticket is shared with this user.” It does not replace the tenant predicate. Run both. The query is the floor that still works when someone forgets to call can.

Grep is the triage. Every handler that reads a client id and never mentions the tenant in the same function is a suspect. That is a one-line ripgrep, and it is how you find the PDF route before a customer does.

rg -n --glob '!node_modules/**' --glob '!dist/**' \
 'req\.params\.(id|uuid|invoiceId|userId|documentId)'

# then, in each hit, require orgId / org_id / actor.orgId in the same function.
# a params.id with no tenant name next to it is the review.

Postgres row-level security as the backstop

Application code will miss a route. The database can still refuse the row. Postgres 18 row security page (the page banner is the 13 August 2026 release line: 18.6). Three facts from that page, because they bite people who “turned RLS on” and stopped:

  • With RLS enabled and no policy, the default is deny. No rows are visible or writable.
  • Table owners and BYPASSRLS roles skip policies unless you FORCE ROW LEVEL SECURITY.
  • Referential-integrity checks (unique, primary key, foreign key) bypass RLS. A covert channel through a constraint is a documented leftover.
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY invoices_isolation ON invoices
 USING (org_id = current_setting('app.current_org_id')::uuid)
 WITH CHECK (org_id = current_setting('app.current_org_id')::uuid);

Set the tenant per request with set_config(..., true) so it is SET LOCAL and dies at COMMIT. Do not interpolate the uuid into SET LOCAL.

const { Pool } = require("pg"); // pg 8
const pool = new Pool();

async function withOrg(orgId, fn) {
 const client = await pool.connect();
 try {
 await client.query("BEGIN");
 await client.query(
 "SELECT set_config('app.current_org_id', $1, true)",
 [orgId]
 );
 const result = await fn(client);
 await client.query("COMMIT");
 return result;
 } catch (err) {
 await client.query("ROLLBACK");
 throw err;
 } finally {
 client.release();
 }
}

RLS is the floor. You still write the application predicate. You still write WHERE id = $1 AND org_id = $2 in the application so a missed FORCE, a migration role, or a replica without policies does not become the incident. Backups must run with row_security = off only after you have decided a filtered dump is worse than an error. The docs say that setting throws if a policy would have filtered rows. That is the safe backup failure.

McHire already had logins. The inbox still skipped the object check

Ian Carroll’s writeup on the McHire / Paradox.ai review (the page is dated around the 30 June 2025 disclosure). This section does not include credentials, endpoints, or request bodies.

Two issues, named on that page. First: the restaurant-admin login accepted default credentials. Second: an authenticated inbox API did not check that the caller was allowed to see the object id it was handed. Together they exposed more than 64 million applicants. Disclosure went out at 5:46 p.m. ET on 30 June 2025. Paradox confirmed the issues resolved at 10:18 p.m. ET on 1 July 2025.

Daviey, on Hacker News on 10 July 2025 (same thread as the card below), refused to call the first half IDOR. Default credentials is missing authentication. He pointed at CWE-1390 and CWE-306. That reading is right. A login that anyone can walk through is not an object-level miss. The inbox half still is. A session that is allowed to use the API, pointed at another tenant’s object, is exactly API1.

The title on Ian’s page leads with IDOR. The body leads with both bugs. Hold both. Do not collapse “they used default passwords” into this class, and do not let that collapse hide the object check that was also missing. The control that would have contained the second bug is the query on this page: the inbox row is retrieved with the caller’s tenant (or restaurant, or inbox scope) in the same predicate as the id. A UUID on the applicant would not have been the control. The writeup’s own id space was already large.

Suno, October 2025: a valid user, the wrong song id

theelderemo posted a Suno.com disclosure on Hacker News on 10 October 2025 (the writeup is dated 9 October). Finding 2 on that post is the object check: an authenticated caller asked for another user’s song id and the API answered. Finding 1 on the same post is a JWT sitting in the JSON body.

scuttmc’s reply on that thread called the writeup rushed. Hold both. The shape does not need Suno to be true: a session that is allowed to use the API, pointed at someone else’s object, is API1. The JWT-in-the-body half is a session you did not mean to mint. That half lives on the JWT page.

A 2026 preprint, Broken Object Level Authorization in the Wild, sampled 200 HackerOne disclosures tagged IDOR or Improper Access Control from 2021 through January 2026 and classified 107. 84 met a strict BOLA test. Action-level object BOLA (a caller who may see a row and then edits or deletes the wrong one) was 41.7 percent of the confirmed set, tied with the classic guess-the-id-and-read family. That is why the PATCH path on this page is not an appendix. The sample is a preprint, not a census. The split is still the one you test: read, then write, then export.

Agents holding a user token move at machine speed

A curious user used to try a handful of ids. An agent holding a delegated user token will try all of them, on a schedule, without getting bored. MCP connectors and browser agents are that caller. The bug class did not change. The iteration speed did.

MCP Authorization spec, 2025-11-25. Two MUST lines from that page, and one from the Security Best Practices:

  • MCP servers must validate that access tokens were issued specifically for them as the audience.
  • MCP servers must not accept or transit any other tokens. Token passthrough is forbidden.
  • The best-practices page restates the floor: servers must not accept tokens that were not explicitly issued for that server.

That is audience binding. It stops a token minted for analytics from being replayed at billing, and it stops your MCP server from forwarding a client’s token upstream. It does not scope the token to one invoice. A user-scoped token that is valid for your server is still a full-user caller. If the handler answers any id that user can name, the agent enumerates the corpus.

So the agent-era control is the same predicate, applied harder. Object-scoped tokens are a nice extra if your authorization server will mint them. Most will not mint one per invoice. WHERE id = $1 AND org_id = $2 still runs when the caller is software. RLS still runs. The two-account test still runs, and you should run it again with a token your own test agent holds.

Do not “fix” this by hiding ids from the agent. The agent will get them from the same list endpoint the UI uses. Hide nothing. Authorize the row.

The two-account test

You are not walking an exploit. You are proving your own two test accounts cannot read each other. Create account A and account B on a staging app you own. Create one invoice (or document, or ticket) under each. Then replay.

  1. Log in as A. In DevTools, copy a GET of A’s own invoice as cURL. Keep that cookie on your machine.
  2. Swap only the object id for B’s invoice id, which you copied from B’s own session. Do not increment. Do not scan.
  3. Replay against your origin. Expect 404 and an empty body. A 200 with B’s fields is the bug. A 403 means the query found the row and then told you so.
  4. Repeat for PATCH and DELETE on the same two ids. Repeat for any export or PDF route the grep found.
# Your own two test accounts. A's cookie. B's invoice id from B's UI.
# Do not send this cookie anywhere else. Do not run it at a host you do not own.
curl -sS -D - -o /tmp/idor-a.out \
 -H "Cookie: session=PASTE_A_FROM_YOUR_DEVTOOLS" \
 "https://your-app.example/invoices/PASTE_B_INVOICE_ID"
# Expect: HTTP/2 404
# A 200 whose body contains B's memo is the miss.
# A 403 is an existence leak. Fix the query, then the status.

Script that pair once. Run it on every release. It beats a scanner that does not know your tenants. Then run the grep from the policy section. A new req.params.id with no orgId in the same function fails the check.

If you also issue bearer tokens to agents, repeat the curl with A’s Authorization header and B’s id. The cookie test does not cover that caller.

What the internet thinks about IDOR

Two community discussions capture the recurring argument:

Hacker NewsDaviey ยท 10 Jul 2025

“Perhaps I’m being overly cynical, but I’m struggling to see how this qualifies as an IDOR in the strict sense. While using UUIDs might reduce guessability, the real issue here is weak authentication, not insecure direct object references.”

On the McHire writeup. The rest of the comment maps the default-credential half to CWE-1390 and CWE-306. The inbox half remains an object-level miss.

Hacker Newstheelderemo ยท 10 Oct 2025

“The API does not validate if a user owns a resource before returning data. This allows any authenticated user to access the private content of any other user, including private songs, prompts, and generation history, simply by enumerating user IDs.”

Suno disclosure. scuttmc’s dissent is on the same thread: the writeup went public the next day. The object-check sentence is still the test on this page.