You’re logged in as user #4821. You notice the URL in your browser reads /api/invoices/4821. Out of curiosity, you change the number to 4820. The server responds with someone else’s invoice — name, address, transaction history and all. You just found an IDOR.
TL;DR
- IDOR happens when an application uses user-controlled IDs to access objects without checking whether the requester is actually authorized to see them
- It affects APIs, web apps, mobile backends, and file downloads — anywhere a reference to a resource is exposed to the user
- The impact ranges from data leaks to full account takeover and mass data extraction
- Finding IDOR requires methodical testing: swap IDs, fuzz parameters, look for encoded references
- The fix is always the same: server-side authorization checks on every object access, no exceptions
Why IDOR Is Everywhere
IDOR — Insecure Direct Object Reference — consistently ranks among the most reported vulnerabilities in bug bounty programs. It’s also one of the most underestimated. The bug itself is simple: a developer builds a feature that fetches data using an ID, forgets to check whether the logged-in user owns that data, and ships it.
The result is a vulnerability that lets any attacker — not just skilled ones — read, modify, or delete other users’ data by simply guessing or incrementing an ID.
OWASP lists it under Broken Access Control (A01 in the OWASP Top 10), which is the most common web application security failure category. In real-world bug bounty programs, IDOR findings regularly earn payouts from $500 to $50,000+ depending on the scope of data exposed.
The Core Problem: Trusting the Client
Every web application manages data for multiple users. When user Alice asks for her order, the server needs to fetch order #7291. The naive implementation looks like this:
# Vulnerable: takes ID directly from the user's request@app.route("/api/orders/<int:order_id>")def get_order(order_id): order = db.query("SELECT * FROM orders WHERE id = ?", order_id) return jsonify(order)The server fetches order #7291 — but never checks whether the logged-in user actually owns it. Any authenticated user can fetch any order by guessing the ID.
The secure version adds one check:
# Secure: verifies ownership before returning data@app.route("/api/orders/<int:order_id>")def get_order(order_id): order = db.query( "SELECT * FROM orders WHERE id = ? AND user_id = ?", order_id, current_user.id # enforce ownership ) if not order: return abort(403) return jsonify(order)One AND user_id = ? clause separates a secure application from a vulnerable one.
Types of IDOR
Not all IDOR vulnerabilities look the same. Understanding the types helps during testing.
Horizontal Privilege Escalation
The most common type. Two users have the same role (e.g., both regular users), but one can access the other’s data.
- User A accesses
/profile/1001— their own profile - User A modifies the ID to
/profile/1002— and reads User B’s private data
Impact: Data theft, privacy violations, account information exposure.
Vertical Privilege Escalation
A lower-privileged user accesses resources that belong to a higher-privileged role — an admin panel, restricted reports, or management functions.
- Regular user POSTs to
/api/admin/users/deletewith auser_idparameter - The server checks authentication (is user logged in?) but not authorization (is user an admin?)
- Deletion succeeds
Impact: Privilege escalation, full account takeover, data destruction.
BOLA — Broken Object Level Authorization
BOLA is the API-specific name for IDOR, formalized by OWASP in its API Security Top 10. The term is more common in API security contexts, but the vulnerability is identical.
In REST APIs, BOLA is endemic because:
- Resources are explicitly identified by IDs in URLs or request bodies
- APIs often skip authorization because they assume API keys or JWTs are sufficient
- Mobile app backends frequently expose IDs that clients wouldn’t normally see
Example:
GET /api/v1/accounts/8842/statementsAuthorization: Bearer eyJhbGci...The JWT proves the user is authenticated. It says nothing about whether they own account 8842.
Mass Assignment IDOR
A subtler variant. The server accepts a JSON object from the client and directly maps its fields to a database model — including fields the user shouldn’t be allowed to set.
PUT /api/profile{ "name": "Alice", "email": "alice@example.com", "role": "admin" ← should not be writable by the user}If the server blindly updates all fields from the request, Alice just made herself an admin. This is sometimes called mass assignment or auto-binding, and it’s distinct from classic IDOR but shares the same root cause: insufficient server-side access control.
Indirect Reference IDOR
Instead of a sequential integer, the reference is something that looks more opaque: a filename, a hash, or a URL slug.
GET /downloads/report_2026_Q1_alice_export.csvIf the filename is guessable or follows a pattern, an attacker can enumerate other users’ files.
GET /downloads/report_2026_Q1_bob_export.csv ← works if no auth check on static filesThis is easy to overlook because developers assume “the path isn’t shown to users” — but the path is always visible in network traffic.
IDOR in the Wild: Real Scenarios
Scenario 1: The Invoice Leak
An invoicing SaaS application exposes invoices at /api/invoices/{id}. IDs are sequential integers. An authenticated user discovers that by changing their own invoice ID by ±1, they can retrieve any other customer’s invoice, including billing address, company name, and VAT numbers.
Impact: Full customer database accessible to any registered user.
Scenario 2: The Password Reset Takeover
A mobile app’s password reset flow works like this:
- User requests a reset → server sends email with link
/reset?token=abc123 - User visits the link → app generates a new session
The developer also added a convenience API:
POST /api/users/reset-password{ "user_id": 1042, "new_password": "hunter2" }No ownership check. Any authenticated user can reset any other user’s password by supplying their user_id.
Impact: Full account takeover for any user in the system.
Scenario 3: The Chat Message Leak
A messaging app exposes individual messages at /api/messages/{message_id}. The IDs are sequential. An attacker authenticated as a low-privilege user iterates over all IDs and retrieves private messages from other conversations — including conversations between company executives.
Impact: Mass exfiltration of private communications.
Finding IDOR: Testing Methodology
Step 1: Map the Attack Surface
Look for any place where a user-controlled identifier references a resource:
- URL path segments:
/users/1234,/orders/9991 - Query parameters:
?file=invoice_1234.pdf,?account_id=88 - POST/PUT body fields:
{ "user_id": 5, "target": 9 } - Hidden form fields:
<input type="hidden" name="user_id" value="42"> - Cookie values that look like IDs
- JSON Web Tokens with embedded
user_idclaims
A web proxy like Burp Suite makes this easy — browse the application normally and review every request captured.
Step 2: Create Two Accounts
IDOR testing requires two separate authenticated sessions. Create two accounts and note the IDs assigned to each one.
- Account A (your primary test account)
- Account B (the victim account)
With Burp Suite, use two browser profiles or Burp’s multi-session support to maintain both sessions simultaneously.
Step 3: Swap IDs Across Sessions
The core test: perform an action as Account A, capture the request, then replace Account A’s resource ID with Account B’s resource ID.
# Original request (as Account A):GET /api/profile/1001 HTTP/1.1Authorization: Bearer <Account_A_token>
# Modified request (still Account A's token, but Account B's ID):GET /api/profile/1002 HTTP/1.1Authorization: Bearer <Account_A_token>If the response contains Account B’s data — it’s IDOR.
Step 4: Test All HTTP Methods
Don’t test just GET. IDOR vulnerabilities exist in POST, PUT, PATCH, DELETE too — and write-access IDOR is far more severe.
DELETE /api/posts/5512 ← can Account A delete Account B's post?PUT /api/settings/9901 ← can Account A modify Account B's settings?Step 5: Look for Encoded IDs
Many applications encode IDs to make them appear opaque. Common encodings:
| Format | Example | Tool to decode |
|---|---|---|
| Base64 | dXNlcl8xMDI= | Burp Decoder, CyberChef |
| URL encoding | user%5F102 | URL decode |
| UUID/GUID | 550e8400-e29b-41d4-a716-446655440000 | Still guessable — try UUID enumeration |
| Hashed IDs | a94a8fe5... | Hash the target ID and test |
| JWT payload | eyJ1c2VyX2lkIjoxMDJ9 | Decode and modify |
Base64-encoded IDs are trivially reversible. GUIDs look random but may be predictable if generated with weak entropy or sequential timestamps (UUIDv1).
Step 6: Test Indirect References
If the application uses filenames, slugs, or other human-readable identifiers:
GET /reports/2026-Q1-alice.pdf ← your reportGET /reports/2026-Q1-bob.pdf ← try guessing another userCheck the response — does the server validate ownership before serving the file?
Step 7: Test for Mass Assignment
When submitting profile updates or object creation requests, add extra fields that shouldn’t be user-modifiable:
{ "name": "Alice", "email": "alice@example.com", "role": "admin", ← added by tester "is_verified": true, ← added by tester "plan": "enterprise" ← added by tester}Check if the server accepts and applies the extra fields. You can verify by fetching the object again and comparing the values.
Automated Tools
| Tool | Use case |
|---|---|
| Burp Suite Intruder | ID enumeration, payload fuzzing |
| Autorize (Burp plugin) | Automatic IDOR detection across sessions |
| Arachni / OWASP ZAP | Automated scanning with custom rules |
| ffuf | Parameter fuzzing, ID brute-forcing |
Autorize deserves special mention — it intercepts all requests from Account A and automatically replays them with Account B’s session cookie, flagging any responses where data is returned that shouldn’t be.
Bypass Techniques
Applications sometimes attempt to add protections that don’t actually work. These can be bypassed.
Bypassing Sequential ID Checks
Some applications validate that the requested ID is “close” to the user’s own ID (a misguided attempt at rate limiting IDOR). Simply try IDs that fall outside the expected sequential range — very old accounts, admin accounts (which often have low IDs like 1, 2, 3).
Bypassing GUID Checks
If you can find a GUID for another user anywhere in the application (in a shared resource, URL parameter, email headers, API response), use it directly. GUIDs do not prevent IDOR — they just make enumeration harder.
Bypassing Authorization via HTTP Method Switching
Some authorization middleware is method-specific. An endpoint may correctly deny GET /api/admin/users/42 but accept POST /api/admin/users/42 with the same payload.
Bypassing Authorization via Nested Resources
Authorization checks on parent objects don’t always propagate to child objects.
GET /api/projects/99/tasks/12 ← access denied (no access to project 99)GET /api/tasks/12 ← access granted (task endpoint lacks the project check)Always test both the nested and flat endpoint paths for the same resource.
JSON Parameter Pollution
Submit the same parameter multiple times in a JSON body — some parsers take the last value, others the first:
{ "user_id": 1001, "user_id": 1002 }Impact Assessment
IDOR findings range from informational to critical depending on what can be accessed:
| Impact | Example | Severity |
|---|---|---|
| Read other users’ public profile info | Username, avatar | Low |
| Read other users’ private data | Email, phone, address | Medium–High |
| Read sensitive business data | Invoices, contracts, medical records | High |
| Modify or delete other users’ data | Change email, delete posts | High |
| Account takeover | Reset another user’s password | Critical |
| Mass data exfiltration | All user records via ID enumeration | Critical |
Fixing IDOR: Developer Guidance
Rule 1: Always Authorize, Never Assume
Every request that returns or modifies data must verify that the authenticated user has permission to access that specific object.
# Wrong: fetches by ID onlyorder = Order.get(order_id)
# Right: fetches by ID AND verifies ownershiporder = Order.get(order_id, user_id=current_user.id)if not order: raise PermissionDenied()This applies to every read, update, and delete operation — even if the endpoint is behind authentication.
Rule 2: Use Indirect Reference Maps
Instead of exposing raw database IDs, map them to session-specific tokens.
# On login, create a session-specific reference mapsession["order_refs"] = { "ref_a1b2c3": 7291, # maps opaque token to real DB id "ref_x9y8z7": 7305,}
# In the endpoint, resolve the reference@app.route("/api/orders/<ref>")def get_order(ref): order_id = session["order_refs"].get(ref) if not order_id: return abort(403) return jsonify(Order.get(order_id))The client never sees the real ID. This makes enumeration impossible.
Rule 3: Centralize Authorization Logic
Don’t scatter if user.id == resource.owner_id checks across 50 endpoints. Build a single authorization layer:
# Central authorization functiondef authorize(user, action, resource): if action == "read" and resource.owner_id != user.id: raise PermissionDenied() if action == "delete" and not user.is_admin: raise PermissionDenied() return True
# In every endpoint@app.route("/api/orders/<int:order_id>", methods=["DELETE"])def delete_order(order_id): order = Order.get(order_id) authorize(current_user, "delete", order) # one call, enforced everywhere order.delete()Centralized authorization is auditable and harder to accidentally skip.
Rule 4: Blocklist for Mass Assignment
Explicitly define which fields are writable:
# Python/Flask with marshmallow — only allow specific fieldsclass ProfileUpdateSchema(Schema): name = fields.Str() email = fields.Email() # role is NOT in the schema — cannot be set by user
schema = ProfileUpdateSchema()data = schema.load(request.json) # unknown fields are rejectedNever use **request.json directly to update a database model.
Rule 5: Test with Automated Authorization Checks
Add IDOR test cases to your integration test suite:
def test_cannot_read_other_users_order(): # Create order as user A order = create_order(user=user_a)
# Try to read it as user B response = client.get(f"/api/orders/{order.id}", auth=user_b)
assert response.status_code == 403If authorization regressions break this test in CI, they never reach production.
What You Can Do Today
If you’re a developer:
- Audit every API endpoint — for each one that returns or modifies data, verify there’s an ownership/role check in the code
- Add
user_idto all database queries that fetch user-owned resources - Implement centralized authorization middleware rather than inline checks
- Run Autorize against your staging environment with two test accounts
If you’re a security tester:
- Create two accounts in the target application at the start of every engagement
- Install the Autorize Burp plugin — it catches passive IDOR automatically
- Check every endpoint for GET, POST, PUT, PATCH, DELETE — not just GET
- Look for Base64-encoded IDs — decode them and swap values
If you’re a manager or CISO:
- Require authorization testing as part of every code review and security assessment
- Add IDOR test cases to your QA process — it’s cheap to catch in development and expensive in production
- Run periodic access control audits on your APIs, especially after major feature releases
Related Posts
- XSS Explained: How Attackers Inject Code Into Your Browser — sister vulnerability in the web attack cluster; often chained with IDOR for full account takeover
- SSRF Explained: How Attackers Make Servers Fetch Secrets for Them — another server-side access control failure, frequently found alongside IDOR
- API Security in 2026: JWT Attacks, OAuth Abuse, and GraphQL Exploitation — BOLA/IDOR is the #1 API vulnerability; full API attack surface coverage
- Web Application Penetration Testing 2026: Beyond OWASP Top 10 — methodology hub for all web security testing