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/delete with a user_id parameter
  • 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/statements
Authorization: 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.csv

If 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 files

This 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:

  1. User requests a reset → server sends email with link /reset?token=abc123
  2. 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_id claims

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.1
Authorization: Bearer <Account_A_token>
# Modified request (still Account A's token, but Account B's ID):
GET /api/profile/1002 HTTP/1.1
Authorization: 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:

FormatExampleTool to decode
Base64dXNlcl8xMDI=Burp Decoder, CyberChef
URL encodinguser%5F102URL decode
UUID/GUID550e8400-e29b-41d4-a716-446655440000Still guessable — try UUID enumeration
Hashed IDsa94a8fe5...Hash the target ID and test
JWT payloadeyJ1c2VyX2lkIjoxMDJ9Decode 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 report
GET /reports/2026-Q1-bob.pdf ← try guessing another user

Check 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

ToolUse case
Burp Suite IntruderID enumeration, payload fuzzing
Autorize (Burp plugin)Automatic IDOR detection across sessions
Arachni / OWASP ZAPAutomated scanning with custom rules
ffufParameter 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:

ImpactExampleSeverity
Read other users’ public profile infoUsername, avatarLow
Read other users’ private dataEmail, phone, addressMedium–High
Read sensitive business dataInvoices, contracts, medical recordsHigh
Modify or delete other users’ dataChange email, delete postsHigh
Account takeoverReset another user’s passwordCritical
Mass data exfiltrationAll user records via ID enumerationCritical

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 only
order = Order.get(order_id)
# Right: fetches by ID AND verifies ownership
order = 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 map
session["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 function
def 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 fields
class 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 rejected

Never 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 == 403

If authorization regressions break this test in CI, they never reach production.


What You Can Do Today

If you’re a developer:

  1. Audit every API endpoint — for each one that returns or modifies data, verify there’s an ownership/role check in the code
  2. Add user_id to all database queries that fetch user-owned resources
  3. Implement centralized authorization middleware rather than inline checks
  4. Run Autorize against your staging environment with two test accounts

If you’re a security tester:

  1. Create two accounts in the target application at the start of every engagement
  2. Install the Autorize Burp plugin — it catches passive IDOR automatically
  3. Check every endpoint for GET, POST, PUT, PATCH, DELETE — not just GET
  4. Look for Base64-encoded IDs — decode them and swap values

If you’re a manager or CISO:

  1. Require authorization testing as part of every code review and security assessment
  2. Add IDOR test cases to your QA process — it’s cheap to catch in development and expensive in production
  3. Run periodic access control audits on your APIs, especially after major feature releases


Sources