IDOR vulnerability: a local example with PUT and DELETE

An IDOR vulnerability (insecure direct object reference) lets a signed-in user act on another user's record by sending that record's ID. In a small local invoices API, a second user listed, edited and deleted the first user's invoices. Replacing numeric IDs with UUIDs did not stop the attack, because a list endpoint returned every ID. An ownership check on each method stopped all of it.

This is a scripted Python and curl experiment with downloadable source and responses. No AI agent or model ran the test.

What an IDOR vulnerability is

The OWASP IDOR Prevention Cheat Sheet defines IDOR as a flaw that arises when attackers can access or modify objects by manipulating identifiers in URLs or parameters. It lists three ingredients: an object, a reference to it (an ID, UUID, account number, token or slug), and a missing object-level authorization check.

CWE-639, Authorization Bypass Through User-Controlled Key, describes the same weakness: the authorization logic does not stop one user from reaching another user's data by changing the key that identifies it. OWASP's API list files it as API1:2023, Broken Object Level Authorization, and notes that object IDs can be sequential integers, UUIDs or strings.

Our earlier post on API authorization testing shows a cross-account read. This post adds enumeration, changes with PUT, removal with DELETE, and the effect of switching to UUIDs.

Scope of the fixture

The fixture is an invoices API with three synthetic invoices. Alice owns 1001 and 1002. Bob owns 1003. The policy is that each user may list, read, update and delete only their own invoices.

  • Target: short-lived HTTP servers on 127.0.0.1, started on free ports by the runner. The server has no option to bind elsewhere.
  • Methods: GET /invoices, GET /invoices/{id}, PUT /invoices/{id} and DELETE /invoices/{id}.
  • Identities: the public demo bearer values demo-alice-token and demo-bob-token.
  • Configurations: numeric or UUID IDs, each with the ownership check off or on. That gives four servers and 16 requests per server.

The vulnerable configurations omit the ownership check on purpose. In both of them the list endpoint returns every invoice without filtering by caller. For testing a real system, agree hosts, accounts, records, methods and stopping conditions with its owner first. The Acceptable Use Policy describes authorized use, and this article gives no permission to test any other system.

Run the downloadable fixture

Download fixture.py and verify.py into one directory. You need Python 3.9 or later (standard library only) and curl. Run:

python3 verify.py --output rerun

The output directory must be new. Every curl call bypasses proxies, has a one-second connect timeout and a three-second total timeout, and follows no redirects.

Our recorded command was python3 verify.py --output recorded-run. It printed {"checks": 64, "passed": true, "failures": [], "policy_violations_observed": 16} and exited with status 0. That line means every response matched what the fixture was built to produce, including the vulnerable ones. The 16 policy violations are the reproduced IDOR behavior. The run took place on 10 November 2026 with Python 3.15.2 and curl 8.7.1 on macOS ARM64. results.json holds the exact versions, timestamps, commands and source hashes.

Observed results for the IDOR example

Each server started fresh. Bob first listed invoices. He then requested Alice's invoices by ID, changed one with PUT, and deleted the other. Alice re-read both invoices afterwards to see what the server stored. The IDs Bob used came from his own list response whenever it contained them.

In the sequential configuration, Bob did not need the list at all. Numeric guesses for 1001 and 1002 returned 200 with Alice's invoices, and 1004 returned 404, which tells an attacker where the sequence ends. Bob's PUT set Alice's invoice to amount_cents 1 and status paid. His DELETE removed 1002, and Alice's next read of it returned 404.

Four controls ran in every configuration and behaved the same each time. Alice read her own invoice (200). Alice updated her own invoice (200). Bob deleted his own invoice (200), after which a read returned 404. An anonymous request returned 401. A server that rejected everything would pass the attack rows and fail these, so the controls show that the fix leaves legitimate use working.

Why UUIDs alone do not fix an IDOR vulnerability

