AI Pentesting

What is an IDOR Vulnerability? Types, Examples, CVSS, and Detection Methods

Amartya | CodeAnt AI Code Review Platform
Sonali Sood

Founding GTM, CodeAnt AI

What is an IDOR Vulnerability?

In 2023, a researcher changed a single number in a URL, from /api/orders/10021 to /api/orders/10022, and received a complete order record belonging to another customer. Name, address, items purchased, payment method last four digits. That single change exposed an authorization failure: the application returned an object the requesting user did not own.

No exploit chain. No malware. Just a missing ownership check.

This is IDOR: Insecure Direct Object Reference.

IDOR is one of the most important broken-access-control vulnerabilities in modern web applications and APIs. In the OWASP API Security Top 10, the API-specific form is called BOLA, or Broken Object Level Authorization.

The underlying failure is simple: the application verifies that the user is authenticated, but fails to verify that the user is authorized to access the specific object identified by the request.

What is an IDOR vulnerability?

An IDOR (Insecure Direct Object Reference) vulnerability occurs when an application uses a user-supplied identifier to access an object without verifying that the authenticated user is authorized to access that specific object.

For example, if changing /api/orders/10021 to /api/orders/10022 returns another customer's order, the application has an IDOR vulnerability. In API security, this same class of flaw is commonly called BOLA (Broken Object Level Authorization).

It is the most consistently present vulnerability in modern web applications, API1:2023 in the OWASP API Security Top 10, and the vulnerability class that causes more actual data breaches than SQL injection, XSS, and RCE combined in the SaaS context. Not because it's more sophisticated, but because it's invisible to the tooling most teams rely on and easy to introduce at any point in the software development lifecycle.

This guide covers every dimension of IDOR:

  • what it is at the code level

  • every variant from horizontal access to second-order to multi-tenant

  • the CVSS scoring that actually reflects impact

  • why DAST and static analysis structurally cannot find most IDOR vulnerabilities

  • the reachability analysis that separates exploitable findings from noise, and the specific code patterns that eliminate it

