All writing
4 min read

The ID in the URL: broken access control in vibe-coded apps

The second most common way an AI-built app leaks is quieter than an exploit: you change one number in the address bar and read someone else's record. This is IDOR — broken object-level authorization — with the attack, how to detect it, and the four fixes that close it, demonstrated end to end on Kali Linux.

Asghar Ali · Chief Technology Officer, Kakushin.io LTD

Talk · Asghar Ali

This is the second in a short series where I take a realistic, AI-built app, break it the way an attacker would, and then fix it — all against my own lab, nothing pointed at anyone else's systems. Day one was a file upload that became a full server takeover. Today is quieter, and in some ways worse, because it doesn't look like an attack at all. No upload. No payload. I change one number in the address bar and read a different company's private billing.

The flaw is called an insecure direct object reference — IDOR, also known as broken object-level authorization. It is near the top of every access-control checklist for one reason: it is the single mistake an AI scaffold makes most reliably, because the dangerous part is a check that isn't there, and you cannot see a missing line in a diff you never wrote.

A perfectly normal feature

The app is "Metrika," a tidy analytics SaaS — exactly the kind of thing you can describe to a model and have running in a weekend. I'm signed in as one workspace, Acme, and I open the Billing page. There are my invoices, my amounts, my card on file. I click one and it opens:

/invoice.php?id=1042

Nothing looks wrong. The invoice is mine, the page is mine, and the ID of the record I'm allowed to see is sitting right there in the URL. That last part is the whole story.

The question the model never asked

Here is what the generated endpoint does when that link is opened:

// vulnerable: looked up by id alone
$id  = (int) $_GET['id'];
$inv = $INVOICES[$id] ?? null;   // or: SELECT * FROM invoices WHERE id = ?
echo render($inv);

It takes the number from the URL, finds that invoice, and prints it. There is a login check somewhere upstream — you have to be signed in to reach the page at all. But being signed in is where the checking stops. Nothing asks the only question that matters here: is invoice 1042 actually yours?

So I change the number.

The leak

I take one off the end — ?id=1039 — and the same page comes back, same layout, but it isn't my company. It's Northwind Traders: a billing contact, their email address, the last four digits of the card on file, the amount. I was never given that invoice. I'm not in that workspace. The app handed it over without a flicker.

And it isn't one record. 1040, 1041 — every number is a different customer. A three-line script that counts upward could pull the entire billing table in about a minute. The object is the invoice; the reference is the ID in the URL; and it's insecure because nothing checks that the object belongs to the person asking for it.

This is why IDOR is so common in AI-built backends. When you ask a model for "an endpoint that shows an invoice," it writes exactly that: fetch by ID, return it. Ownership is an invariant of your business, not of the generic CRUD shape, so it doesn't appear unless you ask for it by name.

How you'd catch it

You don't need a fancy tool to detect this in something you already run. Look at your access logs:

GET /invoice.php?id=1042
GET /invoice.php?id=1039
GET /invoice.php?id=1040
GET /invoice.php?id=1041

One session, walking the IDs in a tight range. Real users don't behave like that — they open their own one or two invoices and leave. A single session fanning across a sequence of object IDs is almost always enumeration. If you log and alert on one behavior this week, make it that.

The four fixes

The patched build refuses the same request. Signed in as Acme, my own invoice still opens; ?id=1039 returns a flat 403 Forbidden — and the important part is that it decides that before it loads anything. The other workspace's record is never read at all.

// fixed: the record must belong to the caller
$id    = (int) $_GET['id'];
$inv   = $INVOICES[$id] ?? null;
$owned = $inv && $inv['ws'] === $currentWorkspace;   // scope to the owner
if (!$owned) { http_response_code(403); exit; }      // deny by default
echo render($inv);

Four principles close it for good:

  1. Scope every lookup to the owner. Don't fetch by ID and then decide — fetch by ID and owner: WHERE id = ? AND workspace_id = ?. If it isn't theirs, it isn't found.
  2. Check on the server, for every object, every time. Hiding the link in the UI is not a control; the attacker types the URL directly. The authorization check belongs on the server, on each request, for each resource.
  3. Use identifiers that can't be guessed. Sequential integers invite walking the range. Long random IDs (UUIDs) are defence in depth — they don't replace the ownership check, but they stop casual enumeration.
  4. Deny by default. If you can't prove the record belongs to the caller, return nothing. The safe path should be the one you get when a check is missing, not the leak.

The rule underneath all four is one sentence: being logged in is not the same as being allowed. Authentication is who you are; authorization is what you may touch. AI will reliably build the first and reliably skip the second.


I build these labs, break them, and fix them in the open every weekday. If you've shipped something AI-built and you'd like it checked properly — before someone else checks it for you — that is exactly the work I do. For the wider pattern, see the security risks nobody checks in vibe-coded apps.