OWASP's API1 prevention advice says to prefer random, unpredictable values as record IDs, and the cheat sheet adds that complex identifiers such as GUIDs can make valid values practically impossible to guess, but that access control checks are essential even then. In our run, Bob's numeric guesses against the UUID servers returned 404 for all three IDs, so blind enumeration stopped working.

The unfiltered list endpoint gave the IDs away. Bob's GET /invoices on the UUID server returned three invoices, including both of Alice's with their UUIDs. Bob then read, changed and deleted them. The attack needed one extra request. The same applies to any place an ID can surface to another user: a shared link, an email, a log line, an export or a support ticket.

A random ID makes a valid reference harder to find. The server still needs an ownership check to decide whether the caller may use it.

Verify the finding before you report it

A 200 from a PUT shows that the server accepted the request. It does not prove that another account's data changed. Confirm each write or delete by reading the object back as its owner. This is the exact command from the recorded sequential run, split across lines:

curl --noproxy '*' --silent --show-error --include \
  --http1.1 --connect-timeout 1 --max-time 3 --request PUT \
  --header 'Authorization: Bearer demo-bob-token' \
  --header 'Content-Type: application/json' \
  --data '{"amount_cents": 1, "status": "paid"}' \
  http://127.0.0.1:52911/invoices/1001

The recorded response was 200 with "owner": "alice" and "amount_cents": 1. Alice's follow-up GET returned the same changed values, which shows that the write persisted under the real owner's account. For DELETE, the matching evidence is Alice's 404 on an invoice that the policy says she still owns.

A report should name the method and path, the acting identity, the object owner, the policy that was expected, the observed status and the changed or disclosed fields. It should also record the controls that passed. The impact here is limited to synthetic invoices. This experiment cannot rate severity for a real system.

The fix: one ownership check on every method

All three object methods in the fixture call one function. In the fixed configurations it compares the invoice's owner with the authenticated identity before any read, update or delete happens:

def fetch(self, identity, invoice_id):
    invoice = self.server.invoices.get(invoice_id)
    if invoice is None:
        self.reply(404, {"error": "not_found"})
        return None
    if self.server.authz == "owner" and invoice["owner"] != identity:
        self.reply(403, {"error": "forbidden"})
        return None
    return invoice

The list endpoint needs the same rule, applied as a filter:

rows = [i for i in self.server.invoices.values()
        if self.server.authz != "owner" or i["owner"] == identity]

Because every method goes through one function, PUT and DELETE get the same check as GET. A check added only to GET would leave them open. The check also has to run on the server for every request, whatever the client UI shows.

The retest used the same requests. With the check on, Bob's list returned one invoice, his GET, PUT and DELETE on Alice's invoices returned 403, and Alice's follow-up reads showed the original amount, 12500, and her second invoice intact. This held with numeric IDs and with UUIDs. Numeric guesses for Alice's IDs returned 403 on the sequential server and 404 on the UUID server. All four controls still passed. This check works for a model where each invoice has one owner. Shared invoices, roles and tenant hierarchies need a richer policy, as in tenant isolation.

Evidence bundle

All tokens, names and invoices are synthetic. The bundle contains the full HTTP responses, including dates and server headers.

What this experiment does not cover

The fixture is a toy with one owner per object. It shows that a missing ownership check allows read, write and delete across accounts, and that UUIDs do not compensate for it. It does not show how common this is, how severe it is elsewhere, or that the fixed server is secure in any wider sense.

We did not test PATCH, bulk or batch endpoints, nested resources, IDs in request bodies or query strings, GraphQL, shared objects, role changes, caching, or races between a check and a write. Our UUID guesses were numeric strings. We did not try to guess real UUIDs. The 403 on an existing object also confirms that the object exists; some APIs return 404 for both cases to avoid that. For a broader view of the class, see broken access control and the OWASP API Top 10.

Excession Labs runs this kind of object-level test, with two accounts and every method, in its security assessments. See how a finding like this looks in a sample report. Download this finding written up as a one-page sample report (PDF).