IDOR is the bug that pays rent for a lot of bug bounty hunters, and it is still the one developers ship most often. No fancy payload, no encoder, no race condition. You log in as a normal user, you find a request that references an object by its ID, you change that ID to one you should not be able to see, and the server hands it over. That gap – authenticated but not authorized – is an Insecure Direct Object Reference (IDOR), and it maps directly to the number one entry in both the OWASP Top 10 (Broken Access Control) and the OWASP API Top 10 (Broken Object Level Authorization).
This post is a hands-on hunting playbook. Not the textbook one-liner – the actual workflow I use to find these on real targets: how to spot object references, how to set up two accounts, how to swap IDs at scale with Burp, and how to catch the ones that hide behind UUIDs, hashes, POST bodies, and GraphQL. There is a copy-paste checklist at the end you can take straight to your next target.

The short version (TL;DR)
- IDOR is a missing server-side authorization check on an object your request points at (an ID, a filename, a token, an account number).
- Find it by making two accounts, capturing a request that returns your data, then replaying it with the other account’s object ID.
- If the second account’s data comes back, you have an IDOR. If it does not, check whether you got a real 403 or just a client-side hide.
- The best single tool is Burp Suite with the Autorize extension – it replays every request with a low-privilege session automatically and flags what should have been blocked.
- Do not stop at numeric IDs in the URL. Check POST/PUT/DELETE bodies, JSON fields, headers, GraphQL node IDs, UUIDs, and base64/hashed references.
- The only real fix is an ownership check on every object read and write. Random IDs are not a fix – they slow enumeration, nothing more.
What IDOR actually is (and is not)
Every app has objects: invoices, messages, orders, profiles, files, support tickets. To fetch one, the client sends a reference the server uses to look it up. Something like GET /api/invoices/1042. The vulnerability appears when the server does this:
// Vulnerable: looks up the object, never checks who is asking
app.get('/api/invoices/:id', (req, res) => {
const invoice = db.findById(req.params.id);
res.json(invoice); // returns ANY invoice by id
});The server confirmed you are logged in (authentication), but never confirmed the invoice belongs to you (authorization). Change 1042 to 1043 and you are reading a stranger’s billing record. That is the entire bug.
Two flavors worth naming, because they change how you report severity:
- Horizontal IDOR – you access data belonging to another user at your same privilege level (peer to peer). Reading another customer’s invoice.
- Vertical IDOR – you reach objects that belong to a higher-privileged role, like an admin-only record. Often the same missing check, just a more valuable object.
What IDOR is not: a UI that hides a button. If the “edit” button is missing for you but the underlying PUT /api/users/1043 still works, that is IDOR. Client-side hiding is not access control.
Why IDOR is still everywhere in 2026
Broken Access Control has sat at the top of the OWASP Top 10 since 2021, and API1:2023 Broken Object Level Authorization leads the API list for the same reason: it cannot be caught by a scanner reliably and it cannot be fixed by a framework default. Authorization is business logic. A tool does not know that invoice 1043 should not belong to you – only your app does, and only if a developer wrote the check.
Modern architecture makes it worse, not better. Single-page apps and mobile clients expose fine-grained REST and GraphQL endpoints that used to live behind server-rendered pages. Every one of those endpoints is a place someone forgot an ownership check. That is why IDOR remains one of the most reported and best-paying classes on HackerOne and Bugcrowd year after year.

