INTAKE
Most vulnerabilities are about tricking a server into doing something it didn't mean to do — running your code, parsing your payload, trusting your input. Access control bugs are different. They're about the server doing exactly what you asked, because nobody told it not to.
You log in as yourself. You request /account?id=1337. The server hands you account 1337's data — never checking whether 1337 is actually you. That's it. That's the bug that pays more bug bounty money than any clever exploit chain ever will. It requires no payload, no encoding tricks, no race window. Just the willingness to change a number and look at what comes back.
MENTAL MODEL
Picture a hotel. Authentication is the front desk checking your ID and confirming you're a guest — you are who you say you are. Authorization is the key card only opening your room, not every room on the floor.
A huge number of websites do the first part well and forget the second entirely. They check that you're logged in — front desk verified your ID — and then hand you a master key that opens every door, trusting you to only open your own. Access control bugs are what happens when you try the other doors.
The reason this bug is everywhere comes down to how applications get built. Authentication is a solved problem: you drop in a login library, it works, you move on. Authorization is per-feature, per-object, forever. Every single endpoint that touches user data has to independently ask "is this specific user allowed to touch this specific object?" — and that question has to be asked again every time a developer adds a new feature, for the entire life of the application. Miss it on one endpoint out of four hundred, and that's the bug.
Here's the mental shift that turns you into someone who finds these: stop thinking about what the UI lets you do, and start thinking about what the server lets you ask. The website only shows you a button to view your invoice. But the server endpoint behind that button is GET /api/invoice/8842. Nothing about that endpoint inherently knows that 8842 is yours. The UI is a suggestion. The endpoint is the truth. Bug hunters live at the endpoint.
There are three shapes this takes, and naming them helps you hunt:
- Horizontal — you access another user's data at your own privilege level. You're a regular user reading another regular user's messages.
- Vertical — you access functionality above your privilege level. You're a regular user calling an admin-only endpoint.
- Context-dependent — you do the right action at the wrong time. Editing an order after it's been finalized, voting twice, applying a coupon that should be single-use.
IDOR — Insecure Direct Object Reference — is the most famous member of this family. It's just horizontal or vertical access control failure where the "direct object reference" is something you can guess or enumerate, like a sequential ID.
ANATOMY
Here's the bug in the smallest amount of code that can contain it. Node/Express, but the shape is identical in every framework.
app.get("/api/account/:id", requireLogin, (req, res) => {
const account = db.accounts.findById(req.params.id);
res.json(account);
});Read it carefully. There's a requireLogin middleware — so authentication is handled. You must be logged in to hit this endpoint. The developer feels safe.
But look at what's missing. The handler takes :id from the URL and looks up that account. It never checks whether :id belongs to the logged-in user. Any authenticated user can request any account ID and get it back. Log in as account 1001, request /api/account/1002, and you're reading someone else's data.
The fix is one line — confirm the object belongs to the requester:
app.get("/api/account/:id", requireLogin, (req, res) => {
const account = db.accounts.findById(req.params.id);
if (account.ownerId !== req.user.id) {
return res.status(403).json({ error: "forbidden" });
}
res.json(account);
});That if is the entire difference between secure and breached. And here's the uncomfortable truth: in a real application with four hundred endpoints, that check has to be correct on all four hundred. The attacker only needs the one where a developer was in a hurry.
VARIANTS
Same root cause, different disguises. These are the patterns you'll actually encounter.
Classic IDOR — the sequential ID
The textbook case. Object references are sequential integers.
GET /api/invoice/8842 → your invoice
GET /api/invoice/8841 → someone else's invoice
GET /api/invoice/8843 → someone else's invoice
If the numbers go up by one and the data isn't yours, you've found it. The fix developers reach for — switching to UUIDs — makes enumeration harder but doesn't fix the bug. A leaked UUID is still accessible if there's no ownership check. UUIDs are obscurity, not access control.
IDOR in the request body or as a parameter
The reference isn't always in the URL. Check POST bodies, JSON fields, hidden form inputs.
POST /api/update-profile
{ "userId": 1002, "email": "attacker@evil.com" }
Change userId to a victim's, and if there's no check, you just changed their email — which usually means you can trigger a password reset to your inbox.
Vertical escalation — the hidden admin endpoint
The UI never shows you admin functions because you're not an admin. But the endpoints exist.
GET /admin/users → 403, as expected
POST /api/users/promote → does it check if YOU are admin?
Find admin endpoints by reading the site's JavaScript (the front-end code often references API routes for roles you don't have), checking /robots.txt, /sitemap.xml, and guessing common paths.
Forced browsing & parameter pollution
Sometimes the server decides your role from something you control.
GET /account?admin=false → try admin=true
Cookie: role=user → try role=admin
POST with {"isAdmin": false} → try true
If the server trusts a client-supplied role flag, that's vertical access control failure of the most embarrassing kind. It still happens constantly.
Multi-step process flaws (context-dependent)
A checkout flow validates your privileges on step 1, then trusts you through steps 2–4. Skip to step 4 directly, or replay step 2 with modified values, and the validation never re-runs. Coupon stacking, price tampering, and "buy now, validate never" bugs live here.
DETECTION
The methodology is almost insultingly simple, which is exactly why it works — most people skip it because it doesn't feel like hacking.
The core loop: two accounts, one request. Create two accounts (most bug bounty programs let you). Log in as User A, capture a request that returns A's data. Now replace the object reference with B's, while keeping A's session. If you get B's data, you have IDOR.
Burp Suite makes this systematic. The two tools that matter:
- Autorize (extension) — log in as a low-priv user, set it to replay every request you make with a high-priv user's session removed or swapped. It flags any request that returns the same data, meaning no access control was enforced. This is the single highest-ROI bug bounty tool for this class.
- Repeater — for manually swapping references one at a time and reading responses carefully. Don't just check the status code; a 200 with someone else's data is the win, but a 302 redirect or a 200 with a subtly different body can also signal a partial flaw.
A detail that catches beginners: a 200 response doesn't mean success and a 403 doesn't mean failure. Some apps return 200 with an empty object when access is denied. Some return 403 but include the sensitive data in the error body anyway (yes, really). Read the actual response content every time.
EXPLOITATION
Finding IDOR is step one. Turning it into something a triager will actually pay for is the skill that separates a $50 report from a $5,000 one. Escalation is the game.
From read to account takeover
A read-only IDOR on a profile endpoint feels minor — "I can see someone's email, so what?" Chain it:
- IDOR reveals victim's email and account ID.
- The same IDOR (or a sibling endpoint) lets you write to the profile.
- Change the victim's email to one you control.
- Trigger "forgot password." Reset link arrives in your inbox.
- Full account takeover.
The lesson: always test whether a read IDOR has a write counterpart. GET /api/user/1002 that leaks data often has a PUT /api/user/1002 or POST /api/user/update that accepts it.
From user to admin
Vertical escalation's biggest prize. You found that POST /api/users/promote doesn't check the caller's role:
$ curl -X POST https://target.example.com/api/users/promote -H "Cookie: session=YOUR_OWN_LOW_PRIV_SESSION" -H "Content-Type: application/json" -d '{"userId": YOUR_OWN_ID, "role": "admin"}'{"status":"ok","userId":...,"role":"admin"}
You just promoted yourself. Refresh the app and the admin panel is yours. This is the highest-severity outcome in the class and triagers pay accordingly.
Mass enumeration → data breach
When the object reference is a sequential ID and there's no rate limiting, a single IDOR becomes a full-database extraction. Burp Intruder over /api/invoice/1 through /api/invoice/100000 dumps every invoice in the system. This is the part to be careful with — see the ethics note below. In a report, you demonstrate the bug on two or three accounts (your own second account plus, with permission, a controlled test account), state that it's enumerable, and let the severity speak for itself. You do not actually exfiltrate a hundred thousand real users' records.
IDOR on UUIDs and "unguessable" references
When IDs are UUIDs, enumeration is off the table — but the bug may still be live. UUIDs leak constantly: in URLs shared between users, in email notifications, in API responses for other endpoints, in referrer headers, in support tickets. If you can obtain a victim's UUID through any side channel and then access their data with it, that's still a valid IDOR. The fix was never "make IDs unguessable" — it was always "check ownership."
DEFENSES
The fixes are conceptually simple and operationally relentless.
Deny by default, check ownership on every object access. Every endpoint that returns or modifies an object must verify the current user is authorized for that specific object. Not "is logged in" — authorized for this object. This check cannot be optional, cannot be skippable, and cannot live only in the UI.
Don't trust anything the client controls to make authorization decisions. Not URL parameters, not body fields, not cookies, not headers. The user's identity comes from the server-side session or a verified token — never from a userId field in the request the user could simply change.
Centralize the access-control logic. The reason IDOR is everywhere is that the check is duplicated across hundreds of handlers and one inevitably gets missed. Push authorization into a single layer — middleware, a policy engine, row-level security in the database — so individual feature code can't forget it. A pattern like "every query is automatically scoped to the current user's tenant at the ORM level" eliminates whole categories of this bug.
Use unpredictable references AND ownership checks — not one or the other. UUIDs make enumeration impractical, which raises the bar. But they are defense in depth, layered on top of a real ownership check, never a replacement for it.
Log and rate-limit access to objects. Even with checks in place, sudden sequential access to thousands of objects is a signal. Rate limiting blunts mass-enumeration; logging gives you the trail when something slips through.
Things that don't work: hiding endpoints from the UI (the endpoint still exists), switching IDs to UUIDs alone (leaks happen), client-side role checks (trivially bypassed), obscure parameter names (security through obscurity). Every one of these has shipped to production at a major company and been bypassed in an afternoon.
LAB GAUNTLET
PortSwigger's access control labs are the most beginner-friendly in the entire Academy, and they map directly to real bug bounty findings. Work them in order — the early ones build the instinct, the later ones build the escalation muscle.
The admin panel exists at a path the site never links to. The muscle to build is looking for what isn't shown — robots.txt, source comments, predictable paths. Your first taste of "the UI is a suggestion."
The admin URL is obscured, but the front-end JavaScript gives it away. This teaches you to read a site's own code for the routes it doesn't want you to see. Obscurity is not access control.
Your role is stored somewhere you can edit. The lesson is visceral: never trust a privilege flag the client can change. Once you've seen it here, you'll check for it everywhere.
The canonical IDOR. Change the id, get another user's data. This is the exact pattern behind a huge share of real bounties — internalize how simple it is.
The IDs are GUIDs, so you can't guess them — but they leak elsewhere in the app. This is the lab that proves "unguessable IDs" was never the fix. Find the leak, use the ID.
The app redirects you away, but the sensitive data is in the response body before the redirect fires. Teaches you to read the raw response, not trust the browser's behavior.
Access control enforced on POST but not on GET (or vice versa). Swap the HTTP method and the check vanishes. A great reminder that authorization must be method-agnostic.
The flow checks your privilege on step one and trusts you afterward. Jump straight to the unprotected step. This is the context-dependent variant — subtle, common, and easy to miss.
The app decides your access from the Referer header — which you fully control. Forge it, gain access. The final "never trust the client" lesson in the set.
WAR STORIES
Facebook page admin takeover (2019). A researcher found that the endpoint for assigning page roles didn't properly verify the caller's own permissions on the page. By sending a crafted request, an attacker could add themselves as an admin to any Facebook business page they didn't own. The bounty was reportedly in the high four figures, and the bug is a textbook vertical access control failure — the server trusted the request to define the relationship rather than checking it. Search for the public writeup; the request was almost embarrassingly simple.
The First American Financial breach (2019). Not a bug bounty — a real breach. First American, a major US title insurance company, exposed roughly 885 million records of sensitive financial documents through a classic IDOR. Document URLs contained sequential numbers; changing the number in the URL served someone else's mortgage paperwork, bank statements, and SSNs — with no authentication required at all. It went undetected for years. This is the breach to cite when someone says "IDOR is low severity." Verify the record count against the original Krebs on Security reporting before you quote it on camera.
Shopify, Uber, and the bug bounty long tail. If you read disclosed reports on HackerOne, broken access control is consistently the highest-volume and among the highest-paying categories. It also tops the OWASP Top 10 — it's been ranked #1 (A01) since the 2021 edition. The reason is exactly what this note opened with: it's not hard to find, it's just tedious to prevent on every endpoint forever. Confirm the OWASP A01 ranking yourself — OWASP updates the list periodically and you want to state the current edition correctly.
The thread tying these together: none of them required a clever exploit. They required someone changing a number and noticing the server didn't care who was asking.
FURTHER READING
- PortSwigger's Access Control topic — your lab platform and the cleanest theory writeup
- OWASP Top 10 — A01:2021 Broken Access Control — the authoritative ranking and category definition
- PortSwigger Authorize extension — the automated tool that makes this systematic
- HackerOne disclosed reports — filter for "IDOR" — read real, paid, public reports to calibrate what a good finding looks like
- PayloadsAllTheThings — Insecure Direct Object References — the testing-methodology checklist
END OF FILE
If you want the server-side complement to this, the SSTI note covers what happens when input becomes code rather than when access goes unchecked — the other major path to taking over an application. Next in the series: SQL injection, the bug that started it all.
Stuck on a lab or want to see the Burp Autorize workflow live? The channel has the walkthroughs.