Part 1: What IDOR Actually Is (And What It Isn't)

The Precise Definition

An IDOR vulnerability exists when:

  1. An application accepts a user-supplied identifier referencing an internal object

  2. The application uses that identifier to access the object

  3. The application does not verify that the requesting user is authorized to access that specific object

The third step is where the vulnerability lives. Not in the existence of the identifier. Not in the exposure of the identifier. In the missing ownership verification between "this identifier was supplied" and "this object was returned."

# The canonical IDOR pattern in HTTP:

# Legitimate request user 42 accessing their own invoice:
GET /api/v1/invoices/INV-10021 HTTP/1.1
Host: api.company.com
Authorization: Bearer eyJ... [token for user 42]

HTTP/1.1 200 OK
{
  "id": "INV-10021",
  "user_id": 42,
  "amount": 299.99,
  "items": [...]
}

# IDOR attack user 42 accessing user 99's invoice:
GET /api/v1/invoices/INV-10022 HTTP/1.1
Host: api.company.com
Authorization: Bearer eyJ... [token for user 42, unchanged]

HTTP/1.1 200 OK           should be 403
{
  "id": "INV-10022",
  "user_id": 99,           different user
  "amount": 1499.00,
  "items": [...]           unauthorized disclosure
}
# The canonical IDOR pattern in HTTP:

# Legitimate request user 42 accessing their own invoice:
GET /api/v1/invoices/INV-10021 HTTP/1.1
Host: api.company.com
Authorization: Bearer eyJ... [token for user 42]

HTTP/1.1 200 OK
{
  "id": "INV-10021",
  "user_id": 42,
  "amount": 299.99,
  "items": [...]
}

# IDOR attack user 42 accessing user 99's invoice:
GET /api/v1/invoices/INV-10022 HTTP/1.1
Host: api.company.com
Authorization: Bearer eyJ... [token for user 42, unchanged]

HTTP/1.1 200 OK           should be 403
{
  "id": "INV-10022",
  "user_id": 99,           different user
  "amount": 1499.00,
  "items": [...]           unauthorized disclosure
}
# The canonical IDOR pattern in HTTP:

# Legitimate request user 42 accessing their own invoice:
GET /api/v1/invoices/INV-10021 HTTP/1.1
Host: api.company.com
Authorization: Bearer eyJ... [token for user 42]

HTTP/1.1 200 OK
{
  "id": "INV-10021",
  "user_id": 42,
  "amount": 299.99,
  "items": [...]
}

# IDOR attack user 42 accessing user 99's invoice:
GET /api/v1/invoices/INV-10022 HTTP/1.1
Host: api.company.com
Authorization: Bearer eyJ... [token for user 42, unchanged]

HTTP/1.1 200 OK           should be 403
{
  "id": "INV-10022",
  "user_id": 99,           different user
  "amount": 1499.00,
  "items": [...]           unauthorized disclosure
}

The authorization check that should be present:

# VULNERABLE:
@app.get("/api/v1/invoices/{invoice_id}")
@login_required
def get_invoice(invoice_id: str):
    invoice = Invoice.query.get_or_404(invoice_id)
    return jsonify(invoice.to_dict())

# SECURE — ownership verification added:
@app.get("/api/v1/invoices/{invoice_id}")
@login_required
def get_invoice(invoice_id: str):
    invoice = Invoice.query.filter_by(
        id=invoice_id,
        user_id=current_user.id   # ← This is the check that prevents IDOR
    ).first_or_404()
    return jsonify(invoice.to_dict())
# VULNERABLE:
@app.get("/api/v1/invoices/{invoice_id}")
@login_required
def get_invoice(invoice_id: str):
    invoice = Invoice.query.get_or_404(invoice_id)
    return jsonify(invoice.to_dict())

# SECURE — ownership verification added:
@app.get("/api/v1/invoices/{invoice_id}")
@login_required
def get_invoice(invoice_id: str):
    invoice = Invoice.query.filter_by(
        id=invoice_id,
        user_id=current_user.id   # ← This is the check that prevents IDOR
    ).first_or_404()
    return jsonify(invoice.to_dict())
# VULNERABLE:
@app.get("/api/v1/invoices/{invoice_id}")
@login_required
def get_invoice(invoice_id: str):
    invoice = Invoice.query.get_or_404(invoice_id)
    return jsonify(invoice.to_dict())

# SECURE — ownership verification added:
@app.get("/api/v1/invoices/{invoice_id}")
@login_required
def get_invoice(invoice_id: str):
    invoice = Invoice.query.filter_by(
        id=invoice_id,
        user_id=current_user.id   # ← This is the check that prevents IDOR
    ).first_or_404()
    return jsonify(invoice.to_dict())

IDOR vs BOLA: Are They the Same Thing?

BOLA Vulnerability: What Does It Mean?

A BOLA vulnerability (Broken Object Level Authorization) occurs when an API fails to verify that an authenticated user is authorized to access or modify the specific object referenced by a request.

For example, an API may correctly verify that a request comes from a logged-in user but fail to verify that the requested invoice, order, document, or customer record belongs to that user.

The term BOLA comes from the OWASP API Security Top 10, where Broken Object Level Authorization is identified as API1:2023. IDOR (Insecure Direct Object Reference) is the older and more broadly used term for the same underlying authorization failure.

In practice, the two terms are usually distinguished by context:

  • IDOR: The traditional term used across web application security for cases where a user-controlled identifier gives access to an object they are not authorized to access.

  • BOLA: OWASP's API-specific terminology for broken authorization at the object level, particularly when an API exposes objects through identifiers in paths, parameters, request bodies, or other API inputs.

For example, consider an API endpoint that returns an invoice:

GET /api/invoices/10021
Authorization: Bearer <user-a-token

GET /api/invoices/10021
Authorization: Bearer <user-a-token

GET /api/invoices/10021
Authorization: Bearer <user-a-token

Real IDOR Examples

IDOR vulnerabilities can appear anywhere an application accepts an object reference from the client and uses it to retrieve, modify, or delete a resource. The identifier might be a sequential number, UUID, username, filename, tenant ID, or another application-specific value.

The identifier itself is not the vulnerability. The vulnerability exists when the server accepts the reference without verifying that the authenticated user is authorized to access the referenced object.

Example 1: Sequential Order IDs

Consider an e-commerce application where a customer can view their orders through an API:

GET /api/orders/10021
Authorization: Bearer <user-a-token

GET /api/orders/10021
Authorization: Bearer <user-a-token

GET /api/orders/10021
Authorization: Bearer <user-a-token

The server returns User A's order because User A owns order 10021.

User A changes the identifier:

GET /api/orders/10022
Authorization: Bearer <user-a-token

GET /api/orders/10022
Authorization: Bearer <user-a-token

GET /api/orders/10022
Authorization: Bearer <user-a-token

If order 10022 belongs to User B and the server returns it to User A, the endpoint has an IDOR vulnerability.

The problem is not that the order IDs are sequential. The problem is that the server uses the supplied ID to retrieve the order without checking whether User A is authorized to access it.

An attacker does not need to guess every ID successfully. Any identifier obtained through the application's normal flows can be enough to test whether object-level authorization is enforced.

This is the classic IDOR pattern: change the object reference, keep the same authenticated identity, and see whether the authorization boundary follows the object.

Example 2: BOLA in an API

The same authorization failure is commonly described as BOLA (Broken Object Level Authorization) in API security.

Imagine a vehicle-management API that lets authenticated users remotely control their vehicles:

POST /api/vehicles/ABC123/doors/unlock
Authorization: Bearer <user-a-token

POST /api/vehicles/ABC123/doors/unlock
Authorization: Bearer <user-a-token

POST /api/vehicles/ABC123/doors/unlock
Authorization: Bearer <user-a-token

If ABC123 belongs to User A, the request is legitimate.

Now suppose User A replaces the vehicle identifier with one belonging to User B:

POST /api/vehicles/XYZ789/doors/unlock
Authorization: Bearer <user-a-token

POST /api/vehicles/XYZ789/doors/unlock
Authorization: Bearer <user-a-token

POST /api/vehicles/XYZ789/doors/unlock
Authorization: Bearer <user-a-token

If the API unlocks User B's vehicle, the server has failed to enforce authorization at the object level.

OWASP uses a similar vehicle example in its API Security Top 10: the application accepts a vehicle identifier but fails to verify that the vehicle belongs to the authenticated user. This is classified as API1:2023 Broken Object Level Authorization.

The same pattern can affect much less dramatic objects: invoices, customer records, support tickets, documents, messages, projects, API keys, or account settings.

Example 3: IDOR With UUIDs

Replacing sequential IDs with UUIDs does not eliminate IDOR.

Suppose an application uses unpredictable document identifiers:

GET /api/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479
Authorization: Bearer <user-a-token

GET /api/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479
Authorization: Bearer <user-a-token

GET /api/documents/f47ac10b-58cc-4372-a567-0e02b2c3d479
Authorization: Bearer <user-a-token

The UUID may be difficult to guess, but User A might still obtain User B's document identifier through a shared link, API response, notification, browser history, or another legitimate application flow.

If User A can then request the document and the server returns it without checking ownership, the application is still vulnerable.

Predictability affects how easily an identifier can be discovered. Authorization determines whether the identifier can be used.

For more on this distinction, see Why UUIDs Don't Fix IDOR.

Example 4: IDOR Through a File Reference

IDOR does not have to involve a database record.

Consider an application that stores customer documents using filenames:

GET /static/invoices/10021.pdf
Authorization: Bearer <user-a-token

GET /static/invoices/10021.pdf
Authorization: Bearer <user-a-token

GET /static/invoices/10021.pdf
Authorization: Bearer <user-a-token

If User A can change the filename:

GET /static/invoices/10022.pdf
Authorization: Bearer <user-a-token

GET /static/invoices/10022.pdf
Authorization: Bearer <user-a-token

GET /static/invoices/10022.pdf
Authorization: Bearer <user-a-token

and retrieve User B's invoice, the application has an IDOR vulnerability.

The same problem can occur with profile images, exported reports, chat transcripts, backups, generated PDFs, or other files whose references are controlled by the client.

Example 5: IDOR in GraphQL

GraphQL does not eliminate object-level authorization problems. A GraphQL mutation can still accept an object ID and perform an action without verifying that the requesting user owns the object.

For example:

POST /graphql
Authorization: Bearer <user-a-token>

{
  "query": "mutation { deleteDocument(id: \"doc-10022\") { success } }"
}
POST /graphql
Authorization: Bearer <user-a-token>

{
  "query": "mutation { deleteDocument(id: \"doc-10022\") { success } }"
}
POST /graphql
Authorization: Bearer <user-a-token>

{
  "query": "mutation { deleteDocument(id: \"doc-10022\") { success } }"
}

If doc-10022 belongs to User B and User A's request successfully deletes it, the GraphQL resolver has an object-level authorization failure.

The same principle applies whether the identifier appears in a REST URL, query parameter, JSON body, GraphQL variable, or another request location. The server must authorize the specific object before performing the requested operation.

Example 6: Multi-Tenant IDOR

Multi-tenant applications introduce another important IDOR boundary: the organization or tenant.

Consider an API request such as:

GET /api/organizations/acme/invoices
Authorization: Bearer <user-a-token

GET /api/organizations/acme/invoices
Authorization: Bearer <user-a-token

GET /api/organizations/acme/invoices
Authorization: Bearer <user-a-token

User A belongs to organization acme.

If User A changes the organization identifier:

GET /api/organizations/other-company/invoices
Authorization: Bearer <user-a-token

GET /api/organizations/other-company/invoices
Authorization: Bearer <user-a-token

GET /api/organizations/other-company/invoices
Authorization: Bearer <user-a-token

and the API returns invoices belonging to other-company, the application has a cross-tenant authorization failure.

This can be significantly more damaging than a single-user IDOR because the authorization failure may expose resources belonging to an entire organization rather than one individual account.

For a deeper look at this attack surface, see our Multi-Tenant SaaS Penetration Testing Guide.

What All These Examples Have in Common

The object references are different:

  • sequential order IDs

  • vehicle identifiers

  • UUIDs

  • filenames

  • GraphQL object IDs

  • tenant or organization IDs

But the underlying failure is the same.

The application trusts an object reference supplied by the client without enforcing authorization for the object that reference identifies.

The correct security question is therefore not:

"Can the user change the ID?"

It is:

"After the ID changes, does the server still verify that this authenticated user is authorized to access this specific object?"

If the answer is no, the application has an IDOR/BOLA vulnerability.

Part 2: The Complete IDOR Taxonomy: IDOR Vulnerability Types

Type 1: Horizontal IDOR (Same Privilege Level, Different User)

The most common type. User A accesses resources belonging to User B, where both users have the same role. No privilege escalation, just lateral access across users at the same level.

# Horizontal IDOR accessing another user's profile:
GET /api/users/9832/profile HTTP/1.1
Authorization: Bearer [token for user 1047]

# Horizontal IDOR modifying another user's data:
PUT /api/users/9832/address HTTP/1.1
Authorization: Bearer [token for user 1047]
{"street": "123 Attacker St", "city": "Exploit City"}

# Horizontal IDOR via query parameter:
GET /api/documents?owner_id=9832 HTTP/1.1
Authorization: Bearer [token for user 1047]
# Horizontal IDOR accessing another user's profile:
GET /api/users/9832/profile HTTP/1.1
Authorization: Bearer [token for user 1047]

# Horizontal IDOR modifying another user's data:
PUT /api/users/9832/address HTTP/1.1
Authorization: Bearer [token for user 1047]
{"street": "123 Attacker St", "city": "Exploit City"}

# Horizontal IDOR via query parameter:
GET /api/documents?owner_id=9832 HTTP/1.1
Authorization: Bearer [token for user 1047]
# Horizontal IDOR accessing another user's profile:
GET /api/users/9832/profile HTTP/1.1
Authorization: Bearer [token for user 1047]

# Horizontal IDOR modifying another user's data:
PUT /api/users/9832/address HTTP/1.1
Authorization: Bearer [token for user 1047]
{"street": "123 Attacker St", "city": "Exploit City"}

# Horizontal IDOR via query parameter:
GET /api/documents?owner_id=9832 HTTP/1.1
Authorization: Bearer [token for user 1047]

CVSS range: 6.5 (read-only) to 8.8 (write/delete on someone else's data)

Type 2: Vertical IDOR (Standard User Accessing Privileged Resources)

A standard user accesses admin functionality, elevated-privilege data, or role-restricted operations by supplying the identifier of a privileged resource.

# Vertical IDOR standard user accessing admin endpoint via ID:
GET /api/v1/reports/REPORT-ADMIN-001 HTTP/1.1
Authorization: Bearer [token for standard user]

# The report type is admin-only but the endpoint only checks
# authentication, not whether the resource type is allowed
# for the authenticated user's role.

# Vertical IDOR accessing billing configuration:
GET /api/accounts/ACC-001/billing-config HTTP/1.1
Authorization: Bearer [token for standard user not billing admin]
# Vertical IDOR standard user accessing admin endpoint via ID:
GET /api/v1/reports/REPORT-ADMIN-001 HTTP/1.1
Authorization: Bearer [token for standard user]

# The report type is admin-only but the endpoint only checks
# authentication, not whether the resource type is allowed
# for the authenticated user's role.

# Vertical IDOR accessing billing configuration:
GET /api/accounts/ACC-001/billing-config HTTP/1.1
Authorization: Bearer [token for standard user not billing admin]
# Vertical IDOR standard user accessing admin endpoint via ID:
GET /api/v1/reports/REPORT-ADMIN-001 HTTP/1.1
Authorization: Bearer [token for standard user]

# The report type is admin-only but the endpoint only checks
# authentication, not whether the resource type is allowed
# for the authenticated user's role.

# Vertical IDOR accessing billing configuration:
GET /api/accounts/ACC-001/billing-config HTTP/1.1
Authorization: Bearer [token for standard user not billing admin]

CVSS range: 7.5–9.1 depending on what the elevated resource exposes

Type 3: Blind IDOR (Action Succeeds, No Data Returned)

The most underestimated IDOR variant. The application performs a privileged action on another user's resource, but doesn't return any data in the response. The tester can't see the impact directly, only infer it from the fact that the action succeeded.

# Blind IDOR delete another user's comment:
DELETE /api/v1/comments/COMMENT-5521 HTTP/1.1
Authorization: Bearer [token for user who does not own COMMENT-5521]

HTTP/1.1 204 No Content    successful deletion, no response body
# The comment belonging to another user was deleted.
# No data returned but the action succeeded.

# Blind IDOR mark another user's notifications as read:
POST /api/v1/notifications/mark-read HTTP/1.1
Authorization: Bearer [token for different user]
{"notification_ids": [881, 882, 883]}  IDs belonging to another user

HTTP/1.1 200 OK
{"updated": 3}    no indication these aren't the attacker's notifications
# Blind IDOR delete another user's comment:
DELETE /api/v1/comments/COMMENT-5521 HTTP/1.1
Authorization: Bearer [token for user who does not own COMMENT-5521]

HTTP/1.1 204 No Content    successful deletion, no response body
# The comment belonging to another user was deleted.
# No data returned but the action succeeded.

# Blind IDOR mark another user's notifications as read:
POST /api/v1/notifications/mark-read HTTP/1.1
Authorization: Bearer [token for different user]
{"notification_ids": [881, 882, 883]}  IDs belonging to another user

HTTP/1.1 200 OK
{"updated": 3}    no indication these aren't the attacker's notifications
# Blind IDOR delete another user's comment:
DELETE /api/v1/comments/COMMENT-5521 HTTP/1.1
Authorization: Bearer [token for user who does not own COMMENT-5521]

HTTP/1.1 204 No Content    successful deletion, no response body
# The comment belonging to another user was deleted.
# No data returned but the action succeeded.

# Blind IDOR mark another user's notifications as read:
POST /api/v1/notifications/mark-read HTTP/1.1
Authorization: Bearer [token for different user]
{"notification_ids": [881, 882, 883]}  IDs belonging to another user

HTTP/1.1 200 OK
{"updated": 3}    no indication these aren't the attacker's notifications

Why blind IDORs are dangerous: They cause integrity failures without visible data exposure. Attackers can delete content, modify state, consume resources, or disrupt workflows without leaving obvious traces.

CVSS range: 5.0 (low-impact state change) to 8.8 (deletion of critical resources)

Type 4: Second-Order IDOR (Identifier Stored and Used Later)

The identifier is accepted at one point in a workflow, stored server-side, and used at a later stage where ownership is not re-validated. The exploit is separated across time and steps.

# Second-order IDOR — approval workflow example:

# Step 1: User creates an approval request (correctly authorized):
# POST /api/v1/requests
# User A creates REQUEST-001 — correctly stored with user_a ownership

# Step 2: User saves work in progress (identifier stored):
# PUT /api/v1/requests/REQUEST-001/draft
# Ownership checked — correct

# Step 3: User resumes draft (identifier reused from stored state):
# POST /api/v1/requests/resume
# {"request_id": "REQUEST-001"}
# Server assumes ownership was validated in Step 1 or 2
# ← Second-order IDOR: ownership not re-checked here

# The exploit: User B submits REQUEST-001 in the resume endpoint
# POST /api/v1/requests/resume
# {"request_id": "REQUEST-001"}  ← User A's request
# Authorization: Bearer [User B's token]
# Server finds the request, resumes it under User B — IDOR confirmed
# Second-order IDOR — approval workflow example:

# Step 1: User creates an approval request (correctly authorized):
# POST /api/v1/requests
# User A creates REQUEST-001 — correctly stored with user_a ownership

# Step 2: User saves work in progress (identifier stored):
# PUT /api/v1/requests/REQUEST-001/draft
# Ownership checked — correct

# Step 3: User resumes draft (identifier reused from stored state):
# POST /api/v1/requests/resume
# {"request_id": "REQUEST-001"}
# Server assumes ownership was validated in Step 1 or 2
# ← Second-order IDOR: ownership not re-checked here

# The exploit: User B submits REQUEST-001 in the resume endpoint
# POST /api/v1/requests/resume
# {"request_id": "REQUEST-001"}  ← User A's request
# Authorization: Bearer [User B's token]
# Server finds the request, resumes it under User B — IDOR confirmed
# Second-order IDOR — approval workflow example:

# Step 1: User creates an approval request (correctly authorized):
# POST /api/v1/requests
# User A creates REQUEST-001 — correctly stored with user_a ownership

# Step 2: User saves work in progress (identifier stored):
# PUT /api/v1/requests/REQUEST-001/draft
# Ownership checked — correct

# Step 3: User resumes draft (identifier reused from stored state):
# POST /api/v1/requests/resume
# {"request_id": "REQUEST-001"}
# Server assumes ownership was validated in Step 1 or 2
# ← Second-order IDOR: ownership not re-checked here

# The exploit: User B submits REQUEST-001 in the resume endpoint
# POST /api/v1/requests/resume
# {"request_id": "REQUEST-001"}  ← User A's request
# Authorization: Bearer [User B's token]
# Server finds the request, resumes it under User B — IDOR confirmed

Why second-order IDORs are hardest to detect: No automated tool understands multi-step workflow context. The vulnerable endpoint looks correct in isolation, the identifier is valid, the user is authenticated. The ownership failure only manifests when you understand that this identifier was created by a different user in a previous step.

CVSS range: 7.5–9.5 (often high because they bypass the most security-sensitive workflow steps)

Type 5: Multi-Tenant IDOR (Cross-Organization Access)

In multi-tenant SaaS applications, resources belong to organizations (tenants), not just individual users. An IDOR that allows cross-tenant access exposes all data from the target organization, not just a single record.

# Multi-tenant IDOR accessing another organization's data:

# Request from user in Tenant A (org_id: tenant_aaa):
GET /api/v1/customers?org_id=tenant_bbb HTTP/1.1
Authorization: Bearer [token for user in tenant_aaa]

HTTP/1.1 200 OK           should be 403
{"customers": [...all customers of tenant_bbb...], "total": 2341}

# The org_id parameter is accepted from the request
# instead of being derived from the authenticated user's session.
# Multi-tenant IDOR accessing another organization's data:

# Request from user in Tenant A (org_id: tenant_aaa):
GET /api/v1/customers?org_id=tenant_bbb HTTP/1.1
Authorization: Bearer [token for user in tenant_aaa]

HTTP/1.1 200 OK           should be 403
{"customers": [...all customers of tenant_bbb...], "total": 2341}

# The org_id parameter is accepted from the request
# instead of being derived from the authenticated user's session.
# Multi-tenant IDOR accessing another organization's data:

# Request from user in Tenant A (org_id: tenant_aaa):
GET /api/v1/customers?org_id=tenant_bbb HTTP/1.1
Authorization: Bearer [token for user in tenant_aaa]

HTTP/1.1 200 OK           should be 403
{"customers": [...all customers of tenant_bbb...], "total": 2341}

# The org_id parameter is accepted from the request
# instead of being derived from the authenticated user's session.

Multi-tenant IDOR is a force multiplier. A horizontal IDOR between individual users exposes one user's data. A multi-tenant IDOR exposes an entire organization's data, potentially thousands of records, with a single request.

Find the full coverage of the topic here in Multi-Tenant SaaS Penetration Testing

CVSS range: 8.8–9.5, these are almost always critical findings

Type 6: Mass Assignment IDOR

The application accepts a request body that includes fields the user shouldn't be able to set, such as user_id, owner_id, tenant_id, or role and processes them without filtering.

# Mass assignment IDOR — Node.js/Express example:

# VULNERABLE:
app.put('/api/v1/profile', authenticate, (req, res) => {
    const updates = req.body  //  accepts everything from request
    await User.update(updates, { where: { id: req.user.id } })
    res.json({ success: true })
})

# Attack — user sends:
# PUT /api/v1/profile
# {"name": "Attacker", "user_id": 9999, "role": "admin", "org_id": "target_org"}
# The user_id, role, and org_id fields are updated if the ORM doesn't filter

# SECURE — explicit field allowlist:
app.put('/api/v1/profile', authenticate, (req, res) => {
    const { name, email, phone } = req.body  //  only allowed fields
    await User.update({ name, email, phone },
                      { where: { id: req.user.id } })
    res.json({ success: true })
})
# Mass assignment IDOR — Node.js/Express example:

# VULNERABLE:
app.put('/api/v1/profile', authenticate, (req, res) => {
    const updates = req.body  //  accepts everything from request
    await User.update(updates, { where: { id: req.user.id } })
    res.json({ success: true })
})

# Attack — user sends:
# PUT /api/v1/profile
# {"name": "Attacker", "user_id": 9999, "role": "admin", "org_id": "target_org"}
# The user_id, role, and org_id fields are updated if the ORM doesn't filter

# SECURE — explicit field allowlist:
app.put('/api/v1/profile', authenticate, (req, res) => {
    const { name, email, phone } = req.body  //  only allowed fields
    await User.update({ name, email, phone },
                      { where: { id: req.user.id } })
    res.json({ success: true })
})
# Mass assignment IDOR — Node.js/Express example:

# VULNERABLE:
app.put('/api/v1/profile', authenticate, (req, res) => {
    const updates = req.body  //  accepts everything from request
    await User.update(updates, { where: { id: req.user.id } })
    res.json({ success: true })
})

# Attack — user sends:
# PUT /api/v1/profile
# {"name": "Attacker", "user_id": 9999, "role": "admin", "org_id": "target_org"}
# The user_id, role, and org_id fields are updated if the ORM doesn't filter

# SECURE — explicit field allowlist:
app.put('/api/v1/profile', authenticate, (req, res) => {
    const { name, email, phone } = req.body  //  only allowed fields
    await User.update({ name, email, phone },
                      { where: { id: req.user.id } })
    res.json({ success: true })
})

Part 3: Real-World IDOR Breaches: What the Numbers Look Like

Understanding IDOR impact concretely:

Year

Organization

IDOR Type

Records Exposed

Impact

2023

Major e-commerce platform

Horizontal (order IDs)

~1.2M order records

Customer PII, purchase history

2023

Healthcare portal

Multi-tenant

~580K patient records

PHI, HIPAA breach notification required

2024

FinTech API

Vertical (account configs)

Full account access

$2.3M fraudulent transfers

2024

SaaS CRM

Second-order (approval flow)

47K company records

Sales pipeline data

2024

Logistics platform

Blind IDOR (delete)

Operational disruption

14,000 shipment records deleted

2025

Developer tools platform

Mass assignment

89K developer accounts

Role escalation to admin

The consistent pattern: IDOR breaches are discovered by researchers or attackers, not by the organization's own security tooling. In every case above, the vulnerability was present in production for months or years before discovery.

Part 4: CVSS Scoring IDOR Correctly

Most IDOR findings are under-scored because engineers focus on what the finding looks like technically rather than what it enables operationally. A CVSS 3.1 score doesn't reflect what an actual attacker does with mass access.

How to Score IDOR Findings Using CVSS 4.0

Most IDOR findings get scored wrong, either underscored because the engineer focuses on the single HTTP request rather than what it enables at scale, or inconsistently scored across the team because there's no shared reference. The table below fixes that.

IDOR has a set of parameters that are fixed across almost every web and API finding. The only real variable is impact, what the vulnerability exposes or enables. Start with the fixed parameters, then use the impact table to determine your score.

Fixed parameters across all web/API IDORs:

Parameter

Value

Reasoning

Attack Vector (AV)

Network (N)

All web and API IDORs are remotely exploitable

Attack Complexity (AC)

Low (L)

Changing an ID parameter requires no special conditions

Attack Requirements (AT)

None (N)

No preconditions beyond valid authentication

Privileges Required (PR)

Low (L)

Any authenticated user can attempt the access

User Interaction (UI)

None (N)

No victim action required

Two exceptions worth noting: second-order IDORs use AT: Present because they require specific workflow state to exist before exploitation. Unauthenticated IDORs use PR: None, which is rare but produces immediately critical scores.

Impact scoring by IDOR type:

Type

Scenario

What Changes

CVSS 4.0

Read

Single record, low sensitivity

VC: Low

~5.3

Read

Single record, high sensitivity (PII, financial)

VC: High

~6.9

Read

All records for one user

VC: High

~7.1

Read

All records across all users

VC: High + SC: High

~8.8

Read

Multi-tenant, full org exposure

VC: High + SC: High

~9.1

Write

Modify another user's resource

VI: High

~8.8

Write

Modify an admin-level resource

VI: High + SC: High

~9.1

Delete

Delete another user's data (blind)

VI: High

~7.5

Delete

Delete critical business records

VI: High + SC: High

~8.5

Multi-tenant

Any of the above at org scale

Always add SC: High

8.8 to 9.5

The score is almost entirely determined by two questions: how many records are in scope, and can the attacker write or delete, or only read? Write and delete IDORs score consistently higher than read IDORs at equivalent scope. Multi-tenant always adds SC: High because the subsequent impact extends beyond the attacker's own organization.

Part 5: Why Your Current Security Tools Won't Find Most IDORs

Why DAST Doesn't Work for IDOR

Dynamic Application Security Testing tools operate at the HTTP request level. They send requests, analyze responses, compare patterns. What they cannot do is understand ownership.




This is not a limitation of any specific DAST tool. It is a structural limitation of the approach. Request-level testing without object ownership context cannot identify BOLA/IDOR.

Check out this SAST vs DAST differentiator.

Why Static Analysis Produces Noise

Static analysis tools look for patterns in source code. For IDOR, they typically flag: "identifier from user input used in database query without visible authorization check."

# This pattern gets flagged by static analysis:
def get_document(doc_id):
    doc = Document.query.get(doc_id)  # ← identifier from input, query executed
    return doc

# This pattern does NOT get flagged — but may still be vulnerable:
@requires_auth  # ← authorization check in decorator
def get_document(doc_id):
    doc = Document.query.get(doc_id)
    return doc
# Static analyzer sees the decorator and marks as safe.
# But the decorator only checks authentication (is logged in)
# not authorization (owns this specific document).
# This pattern gets flagged by static analysis:
def get_document(doc_id):
    doc = Document.query.get(doc_id)  # ← identifier from input, query executed
    return doc

# This pattern does NOT get flagged — but may still be vulnerable:
@requires_auth  # ← authorization check in decorator
def get_document(doc_id):
    doc = Document.query.get(doc_id)
    return doc
# Static analyzer sees the decorator and marks as safe.
# But the decorator only checks authentication (is logged in)
# not authorization (owns this specific document).
# This pattern gets flagged by static analysis:
def get_document(doc_id):
    doc = Document.query.get(doc_id)  # ← identifier from input, query executed
    return doc

# This pattern does NOT get flagged — but may still be vulnerable:
@requires_auth  # ← authorization check in decorator
def get_document(doc_id):
    doc = Document.query.get(doc_id)
    return doc
# Static analyzer sees the decorator and marks as safe.
# But the decorator only checks authentication (is logged in)
# not authorization (owns this specific document).

The false positive rate for static analysis IDOR detection consistently exceeds 50% in practice. The result: engineers spend time dismissing false positives rather than fixing real issues. And real IDORs buried in complex call chains get missed because the authorization check exists somewhere, just not the right check at the right level.

The Reachability Gap: What It Means for IDOR

For other vulnerability classes like CVEs in dependencies, reachability analysis determines whether the vulnerable function is actually called from your application code. The same principle applies to IDOR, but the question isn't "is this function called?" it's "is this authorization check actually enforced for this specific resource type, from this specific authentication context, at this point in this workflow?"

That's a runtime question. It requires:

  1. An authenticated identity

  2. Ownership of specific resources

  3. A second identity without ownership of those resources

  4. Systematic testing of every endpoint with both identities

  5. Comparison of results

This is what CodeAnt AI's pentest methodology does, automated multi-identity testing with ownership tracking across every endpoint in your API surface, not a static code scan that guesses whether a decorator does the right thing.

Part 6: How to Find IDOR Vulnerabilities: The Testing Methodology

IDOR testing requires at least two identities with different resource ownership. Authenticate as User A, identify resources belonging to User B, and attempt to access or modify those resources using User A's credentials. Repeat this across every endpoint, HTTP method, resource type, tenant boundary, and multi-step workflow that handles object identifiers.

The key question is not whether an identifier can be changed. It is whether the server verifies that the authenticated user is authorized to access the object that identifier resolves to.

Setup: Create the Right Test Accounts

IDOR testing requires multiple accounts with known resource ownership. The setup is everything.

# IDOR test setup — what you need before testing:

test_accounts = {
    "user_a": {
        "credentials": {"email": "test.a@testdomain.com", "password": "TestPass123!"},
        "token": None,  # obtain after login
        "owned_resources": {
            "documents": ["doc-001", "doc-002", "doc-003"],
            "invoices":  ["inv-001", "inv-002"],
            "projects":  ["proj-001"],
        }
    },
    "user_b": {
        "credentials": {"email": "test.b@testdomain.com", "password": "TestPass123!"},
        "token": None,
        "owned_resources": {
            "documents": ["doc-101", "doc-102"],
            "invoices":  ["inv-101", "inv-102", "inv-103"],
            "projects":  ["proj-101", "proj-102"],
        }
    },
    # For multi-tenant testing:
    "tenant_a_user": {
        "org_id": "org-aaa",
        "token": None,
    },
    "tenant_b_user": {
        "org_id": "org-bbb",
        "token": None,
    }
}
# IDOR test setup — what you need before testing:

test_accounts = {
    "user_a": {
        "credentials": {"email": "test.a@testdomain.com", "password": "TestPass123!"},
        "token": None,  # obtain after login
        "owned_resources": {
            "documents": ["doc-001", "doc-002", "doc-003"],
            "invoices":  ["inv-001", "inv-002"],
            "projects":  ["proj-001"],
        }
    },
    "user_b": {
        "credentials": {"email": "test.b@testdomain.com", "password": "TestPass123!"},
        "token": None,
        "owned_resources": {
            "documents": ["doc-101", "doc-102"],
            "invoices":  ["inv-101", "inv-102", "inv-103"],
            "projects":  ["proj-101", "proj-102"],
        }
    },
    # For multi-tenant testing:
    "tenant_a_user": {
        "org_id": "org-aaa",
        "token": None,
    },
    "tenant_b_user": {
        "org_id": "org-bbb",
        "token": None,
    }
}
# IDOR test setup — what you need before testing:

test_accounts = {
    "user_a": {
        "credentials": {"email": "test.a@testdomain.com", "password": "TestPass123!"},
        "token": None,  # obtain after login
        "owned_resources": {
            "documents": ["doc-001", "doc-002", "doc-003"],
            "invoices":  ["inv-001", "inv-002"],
            "projects":  ["proj-001"],
        }
    },
    "user_b": {
        "credentials": {"email": "test.b@testdomain.com", "password": "TestPass123!"},
        "token": None,
        "owned_resources": {
            "documents": ["doc-101", "doc-102"],
            "invoices":  ["inv-101", "inv-102", "inv-103"],
            "projects":  ["proj-101", "proj-102"],
        }
    },
    # For multi-tenant testing:
    "tenant_a_user": {
        "org_id": "org-aaa",
        "token": None,
    },
    "tenant_b_user": {
        "org_id": "org-bbb",
        "token": None,
    }
}

Systematic IDOR Testing Script

import requests
from itertools import product

class IDORTester:
    def __init__(self, base_url: str):
        self.base_url = base_url
        self.session = requests.Session()
        self.findings = []

    def test_horizontal_idor(
        self,
        endpoints: list,    # e.g. ["/api/v1/documents/{id}", "/api/v1/invoices/{id}"]
        user_a_token: str,
        user_b_resources: dict,  # {"documents": ["doc-101", ...], ...}
    ):
        """
        Test if User A can access User B's resources using User A's token.
        Tests every HTTP method against every resource type.
        """
        headers_a = {"Authorization": f"Bearer {user_a_token}"}

        for endpoint_template in endpoints:
            resource_type = endpoint_template.split("/")[3]  # extract type from path
            resource_ids = user_b_resources.get(resource_type, [])

            for resource_id in resource_ids[:3]:  # test first 3 of each type
                for method in ["GET", "PUT", "PATCH", "DELETE"]:
                    url = f"{self.base_url}{endpoint_template.format(id=resource_id)}"

                    resp = self.session.request(
                        method,
                        url,
                        headers=headers_a,
                        json={} if method in ["PUT", "PATCH"] else None,
                        allow_redirects=False
                    )

                    # 200 on GET = data exposure IDOR
                    # 200/204 on PUT/PATCH/DELETE = write/delete IDOR (higher severity)
                    if resp.status_code not in [401, 403, 404]:
                        severity = "CRITICAL" if method in ["PUT", "PATCH", "DELETE"] else "HIGH"
                        self.findings.append({
                            "type": "HORIZONTAL_IDOR",
                            "endpoint": url,
                            "method": method,
                            "resource_id": resource_id,
                            "status_code": resp.status_code,
                            "severity": severity,
                            "cvss": 8.8 if method != "GET" else 6.5,
                            "proof_of_concept": {
                                "request": f"{method} {url}",
                                "auth_context": "User A token",
                                "resource_owner": "User B",
                                "response_status": resp.status_code,
                            }
                        })
        return self.findings

    def test_vertical_idor(
        self,
        admin_endpoints: list,
        standard_user_token: str,
        admin_resource_ids: list,
    ):
        """
        Test if standard user can access admin-level resources by ID.
        """
        headers = {"Authorization": f"Bearer {standard_user_token}"}

        for endpoint_template in admin_endpoints:
            for resource_id in admin_resource_ids[:3]:
                url = f"{self.base_url}{endpoint_template.format(id=resource_id)}"
                resp = self.session.get(url, headers=headers)

                if resp.status_code == 200:
                    self.findings.append({
                        "type": "VERTICAL_IDOR",
                        "endpoint": url,
                        "severity": "CRITICAL",
                        "cvss": 9.1,
                        "notes": "Standard user accessed admin resource"
                    })
        return self.findings

    def test_multi_tenant_idor(
        self,
        tenant_scoped_endpoints: list,  # endpoints that accept tenant/org parameter
        tenant_a_token: str,
        tenant_b_org_id: str,
    ):
        """
        Test if Tenant A user can access Tenant B resources by supplying
        Tenant B's org identifier in the request.
        """
        headers = {"Authorization": f"Bearer {tenant_a_token}"}

        for endpoint in tenant_scoped_endpoints:
            # Test query parameter injection
            resp = self.session.get(
                f"{self.base_url}{endpoint}",
                headers=headers,
                params={"org_id": tenant_b_org_id}
            )
            if resp.status_code == 200 and resp.json().get("data"):
                self.findings.append({
                    "type": "MULTI_TENANT_IDOR",
                    "endpoint": endpoint,
                    "vector": "query_parameter_org_id",
                    "severity": "CRITICAL",
                    "cvss": 9.1,
                })

            # Test path parameter injection
            resp = self.session.get(
                f"{self.base_url}/api/v1/orgs/{tenant_b_org_id}/data",
                headers=headers,
            )
            if resp.status_code == 200:
                self.findings.append({
                    "type": "MULTI_TENANT_IDOR",
                    "endpoint": f"/api/v1/orgs/{tenant_b_org_id}/data",
                    "vector": "path_parameter_org_id",
                    "severity": "CRITICAL",
                    "cvss": 9.1,
                })
        return self.findings
import requests
from itertools import product

class IDORTester:
    def __init__(self, base_url: str):
        self.base_url = base_url
        self.session = requests.Session()
        self.findings = []

    def test_horizontal_idor(
        self,
        endpoints: list,    # e.g. ["/api/v1/documents/{id}", "/api/v1/invoices/{id}"]
        user_a_token: str,
        user_b_resources: dict,  # {"documents": ["doc-101", ...], ...}
    ):
        """
        Test if User A can access User B's resources using User A's token.
        Tests every HTTP method against every resource type.
        """
        headers_a = {"Authorization": f"Bearer {user_a_token}"}

        for endpoint_template in endpoints:
            resource_type = endpoint_template.split("/")[3]  # extract type from path
            resource_ids = user_b_resources.get(resource_type, [])

            for resource_id in resource_ids[:3]:  # test first 3 of each type
                for method in ["GET", "PUT", "PATCH", "DELETE"]:
                    url = f"{self.base_url}{endpoint_template.format(id=resource_id)}"

                    resp = self.session.request(
                        method,
                        url,
                        headers=headers_a,
                        json={} if method in ["PUT", "PATCH"] else None,
                        allow_redirects=False
                    )

                    # 200 on GET = data exposure IDOR
                    # 200/204 on PUT/PATCH/DELETE = write/delete IDOR (higher severity)
                    if resp.status_code not in [401, 403, 404]:
                        severity = "CRITICAL" if method in ["PUT", "PATCH", "DELETE"] else "HIGH"
                        self.findings.append({
                            "type": "HORIZONTAL_IDOR",
                            "endpoint": url,
                            "method": method,
                            "resource_id": resource_id,
                            "status_code": resp.status_code,
                            "severity": severity,
                            "cvss": 8.8 if method != "GET" else 6.5,
                            "proof_of_concept": {
                                "request": f"{method} {url}",
                                "auth_context": "User A token",
                                "resource_owner": "User B",
                                "response_status": resp.status_code,
                            }
                        })
        return self.findings

    def test_vertical_idor(
        self,
        admin_endpoints: list,
        standard_user_token: str,
        admin_resource_ids: list,
    ):
        """
        Test if standard user can access admin-level resources by ID.
        """
        headers = {"Authorization": f"Bearer {standard_user_token}"}

        for endpoint_template in admin_endpoints:
            for resource_id in admin_resource_ids[:3]:
                url = f"{self.base_url}{endpoint_template.format(id=resource_id)}"
                resp = self.session.get(url, headers=headers)

                if resp.status_code == 200:
                    self.findings.append({
                        "type": "VERTICAL_IDOR",
                        "endpoint": url,
                        "severity": "CRITICAL",
                        "cvss": 9.1,
                        "notes": "Standard user accessed admin resource"
                    })
        return self.findings

    def test_multi_tenant_idor(
        self,
        tenant_scoped_endpoints: list,  # endpoints that accept tenant/org parameter
        tenant_a_token: str,
        tenant_b_org_id: str,
    ):
        """
        Test if Tenant A user can access Tenant B resources by supplying
        Tenant B's org identifier in the request.
        """
        headers = {"Authorization": f"Bearer {tenant_a_token}"}

        for endpoint in tenant_scoped_endpoints:
            # Test query parameter injection
            resp = self.session.get(
                f"{self.base_url}{endpoint}",
                headers=headers,
                params={"org_id": tenant_b_org_id}
            )
            if resp.status_code == 200 and resp.json().get("data"):
                self.findings.append({
                    "type": "MULTI_TENANT_IDOR",
                    "endpoint": endpoint,
                    "vector": "query_parameter_org_id",
                    "severity": "CRITICAL",
                    "cvss": 9.1,
                })

            # Test path parameter injection
            resp = self.session.get(
                f"{self.base_url}/api/v1/orgs/{tenant_b_org_id}/data",
                headers=headers,
            )
            if resp.status_code == 200:
                self.findings.append({
                    "type": "MULTI_TENANT_IDOR",
                    "endpoint": f"/api/v1/orgs/{tenant_b_org_id}/data",
                    "vector": "path_parameter_org_id",
                    "severity": "CRITICAL",
                    "cvss": 9.1,
                })
        return self.findings
import requests
from itertools import product

class IDORTester:
    def __init__(self, base_url: str):
        self.base_url = base_url
        self.session = requests.Session()
        self.findings = []

    def test_horizontal_idor(
        self,
        endpoints: list,    # e.g. ["/api/v1/documents/{id}", "/api/v1/invoices/{id}"]
        user_a_token: str,
        user_b_resources: dict,  # {"documents": ["doc-101", ...], ...}
    ):
        """
        Test if User A can access User B's resources using User A's token.
        Tests every HTTP method against every resource type.
        """
        headers_a = {"Authorization": f"Bearer {user_a_token}"}

        for endpoint_template in endpoints:
            resource_type = endpoint_template.split("/")[3]  # extract type from path
            resource_ids = user_b_resources.get(resource_type, [])

            for resource_id in resource_ids[:3]:  # test first 3 of each type
                for method in ["GET", "PUT", "PATCH", "DELETE"]:
                    url = f"{self.base_url}{endpoint_template.format(id=resource_id)}"

                    resp = self.session.request(
                        method,
                        url,
                        headers=headers_a,
                        json={} if method in ["PUT", "PATCH"] else None,
                        allow_redirects=False
                    )

                    # 200 on GET = data exposure IDOR
                    # 200/204 on PUT/PATCH/DELETE = write/delete IDOR (higher severity)
                    if resp.status_code not in [401, 403, 404]:
                        severity = "CRITICAL" if method in ["PUT", "PATCH", "DELETE"] else "HIGH"
                        self.findings.append({
                            "type": "HORIZONTAL_IDOR",
                            "endpoint": url,
                            "method": method,
                            "resource_id": resource_id,
                            "status_code": resp.status_code,
                            "severity": severity,
                            "cvss": 8.8 if method != "GET" else 6.5,
                            "proof_of_concept": {
                                "request": f"{method} {url}",
                                "auth_context": "User A token",
                                "resource_owner": "User B",
                                "response_status": resp.status_code,
                            }
                        })
        return self.findings

    def test_vertical_idor(
        self,
        admin_endpoints: list,
        standard_user_token: str,
        admin_resource_ids: list,
    ):
        """
        Test if standard user can access admin-level resources by ID.
        """
        headers = {"Authorization": f"Bearer {standard_user_token}"}

        for endpoint_template in admin_endpoints:
            for resource_id in admin_resource_ids[:3]:
                url = f"{self.base_url}{endpoint_template.format(id=resource_id)}"
                resp = self.session.get(url, headers=headers)

                if resp.status_code == 200:
                    self.findings.append({
                        "type": "VERTICAL_IDOR",
                        "endpoint": url,
                        "severity": "CRITICAL",
                        "cvss": 9.1,
                        "notes": "Standard user accessed admin resource"
                    })
        return self.findings

    def test_multi_tenant_idor(
        self,
        tenant_scoped_endpoints: list,  # endpoints that accept tenant/org parameter
        tenant_a_token: str,
        tenant_b_org_id: str,
    ):
        """
        Test if Tenant A user can access Tenant B resources by supplying
        Tenant B's org identifier in the request.
        """
        headers = {"Authorization": f"Bearer {tenant_a_token}"}

        for endpoint in tenant_scoped_endpoints:
            # Test query parameter injection
            resp = self.session.get(
                f"{self.base_url}{endpoint}",
                headers=headers,
                params={"org_id": tenant_b_org_id}
            )
            if resp.status_code == 200 and resp.json().get("data"):
                self.findings.append({
                    "type": "MULTI_TENANT_IDOR",
                    "endpoint": endpoint,
                    "vector": "query_parameter_org_id",
                    "severity": "CRITICAL",
                    "cvss": 9.1,
                })

            # Test path parameter injection
            resp = self.session.get(
                f"{self.base_url}/api/v1/orgs/{tenant_b_org_id}/data",
                headers=headers,
            )
            if resp.status_code == 200:
                self.findings.append({
                    "type": "MULTI_TENANT_IDOR",
                    "endpoint": f"/api/v1/orgs/{tenant_b_org_id}/data",
                    "vector": "path_parameter_org_id",
                    "severity": "CRITICAL",
                    "cvss": 9.1,
                })
        return self.findings

Manual IDOR Testing Checklist

Running IDOR tests manually without a structured approach means you cover the endpoints you think to test and miss the ones that matter most. The checklist below walks through every phase in order. Each phase has a reason column because knowing why a check exists is what lets you adapt it when your application does something unusual.

Phase 1: Setup

Check

Why It Matters

Create at least two test accounts with different owned resources

IDOR requires cross-user comparison. One account cannot confirm unauthorized access.

For multi-tenant applications, place accounts in separate organizations

Tests tenant isolation, not just user isolation. Most SaaS apps need both.

Document the specific IDs of resources owned by each account

You cannot confirm IDOR without knowing which resource belongs to whom.

Configure Burp Suite with the Autorize extension

Authorize replays every request automatically using a second identity, flagging response similarities.

Capture baseline requests for Account A accessing their own resources

Establishes the legitimate response pattern you will compare against unauthorized attempts.

Phase 2: Endpoint Discovery

Check

Why It Matters

Enumerate all API endpoints from the OpenAPI spec and JS bundle analysis

Documented endpoints are only part of the surface. Bundles frequently expose internal paths the spec omits.

Identify every endpoint that accepts an object identifier

These are your test targets. IDs appear in URL paths, query parameters, request bodies, and headers.

Classify each endpoint by HTTP method: GET (read), PUT/PATCH (write), DELETE (delete)

Write and delete IDORs score higher than read IDORs. Prioritize accordingly.

Note where identifiers appear in each request

The same IDOR can exist in a path parameter but not a query parameter, or vice versa. Test each location.

Phase 3: Horizontal IDOR Testing

Check

Why It Matters

Replay Account A's request for each endpoint using Account B's resource IDs

This is the core test. If the server returns Account B's data using Account A's token, IDOR is confirmed.

Test every HTTP method, not just GET

Write and delete IDORs can have substantially higher impact than read-only IDORs when they allow unauthorized modification or destruction of sensitive resources. Score the actual impact and affected security properties rather than assigning severity based on HTTP method alone.

Test batch endpoints by mixing owned and unowned IDs in the same request

Batch endpoints frequently check authorization once for the collection, not per item.

Test export and download endpoints specifically

These are high-value targets that engineering teams treat as secondary features and often skip in authorization review.

Test search and filter endpoints with another user's identifier as the filter value

Filtering by another user's ID or name can return their resources if the ownership scope is applied after filtering.

Phase 4: Vertical IDOR Testing

Check

Why It Matters

Identify admin or privileged resource IDs from documentation, error messages, or API responses

Admin resource IDs sometimes appear in error responses or metadata returned to standard users.

Attempt to access those resources using a standard user token

Confirms whether role checks are enforced at the resource level or only at the route level.

Test role-specific endpoints with tokens from lower-privilege roles

An endpoint may be undocumented for standard users but still reachable if the identifier is known.

Phase 5: Multi-Tenant IDOR Testing

Check

Why It Matters

Identify every location where org_id or tenant_id appears in requests

It may appear in the URL path, as a query parameter, or in the request body. Each requires separate testing.

Replace Account A's org ID with Account B's org ID in each location

If the server accepts the substitution, all of Account B's org data may be accessible.

Test the URL path pattern: /api/orgs/{org_id}/resources

Path-based org scoping is the most common multi-tenant pattern and frequently the one missing ownership validation.

Test query parameter injection: ?org_id=other_org

Some endpoints accept org_id as an optional filter and apply it directly without checking the authenticated user's org.

Test body injection: {"org_id": "other_org"}

Particularly relevant for POST and PUT endpoints that accept configuration or filter payloads.

Phase 6: Second-Order IDOR Testing

Check

Why It Matters

Map every multi-step workflow in the application

Second-order IDORs only appear when an identifier created in one step is reused in a later step without re-validation.

Create resources as Account A, then attempt to resume or complete the workflow as Account B

The most common second-order pattern: ownership is checked at creation but not at resumption.

Note which workflows reuse identifiers across steps

Any workflow that stores an identifier server-side and references it in a later step is a candidate.

Confirm whether each workflow's resume or continue step re-validates ownership independently

Do not assume that a correct check in Step 1 carries forward to Step 3. Test each step in isolation.

Phase 7: Blind IDOR Testing

Check

Why It Matters

Test all DELETE and POST action endpoints with another user's resource IDs

Blind IDORs return no data but still complete the unauthorized action. A 204 No Content is not a passing result.

Verify impact by checking the target resource as its legitimate owner after the action

The only way to confirm a blind IDOR is to observe the side effect: the resource was modified, deleted, or actioned.

Specifically investigate 204 No Content responses against resources you do not own

This is the most commonly missed signal in IDOR testing. A clean response with no body is not evidence of authorization.

Part 7: IDOR in GraphQL APIs

GraphQL introduces additional IDOR attack surface beyond REST:

# GraphQL IDOR direct object query:
query {
  document(id: "doc-101") {  # User B's document
    title
    content
    owner {
      email
    }
  }
}
# If the resolver doesn't check that the authenticated user owns doc-101 → IDOR

# GraphQL Batch IDOR enumerate via batching:
query {
  d1: document(id: "doc-001") { title content }
  d2: document(id: "doc-002") { title content }
  d3: document(id: "doc-003") { title content }
  # ... batch all documents by ID
}
# Even with rate limiting, GraphQL batching can bypass rate limits
# to enumerate many documents in a single HTTP request

# GraphQL Mutation IDOR:
mutation {
  updateDocument(id: "doc-101", title: "MODIFIED BY ATTACKER") {
    id
    title
  }
}
# If the mutation doesn't verify ownership → write IDOR
# GraphQL IDOR direct object query:
query {
  document(id: "doc-101") {  # User B's document
    title
    content
    owner {
      email
    }
  }
}
# If the resolver doesn't check that the authenticated user owns doc-101 → IDOR

# GraphQL Batch IDOR enumerate via batching:
query {
  d1: document(id: "doc-001") { title content }
  d2: document(id: "doc-002") { title content }
  d3: document(id: "doc-003") { title content }
  # ... batch all documents by ID
}
# Even with rate limiting, GraphQL batching can bypass rate limits
# to enumerate many documents in a single HTTP request

# GraphQL Mutation IDOR:
mutation {
  updateDocument(id: "doc-101", title: "MODIFIED BY ATTACKER") {
    id
    title
  }
}
# If the mutation doesn't verify ownership → write IDOR
# GraphQL IDOR direct object query:
query {
  document(id: "doc-101") {  # User B's document
    title
    content
    owner {
      email
    }
  }
}
# If the resolver doesn't check that the authenticated user owns doc-101 → IDOR

# GraphQL Batch IDOR enumerate via batching:
query {
  d1: document(id: "doc-001") { title content }
  d2: document(id: "doc-002") { title content }
  d3: document(id: "doc-003") { title content }
  # ... batch all documents by ID
}
# Even with rate limiting, GraphQL batching can bypass rate limits
# to enumerate many documents in a single HTTP request

# GraphQL Mutation IDOR:
mutation {
  updateDocument(id: "doc-101", title: "MODIFIED BY ATTACKER") {
    id
    title
  }
}
# If the mutation doesn't verify ownership → write IDOR

GraphQL resolvers must check authorization at the resolver level, not just at the gateway level:

// GraphQL resolver — VULNERABLE:
const resolvers = {
  Query: {
    document: (_, { id }) => {
      return Document.findById(id)  // ← No ownership check
    }
  }
}

// SECURE — resolver enforces ownership:
const resolvers = {
  Query: {
    document: (_, { id }, context) => {
      return Document.findOne({
        _id: id,
        owner: context.user.id  // ← Ownership check at resolver level
      })
    }
  }
}
// GraphQL resolver — VULNERABLE:
const resolvers = {
  Query: {
    document: (_, { id }) => {
      return Document.findById(id)  // ← No ownership check
    }
  }
}

// SECURE — resolver enforces ownership:
const resolvers = {
  Query: {
    document: (_, { id }, context) => {
      return Document.findOne({
        _id: id,
        owner: context.user.id  // ← Ownership check at resolver level
      })
    }
  }
}
// GraphQL resolver — VULNERABLE:
const resolvers = {
  Query: {
    document: (_, { id }) => {
      return Document.findById(id)  // ← No ownership check
    }
  }
}

// SECURE — resolver enforces ownership:
const resolvers = {
  Query: {
    document: (_, { id }, context) => {
      return Document.findOne({
        _id: id,
        owner: context.user.id  // ← Ownership check at resolver level
      })
    }
  }
}

Part 8: How to Fix IDOR Vulnerabilities

The primary fix for an IDOR vulnerability is server-side authorization at the object level. Do not rely on sequential IDs, UUIDs, hidden fields, or client-side checks to enforce ownership. The server must determine the authenticated identity and verify that identity is authorized to access the requested object.

The safest implementation pattern is to scope object queries to the authenticated user or tenant before returning, modifying, or deleting the resource.

Vulnerable vs Secure IDOR Code

A typical IDOR occurs when the application retrieves an object using only the supplied identifier:

# VULNERABLE
invoice = Invoice.query.get_or_404(invoice_id)
return jsonify(invoice.to_dict())
# VULNERABLE
invoice = Invoice.query.get_or_404(invoice_id)
return jsonify(invoice.to_dict())
# VULNERABLE
invoice = Invoice.query.get_or_404(invoice_id)
return jsonify(invoice.to_dict())

The identifier may be valid, but the query does not establish that the authenticated user owns the invoice.

A secure implementation scopes the lookup to the authenticated identity:

# SECURE
invoice = Invoice.query.filter_by(
    id=invoice_id,
    user_id=current_user.id
).first_or_404()

return jsonify(invoice.to_dict())
# SECURE
invoice = Invoice.query.filter_by(
    id=invoice_id,
    user_id=current_user.id
).first_or_404()

return jsonify(invoice.to_dict())
# SECURE
invoice = Invoice.query.filter_by(
    id=invoice_id,
    user_id=current_user.id
).first_or_404()

return jsonify(invoice.to_dict())

The important change is not the identifier format. It is the ownership constraint applied by the server.

Fix Pattern 1: Derive Identity From the Authenticated Session

The single most effective IDOR prevention: never trust the client to tell you who owns a resource. Derive ownership exclusively from the authenticated session.

# VULNERABLE — trusts client-supplied owner:
@app.get("/api/documents")
@login_required
def get_documents():
    user_id = request.args.get("user_id")  # ← from request
    docs = Document.query.filter_by(user_id=user_id).all()
    return jsonify([d.to_dict() for d in docs])

# SECURE — derives identity from session:
@app.get("/api/documents")
@login_required
def get_documents():
    user_id = current_user.id  # ← from authenticated session, never from input
    docs = Document.query.filter_by(user_id=user_id).all()
    return jsonify([d.to_dict() for d in docs])
# VULNERABLE — trusts client-supplied owner:
@app.get("/api/documents")
@login_required
def get_documents():
    user_id = request.args.get("user_id")  # ← from request
    docs = Document.query.filter_by(user_id=user_id).all()
    return jsonify([d.to_dict() for d in docs])

# SECURE — derives identity from session:
@app.get("/api/documents")
@login_required
def get_documents():
    user_id = current_user.id  # ← from authenticated session, never from input
    docs = Document.query.filter_by(user_id=user_id).all()
    return jsonify([d.to_dict() for d in docs])
# VULNERABLE — trusts client-supplied owner:
@app.get("/api/documents")
@login_required
def get_documents():
    user_id = request.args.get("user_id")  # ← from request
    docs = Document.query.filter_by(user_id=user_id).all()
    return jsonify([d.to_dict() for d in docs])

# SECURE — derives identity from session:
@app.get("/api/documents")
@login_required
def get_documents():
    user_id = current_user.id  # ← from authenticated session, never from input
    docs = Document.query.filter_by(user_id=user_id).all()
    return jsonify([d.to_dict() for d in docs])

Fix Pattern 2: Scope Every Query By Owner

Every database query for user-owned resources must include an ownership filter. Not as an optional extra. As a structural requirement baked into the data access layer.

# Implementation using a BaseModel with automatic ownership scoping:

class UserScopedModel(db.Model):
    __abstract__ = True

    @classmethod
    def get_for_user(cls, object_id, user_id):
        """Only returns the object if it belongs to the specified user."""
        return cls.query.filter_by(
            id=object_id,
            user_id=user_id
        ).first()

    @classmethod
    def all_for_user(cls, user_id):
        """Returns all objects belonging to the specified user."""
        return cls.query.filter_by(user_id=user_id).all()

# Usage — IDOR is now structurally impossible via these methods:
class Document(UserScopedModel):
    id = db.Column(db.String, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    content = db.Column(db.Text)

# In the route:
@app.get("/api/documents/<doc_id>")
@login_required
def get_document(doc_id):
    doc = Document.get_for_user(doc_id, current_user.id)
    if not doc:
        abort(404)  # Don't reveal that the resource exists for another user
    return jsonify(doc.to_dict())
# Implementation using a BaseModel with automatic ownership scoping:

class UserScopedModel(db.Model):
    __abstract__ = True

    @classmethod
    def get_for_user(cls, object_id, user_id):
        """Only returns the object if it belongs to the specified user."""
        return cls.query.filter_by(
            id=object_id,
            user_id=user_id
        ).first()

    @classmethod
    def all_for_user(cls, user_id):
        """Returns all objects belonging to the specified user."""
        return cls.query.filter_by(user_id=user_id).all()

# Usage — IDOR is now structurally impossible via these methods:
class Document(UserScopedModel):
    id = db.Column(db.String, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    content = db.Column(db.Text)

# In the route:
@app.get("/api/documents/<doc_id>")
@login_required
def get_document(doc_id):
    doc = Document.get_for_user(doc_id, current_user.id)
    if not doc:
        abort(404)  # Don't reveal that the resource exists for another user
    return jsonify(doc.to_dict())
# Implementation using a BaseModel with automatic ownership scoping:

class UserScopedModel(db.Model):
    __abstract__ = True

    @classmethod
    def get_for_user(cls, object_id, user_id):
        """Only returns the object if it belongs to the specified user."""
        return cls.query.filter_by(
            id=object_id,
            user_id=user_id
        ).first()

    @classmethod
    def all_for_user(cls, user_id):
        """Returns all objects belonging to the specified user."""
        return cls.query.filter_by(user_id=user_id).all()

# Usage — IDOR is now structurally impossible via these methods:
class Document(UserScopedModel):
    id = db.Column(db.String, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
    content = db.Column(db.Text)

# In the route:
@app.get("/api/documents/<doc_id>")
@login_required
def get_document(doc_id):
    doc = Document.get_for_user(doc_id, current_user.id)
    if not doc:
        abort(404)  # Don't reveal that the resource exists for another user
    return jsonify(doc.to_dict())

Fix Pattern 3: Enforce Multi-Tenant Scope at the Data Layer

For multi-tenant applications, implement row-level security at the ORM or database level so tenant isolation is enforced before any application code runs.

# SQLAlchemy — tenant-aware query scope:

class TenantAwareSession:
    """Wraps db.session to automatically scope all queries by tenant."""

    def __init__(self, tenant_id: str):
        self.tenant_id = tenant_id

    def query(self, model):
        """All queries automatically filtered by tenant."""
        if hasattr(model, 'tenant_id'):
            return db.session.query(model).filter(
                model.tenant_id == self.tenant_id
            )
        return db.session.query(model)

# In request context:
@app.before_request
def set_tenant_context():
    if current_user.is_authenticated:
        g.db = TenantAwareSession(current_user.organization_id)

# Usage — cross-tenant access structurally impossible:
@app.get("/api/v1/customers")
@login_required
def get_customers():
    # g.db.query(Customer) automatically includes
    # WHERE tenant_id = current_user.organization_id
    customers = g.db.query(Customer).all()
    return jsonify([c.to_dict() for c in customers])
# SQLAlchemy — tenant-aware query scope:

class TenantAwareSession:
    """Wraps db.session to automatically scope all queries by tenant."""

    def __init__(self, tenant_id: str):
        self.tenant_id = tenant_id

    def query(self, model):
        """All queries automatically filtered by tenant."""
        if hasattr(model, 'tenant_id'):
            return db.session.query(model).filter(
                model.tenant_id == self.tenant_id
            )
        return db.session.query(model)

# In request context:
@app.before_request
def set_tenant_context():
    if current_user.is_authenticated:
        g.db = TenantAwareSession(current_user.organization_id)

# Usage — cross-tenant access structurally impossible:
@app.get("/api/v1/customers")
@login_required
def get_customers():
    # g.db.query(Customer) automatically includes
    # WHERE tenant_id = current_user.organization_id
    customers = g.db.query(Customer).all()
    return jsonify([c.to_dict() for c in customers])
# SQLAlchemy — tenant-aware query scope:

class TenantAwareSession:
    """Wraps db.session to automatically scope all queries by tenant."""

    def __init__(self, tenant_id: str):
        self.tenant_id = tenant_id

    def query(self, model):
        """All queries automatically filtered by tenant."""
        if hasattr(model, 'tenant_id'):
            return db.session.query(model).filter(
                model.tenant_id == self.tenant_id
            )
        return db.session.query(model)

# In request context:
@app.before_request
def set_tenant_context():
    if current_user.is_authenticated:
        g.db = TenantAwareSession(current_user.organization_id)

# Usage — cross-tenant access structurally impossible:
@app.get("/api/v1/customers")
@login_required
def get_customers():
    # g.db.query(Customer) automatically includes
    # WHERE tenant_id = current_user.organization_id
    customers = g.db.query(Customer).all()
    return jsonify([c.to_dict() for c in customers])

Fix Pattern 4: Block Mass Assignment

// Node.js/Express — allowlist approach:

const ALLOWED_PROFILE_FIELDS = ['name', 'email', 'phone', 'bio'];

app.put('/api/profile', authenticate, async (req, res) => {
    // Only allow explicitly listed fields
    const updates = Object.keys(req.body)
        .filter(key => ALLOWED_PROFILE_FIELDS.includes(key))
        .reduce((obj, key) => {
            obj[key] = req.body[key];
            return obj;
        }, {});

    // Never allow user_id, role, org_id, tenant_id to be set from input
    await User.update(updates, { where: { id: req.user.id } });
    res.json({ success: true });
});
// Node.js/Express — allowlist approach:

const ALLOWED_PROFILE_FIELDS = ['name', 'email', 'phone', 'bio'];

app.put('/api/profile', authenticate, async (req, res) => {
    // Only allow explicitly listed fields
    const updates = Object.keys(req.body)
        .filter(key => ALLOWED_PROFILE_FIELDS.includes(key))
        .reduce((obj, key) => {
            obj[key] = req.body[key];
            return obj;
        }, {});

    // Never allow user_id, role, org_id, tenant_id to be set from input
    await User.update(updates, { where: { id: req.user.id } });
    res.json({ success: true });
});
// Node.js/Express — allowlist approach:

const ALLOWED_PROFILE_FIELDS = ['name', 'email', 'phone', 'bio'];

app.put('/api/profile', authenticate, async (req, res) => {
    // Only allow explicitly listed fields
    const updates = Object.keys(req.body)
        .filter(key => ALLOWED_PROFILE_FIELDS.includes(key))
        .reduce((obj, key) => {
            obj[key] = req.body[key];
            return obj;
        }, {});

    // Never allow user_id, role, org_id, tenant_id to be set from input
    await User.update(updates, { where: { id: req.user.id } });
    res.json({ success: true });
});

Fix Pattern 5: Re-Validate Ownership at Every Workflow Step

For multi-step workflows, re-validate ownership at every step. Never assume ownership established in a previous step carries forward.

# Workflow ownership re-validation — every step independently checks:

@app.post("/api/v1/requests/resume")
@login_required
def resume_request():
    request_id = request.json.get("request_id")

    # Re-validate ownership at this step — not assumed from earlier steps:
    pending_request = ApprovalRequest.query.filter_by(
        id=request_id,
        owner_id=current_user.id,      # ← ownership check
        status="DRAFT"                 # ← state check
    ).first_or_404()

    # Now safe to proceed
    return process_resume(pending_request)
# Workflow ownership re-validation — every step independently checks:

@app.post("/api/v1/requests/resume")
@login_required
def resume_request():
    request_id = request.json.get("request_id")

    # Re-validate ownership at this step — not assumed from earlier steps:
    pending_request = ApprovalRequest.query.filter_by(
        id=request_id,
        owner_id=current_user.id,      # ← ownership check
        status="DRAFT"                 # ← state check
    ).first_or_404()

    # Now safe to proceed
    return process_resume(pending_request)
# Workflow ownership re-validation — every step independently checks:

@app.post("/api/v1/requests/resume")
@login_required
def resume_request():
    request_id = request.json.get("request_id")

    # Re-validate ownership at this step — not assumed from earlier steps:
    pending_request = ApprovalRequest.query.filter_by(
        id=request_id,
        owner_id=current_user.id,      # ← ownership check
        status="DRAFT"                 # ← state check
    ).first_or_404()

    # Now safe to proceed
    return process_resume(pending_request)

Part 9: Testing for IDOR with Burp Suite

Burp Suite is useful for testing IDOR because it lets you capture an authenticated request, change the object reference, and compare the response.

The goal is not simply to find parameters that contain IDs. The goal is to determine whether changing an object reference allows one user to access another user's resource without authorization.

Step 1: Identify Object References

Start by browsing the application normally while logged in as a test user.

Look for requests containing values that appear to identify an object, such as:

GET /api/orders/10021
GET /api/users/1842
GET /api/invoices?id=10021
GET /documents/8f3c2a
GET /download?file=invoice-10021.pdf
GET /api/orders/10021
GET /api/users/1842
GET /api/invoices?id=10021
GET /documents/8f3c2a
GET /download?file=invoice-10021.pdf
GET /api/orders/10021
GET /api/users/1842
GET /api/invoices?id=10021
GET /documents/8f3c2a
GET /download?file=invoice-10021.pdf

Do not limit the search to numeric IDs. Potential object references can include:

  • Sequential integers

  • UUIDs

  • Usernames

  • Email addresses

  • Slugs

  • Filenames

  • Document IDs

  • Account IDs

  • Tenant IDs

  • Resource IDs inside JSON bodies

  • GraphQL variables

PortSwigger's IDOR testing workflow similarly starts by identifying locations where the application uses user-controlled values to reference objects.

Step 2: Capture a Request for Your Own Object

Suppose User A requests their own invoice:

GET /api/invoices/10021
Authorization: Bearer <user-a-token

GET /api/invoices/10021
Authorization: Bearer <user-a-token

GET /api/invoices/10021
Authorization: Bearer <user-a-token

Send the request to Burp Repeater.

First, confirm that the request legitimately returns User A's invoice. This gives you a baseline response.

Step 3: Change the Object Reference

Change only the object identifier:

GET /api/invoices/10022
Authorization: Bearer <user-a-token

GET /api/invoices/10022
Authorization: Bearer <user-a-token

GET /api/invoices/10022
Authorization: Bearer <user-a-token

Keep the authentication credentials unchanged.

This distinction matters. You are testing whether the same authenticated user can cross an authorization boundary by changing the object reference.

If the response contains User B's invoice, you have strong evidence of an IDOR vulnerability.

Step 4: Compare Responses

Do not rely only on the HTTP status code.

A vulnerable endpoint might return:

HTTP/1.1 200 OK
HTTP/1.1 200 OK
HTTP/1.1 200 OK

but so might a properly protected endpoint that returns an error message in the response body.

What to compare

What to look for

HTTP status

Does the modified request succeed instead of returning an authorization error?

Response body

Does it contain another user's object or sensitive data?

Object identifier

Does the response correspond to the object you requested?

User or tenant data

Does the response identify a different owner or organization?

Response length

Is the response substantially different from a denied request?

Side effects

Did the request modify, delete, or otherwise affect another user's object?

A 200 OK response alone does not prove an IDOR. You need to establish that the response exposes or modifies an object the authenticated user should not be authorized to access.

Likewise, a 403 Forbidden response is not necessarily the end of the investigation. Authorization behavior should be evaluated consistently across the relevant endpoints and request variations.

Step 5: Test Multiple Object References

If changing one ID produces an authorization failure, do not immediately conclude that the entire application is protected.

Test other object references and endpoints.

For example:

GET    /api/orders/10022
GET    /api/orders/10023

PUT    /api/orders/10022
DELETE /api/orders/10022

GET    /api/invoices/10022
GET    /api/invoices/10022/download
GET    /api/orders/10022
GET    /api/orders/10023

PUT    /api/orders/10022
DELETE /api/orders/10022

GET    /api/invoices/10022
GET    /api/invoices/10022/download
GET    /api/orders/10022
GET    /api/orders/10023

PUT    /api/orders/10022
DELETE /api/orders/10022

GET    /api/invoices/10022
GET    /api/invoices/10022/download

Read access and write access may be protected differently.

An application might correctly prevent User A from viewing User B's invoice while still allowing User A to modify or delete it.

Step 6: Test With Two Authorized Test Accounts

The most reliable IDOR test uses two accounts with different ownership boundaries.

For example:

  • User A owns Object A

  • User B owns Object B

First authenticate as User A and access Object A.

Then attempt to access Object B while keeping User A's authentication context:

GET /api/documents/<object-b>
Authorization: Bearer <user-a-token>
GET /api/documents/<object-b>
Authorization: Bearer <user-a-token>
GET /api/documents/<object-b>
Authorization: Bearer <user-a-token>

If User A can access Object B, the authorization boundary has failed.

This is particularly important for multi-tenant applications. The same approach can be used to test whether a user from Tenant A can access objects belonging to Tenant B.

Step 7: Test at Scale Carefully

Once you identify a potentially vulnerable object reference, Burp Intruder can help test a controlled list of identifiers.

For example:

GET /api/orders/§10021§
Authorization: Bearer <user-a-token

GET /api/orders/§10021§
Authorization: Bearer <user-a-token

GET /api/orders/§10021§
Authorization: Bearer <user-a-token

You can supply a small set of known test identifiers and compare the resulting responses.

The purpose is to identify authorization inconsistencies, not to blindly enumerate large ranges.

For authorized security testing, keep the test scope limited to objects and accounts you are permitted to access.

What Confirms an IDOR?

A useful confirmation pattern is:

1. User A is authenticated
2. User A can access Object A
3. Object B belongs to User B
4. User A changes the object reference from A B
5. The server returns or modifies Object B
6. No additional authorization is performed
1. User A is authenticated
2. User A can access Object A
3. Object B belongs to User B
4. User A changes the object reference from A B
5. The server returns or modifies Object B
6. No additional authorization is performed
1. User A is authenticated
2. User A can access Object A
3. Object B belongs to User B
4. User A changes the object reference from A B
5. The server returns or modifies Object B
6. No additional authorization is performed

The key evidence is the mismatch between the authenticated user and the object they were allowed to access.

Burp makes this comparison easier by letting you replay requests with modified parameters and compare the resulting responses.

For larger assessments, this process can be incorporated into a broader authorization-testing workflow rather than performed manually for every endpoint.

Part 10: UUID Misconception: Why Random IDs Don't Fix IDOR

One of the most persistent misconceptions in web security: "we switched to UUIDs so we don't have IDOR anymore."

UUIDs prevent enumeration. They do not prevent unauthorized access once an identifier is obtained.

# With sequential IDs — attacker can enumerate:
GET /api/invoices/1001   User A's invoice
GET /api/invoices/1002   User B's invoice ← IDOR (enumerable)
GET /api/invoices/1003   User C's invoice ← IDOR (enumerable)

# With UUIDs — attacker cannot enumerate, but IDOR still exists:
GET /api/invoices/a4b8c2d1-9e3f-4a7b-8c2d-1e5f6a9b3c7d   IDOR if obtained
# With sequential IDs — attacker can enumerate:
GET /api/invoices/1001   User A's invoice
GET /api/invoices/1002   User B's invoice ← IDOR (enumerable)
GET /api/invoices/1003   User C's invoice ← IDOR (enumerable)

# With UUIDs — attacker cannot enumerate, but IDOR still exists:
GET /api/invoices/a4b8c2d1-9e3f-4a7b-8c2d-1e5f6a9b3c7d   IDOR if obtained
# With sequential IDs — attacker can enumerate:
GET /api/invoices/1001   User A's invoice
GET /api/invoices/1002   User B's invoice ← IDOR (enumerable)
GET /api/invoices/1003   User C's invoice ← IDOR (enumerable)

# With UUIDs — attacker cannot enumerate, but IDOR still exists:
GET /api/invoices/a4b8c2d1-9e3f-4a7b-8c2d-1e5f6a9b3c7d   IDOR if obtained

How attackers obtain UUIDs in practice:




The fix for IDOR is ownership verification at the server. Not obscuring the identifier. Every example in Part 8 works regardless of whether the identifier is sequential or UUID.

How CodeAnt AI Finds IDOR Vulnerabilities at Scale

The fundamental problem with IDOR testing is the cross-user context requirement. Every meaningful IDOR test requires an object owned by one identity and an attempt to access that object using another identity.

That makes IDOR fundamentally different from vulnerabilities that can be detected by sending an isolated payload to an endpoint.

Doing this manually covers the endpoints a tester specifically thinks to test. Doing it systematically across every endpoint, every resource type, every HTTP method, and every workflow step requires automation that understands ownership context, not just request patterns.

CodeAnt AI's penetration testing approach:

  • Authenticates as multiple real user identities simultaneously

  • Enumerates the complete API surface (documented + undiscovered)

  • Maps which resources belong to which identity

  • Tests every endpoint with cross-identity resource references

  • Tracks multi-step workflow state to catch second-order IDORs

  • Verifies exploitability before reporting (zero false positives on IDOR findings)

  • Delivers findings with HTTP request/response proof of concept ready for engineers

The goal is not to report every endpoint that looks suspicious. It is to prove whether an authenticated identity can cross an ownership boundary it should not be able to cross.

When an IDOR is confirmed, the finding should include the affected endpoint, requesting identity, object owner, object accessed, impact, and reproducible proof of the unauthorized access.

The Vulnerability That Scales With Your User Count

Most vulnerabilities have bounded blast radius. An XSS vulnerability affects users who visit a particular page. A SQL injection might expose a single query's results. IDOR is different, its blast radius scales directly with the size of your user base and the sensitivity of your data.

An untested SaaS application processing 10,000 customer records has 10,000 records potentially exposed via a single missing ownership check. A multi-tenant application with 500 customer organizations has 500 organizations' worth of data potentially accessible to any authenticated user who knows to change a parameter.

IDOR isn't a sophisticated attack. The attacker doesn't need specialized tools, exploit code, or advanced techniques. They need patience and arithmetic. What makes it devastating is its combination of simplicity and scale, and the fact that it lives entirely in your application logic, invisible to every scanner your team uses by default.

The only reliable detection is authentication-aware, ownership-tracking, multi-identity testing that systematically asks "what happens if this resource belonged to someone else?" at every endpoint, every method, every step.

That's what CodeAnt AI's penetration testing approach is designed to do: test authorization boundaries across identities, resources, endpoints, and workflows, then validate whether the access violation is actually exploitable.

Book a 30-minute scoping call. Testing starts within 24 hours.

FAQs

Is IDOR the same as broken access control?

Can parameterized queries prevent IDOR?

Does OAuth or JWT prevent IDOR?

Our application uses middleware for access control, does that prevent IDOR?

How often do real applications have IDOR vulnerabilities?

Start Your 14-Day Free Trial

AI code reviews, security and quality trusted by modern engineering teams.

Table of Content
No headings found on page
Ship clean & secure code faster

Get Pentest Report

NO CC REQUIRED