Where object references hide
Before you swap anything, you have to notice the reference. They show up in more places than the URL path:
| Location | Example |
|---|---|
| URL path segment | /api/invoices/1042 |
| Query string | ?user_id=1042&order=88 |
| POST / PUT JSON body | {"accountId": 1042, "role": "user"} |
| Form field / hidden input | <input name="doc_id" value="1042"> |
| HTTP header | X-Account-Id: 1042 |
| Cookie value | cart=1042 |
| GraphQL variables | {"id": "SW52b2ljZToxMDQy"} (base64 node ID) |
| Filename / path | /download?file=report-1042.pdf |
Anything that looks like it identifies a record is a candidate: numeric IDs, UUIDs, MongoDB ObjectIds, base64 blobs, short hashes, usernames, email addresses, phone numbers, filenames. If you can predict it or leak it from another endpoint, it is testable.
The practical hunting playbook
This is the order I actually work in. Do not skip the two-account setup – it is what turns “I think this is an IDOR” into a clean, reproducible report.
Step 1: Create two accounts and map the app
Register Account A (attacker) and Account B (victim). Put real, unique, searchable data in Account B – a fake company name, a distinctive note, a phone number like 555-0143. You want a value you can grep for later to prove you leaked the right person’s data.
Browse the whole app as Account B with Burp running, so its object IDs land in your history. Note them down: B’s user ID, an order, a message thread, an uploaded file. These are your targets.
Step 2: Establish the baseline as the attacker
Now log in as Account A. Find the request that returns your own object, for example GET /api/invoices/1042. Send it to Burp Repeater. Confirm a clean 200 with your data. This is your control – you need to know exactly what “authorized” looks like before you test “unauthorized.”
Step 3: Swap the ID by hand first
Still authenticated as Account A, change the object ID to Account B’s ID (or just try neighboring numbers – 1043, 1041, 1000). Send it. Read the response carefully:
200with B’s data – confirmed IDOR. Grab a screenshot and the unique value you planted.403/401– authorization is working here. Move on, but retest with tricks in Step 6.404– could be a real “not found” or a disguised “not yours.” Compare it to a genuinely nonexistent ID like99999999. Different responses can leak object existence (a weaker but still reportable bug).302to a login or dashboard – often a client-side redirect while the JSON still leaks. Check the raw body.
# Baseline (your own object) - returns 200 with your data
GET /api/invoices/1042 HTTP/1.1
Host: app.example.com
Cookie: session=<account-A-session>
# The test - same session, victim's object id
GET /api/invoices/1043 HTTP/1.1
Host: app.example.com
Cookie: session=<account-A-session>
# 200 + Jamie Rivera's invoice = IDORStep 4: Automate with Autorize (the real time-saver)
Manual swapping does not scale to a real app with hundreds of endpoints. The Autorize Burp extension (free, in the BApp Store) automates the whole idea. You give it Account B’s session cookie/header, then you browse the app normally as Account A. For every request you make, Autorize silently replays it with B’s low-privilege session and compares responses. It color-codes the result:
- Bypassed! (red) – the low-priv session got the same data. Likely IDOR, look here first.
- Enforced! (green) – properly blocked.
- Is enforced??? (yellow) – ambiguous, verify by hand.
Set an “Enforcement Detector” string (something that only appears when access is denied, like "Forbidden") so Autorize judges accurately. The Auth Analyzer extension does the same job with support for multiple sessions at once, which is handy for horizontal plus vertical testing in one pass. This one workflow finds the majority of IDORs on a modern SPA.
Step 5: Enumerate at scale with Intruder / ffuf
When IDs are numeric and sequential, sweep a range and diff the responses by length or status. Burp Intruder works, or ffuf if you want it scriptable:
# Sweep invoice IDs 1000-1100 with your own session, flag 200s
ffuf -u 'https://app.example.com/api/invoices/FUZZ' \
-H 'Cookie: session=<account-A-session>' \
-w <(seq 1000 1100) \
-mc 200 -of csv -o idor-sweep.csv
# Group by response size to spot records that are not yours
sort -t, -k5 -n idor-sweep.csv | uniq -c -f4Keep the rate sane and stay inside scope – enumerating live customer records is real impact, so many programs want a small proof, not a full dump. Pull two or three records to prove it and stop.
Step 6: Break the “it is protected” cases
Got a 403? Do not give up. The check is often shallow and only guards one shape of the request. Try:
- Change the HTTP method.
GETis blocked butPOST,PUT, orDELETEon the same path is not. - Wrap the ID in an array or object.
id=1043blocked, butid[]=1043or{"id":{"value":1043}}slips past a naive parser. - Send two IDs.
?id=1042&id=1043– some backends authorize on the first and serve the last (HTTP parameter pollution). - Add the ID where it is not expected. Move it from the body to the query string, or add a
X-Account-Id/X-User-Idheader. - Path tricks.
/api/invoices/1043blocked, try/api/invoices/1043/,/api/invoices/1043.json,/api/../invoices/1043, or a different API version/v1/vs/v2/. - Swap content type. JSON blocked, retry as form-encoded or XML if the endpoint accepts it.
- Wildcards and nulls. Some ORMs treat
id=*or a null as “match all.”
Step 7: Non-numeric and “unguessable” IDs
Random UUIDs feel safe, but they are frequently leaked somewhere else in the app – in a list endpoint, a search result, a shared link, an email, an autocomplete, a websocket message, or a Location header on create. The workflow becomes: find where the app hands you someone else’s identifier, then feed it to the vulnerable endpoint. A GUID is not a permission.
- Base64 / hex references – decode them. A GraphQL global ID like
SW52b2ljZToxMDQydecodes toInvoice:1042. Now you are back to enumerating a number. - Hashed IDs – if it is an MD5/CRC of a small integer, it is enumerable. Hash
1000..2000and match. - Predictable UUIDs – UUIDv1 encodes a timestamp and MAC; sequential creates can be narrowed.
- Leaky sibling endpoints –
/api/search?q=or/api/users?page=2that returns other users’ IDs is your enumeration source.
Step 8: GraphQL, mobile, and blind IDOR
GraphQL is an IDOR goldmine because a single endpoint exposes every object type. Query node(id: "...") or a typed query like invoice(id: 1043) with your own token and see what comes back. Introspection (if enabled) hands you the whole schema of things to try.
Mobile apps talk to the same APIs but often with fewer client-side guards, so proxy the app through Burp and hunt the raw traffic. And watch for blind IDOR: the response says 200 OK with no data, but the action still happened – you changed another user’s email, cancelled their order, or triggered a notification to them. No data leak in the response does not mean no impact.
Copy-paste IDOR hunting checklist
Run this per feature that touches user data. Mark each one.
- Two accounts created (A attacker, B victim) with a unique searchable value in B.
- Every object reference mapped: path, query, body, headers, cookies, GraphQL, filenames.
- Baseline captured – your own object returns 200 in Repeater.
- Manual ID swap tested (neighbor IDs and B’s real IDs).
- Autorize / Auth Analyzer run across the whole browse for automatic coverage.
- Numeric ranges swept with Intruder/ffuf, responses diffed by size and status.
- 403/404 cases retried with method change, param pollution, arrays, extra headers, path tricks.
- Non-numeric IDs decoded (base64/hex) and leak sources found for UUIDs.
- GraphQL node/typed queries tested; mobile traffic proxied.
- Write actions tested for blind IDOR (POST/PUT/DELETE that succeed silently).
- Impact proven with 2-3 records max, then stopped – no full customer dump.
How to actually fix IDOR (for developers)
There is exactly one durable fix, and it is not “use random IDs.” It is a server-side authorization check on every single object access, tied to the current session:
// Safe: verify ownership before returning or mutating anything
app.get('/api/invoices/:id', (req, res) => {
const invoice = db.findById(req.params.id);
if (!invoice || invoice.userId !== req.session.userId) {
return res.status(403).json({ error: 'Forbidden' });
}
res.json(invoice);
});- Check on reads and writes. Do not protect
GETand forgetPUT/DELETE. - Centralize it. Middleware or a policy layer (a “can this user touch this object?” function) beats scattering checks in every handler.
- Prefer session-scoped lookups. Query
WHERE id = ? AND user_id = ?so a foreign ID simply returns nothing. - Random IDs are defense in depth, not a control. UUIDs raise the effort to enumerate; they never replace the authorization check.
- Return the same response for “not yours” and “not found” where possible, so you do not leak which objects exist.
- Add automated tests that assert Account A gets a 403 on Account B’s objects. This is the one class of bug you can regression-test cheaply.
FAQ
Can a scanner find IDOR automatically?
Not reliably. Scanners do not know your business rules – they cannot tell that invoice 1043 should be off-limits to you. Tools like Autorize and Auth Analyzer get you most of the way by comparing sessions, but a human still confirms impact. IDOR stays a manual-first bug class.
Do UUIDs prevent IDOR?
No. They make guessing harder, but IDs leak constantly through list endpoints, search, emails, and shared links. If the server still returns any object it is handed a valid ID for, it is vulnerable regardless of how random the ID looks.
What is the difference between IDOR and Broken Access Control?
IDOR is a specific type of broken access control – the one where the missing check is on an object reference. Broken Access Control is the broader OWASP category that also covers things like forced browsing to admin pages, privilege escalation, and missing function-level checks.
How much is an IDOR worth in bug bounty?
It depends entirely on the object. Reading a public-ish profile field might be low. Reading or modifying other users’ PII, invoices, messages, or admin records is commonly high to critical, and mass-enumerable PII IDOR on a large platform can pay very well. Report the concrete data you accessed and keep the proof small.
Is it safe to enumerate lots of records to prove impact?
Stay inside the program’s rules. Pulling a handful of records proves the bug; scraping an entire customer database can breach scope and privacy law even with a bounty in play. Two or three records plus a clear write-up is the professional move.
Bottom line
IDOR is simple to understand and stubborn to kill because it lives in business logic, not in a library you can patch. For hunters, that is good news – two accounts, Burp with Autorize, and a patient eye for object references will keep finding these long after the flashy vuln classes get automated away. For developers, the takeaway is one sentence: check ownership on every object, on every read and every write, server-side. Everything else is decoration.
Now take this checklist to a real, in-scope target and start looking for object references. Once you train your eye to spot a missing authorization check, you will see IDOR everywhere – in REST paths, POST bodies, GraphQL, and file downloads alike.