CRITICAL Express · Next.js · Rails · any REST API

Records fetched by ID with no ownership check (IDOR)

What you'd see: Endpoints look like findUnique({ where: { id } }) with the ID taken straight from the URL.

§ 01 — What's actually happening

There are two questions to answer on every request: is this person signed in, and is this <em>their</em> record. AI-generated code almost always answers the first and almost never answers the second. Any signed-in user — including someone who signed up thirty seconds ago — can walk your ID space and read everything in it.

Why an AI tool writes it this way

"Show the invoice" is a fetch-by-ID operation, and that is what gets written. Ownership is an implied business rule that was never stated, so it never appears. The bug is invisible in normal use, because in normal use people only click links to their own records.

What it costs you

Sequential IDs make this trivial to exploit and trivial to automate — a short script can enumerate every invoice, message or document you hold. Under GDPR this is a reportable personal-data breach, not merely a bug.

§ 02 — Before & after

Change the number, see someone else's invoice

Illustrative code, written for this page — never a client's project.

AI-generated

  • Checks authentication but never ownership
  • Sequential IDs make the whole table walkable
  • The same gap usually exists on update and delete
app.get('/api/invoices/:id', requireAuth, async (req, res) => { const invoice = await db.invoice.findUnique({ where: { id: Number(req.params.id) } // whose invoice? nobody asked }); res.json(invoice); });

Human-reviewed

  • Ownership is part of the query, so a wrong ID returns nothing
  • Returns 404 rather than 403 — no confirmation the record exists
  • Unguessable public IDs as defence in depth
app.get('/api/invoices/:id', requireAuth, async (req, res) => { const invoice = await db.invoice.findFirst({ where: { id: Number(req.params.id), userId: req.user.id // ownership is part of the query } }); if (!invoice) return res.status(404).json({ error: 'Not found' }); res.json(invoice); }); // Better still, don't expose sequential IDs at all: // id Int @id @default(autoincrement()) // internal // publicId String @unique @default(cuid()) // in URLs

§ 03 — Check your own

How to tell in two minutes

You don't need us to run these. If any of them come back the wrong way, you have this problem.

  1. Create two accounts. Sign in as the second, and request a record you created with the first. Getting it back is the bug.
  2. Grep for findUnique, findByPk, findById and where: { id. Each one needs an ownership condition beside it.
  3. Do not stop at GET. Update and delete endpoints have the same hole and cause more damage.
  4. Return 404, not 403 for records the user does not own — 403 confirms the record exists, which is useful to an attacker.

Found it in your project? Fixing this one properly usually takes an engineer under an hour. Finding the other nine takes longer — which is what the free health check is for.

Get my free health check

Find out where you actually stand.

Free. No obligation. Reviewed by experienced engineers.

Get my free health check