Code Security

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

Amartya | CodeAnt AI Code Review Platform
Sonali Sood

Founding GTM, CodeAnt AI

The Breach That Doesn't Require Any Hacking Skill

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 increment exposed the orders of every customer on the platform.

No exploit. No vulnerability scanner. No malware. Just arithmetic.

This is IDOR; Insecure Direct Object Reference.

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: The Terminology

BOLA (Broken Object Level Authorization) is OWASP's API Security Top 10 name for the same class of vulnerability. The terms are used interchangeably in the industry:

  • IDOR: original term from OWASP Web Application Security Testing Guide, typically used for web application contexts

  • BOLA: OWASP API Security Top 10 terminology, specifically for API-driven access patterns

Both describe the same failure: object-level authorization is absent or inconsistent. This guide uses both terms as appropriate to context.

Part 2: The Complete IDOR Taxonomy: Every Variant

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

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

Autorize 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

A PUT or DELETE IDOR against another user's resource scores 8.8 or higher. GET IDORs are often CVSS 6.5.

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: Fixing IDOR: Pattern by Pattern

Fix Pattern 1: Always Derive Identity From Session, Never From Input

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: Multi-Tenant Scope at the ORM Level

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 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: IDOR Testing Automation: What Burp Autorize Actually Does

Burp Suite's Autorize extension is the standard tool for semi-automated IDOR testing. Most engineers set it up and trust the red/green output without fully understanding what it is and is not checking. That misunderstanding is how IDORs get missed even on applications that have been "tested with Autorize."

The mechanics are straightforward. You authenticate as User A in Burp Suite, configure Autorize with User B's authentication token, then browse the application normally as User A. For every request your browser sends, Autorize automatically replays that same request twice: once using User B's token, and once with no token at all. It then compares the response body and status code from all three versions and color-codes the result.

Color

What It Means

What To Do

Green

Autorize detected a meaningful difference between User A and User B responses

Authorization is likely working for this request. Note it and move on.

Red

Responses are similar across users

Potential IDOR. Review the actual response content and confirm manually.

Yellow

Response length is similar but not identical

Requires manual review. The content difference may or may not indicate authorization.

The color-coding gives you a fast triage signal across dozens or hundreds of requests. That is genuinely useful. The problem is what Autorize does not check, and the list is significant enough that treating Autorize as comprehensive coverage is a mistake.

Limitation

Why It Matters

Cannot detect IDORs where response structure is identical but content differs

If User A and User B both have invoices with the same field structure, a red result still requires you to check whether the data is actually User B's.

Does not handle multi-step workflows

Autorize captures individual requests. Second-order IDORs that span multiple steps are invisible to it.

Requires manual browsing of every workflow

Autorize only tests what you actually navigate through. Endpoints you do not visit are not tested.

Does not systematically test POST, PUT, and DELETE mutations

Write and delete IDORs are higher severity than read IDORs and require deliberate testing that Autorize does not drive automatically.

Has no concept of resource ownership

Autorize does not know that INV-10021 belongs to User A and INV-10022 belongs to User B. It compares response similarity, not ownership correctness. A request that returns User B's data under User A's token looks identical to a legitimate request under User A's token unless you read the actual response body.

The practical conclusion is that Autorize is a useful first pass over the authenticated request surface, not a substitute for the structured methodology in the previous section. Use it to flag candidates quickly, then apply the per-phase checklist to confirm and extend coverage to the areas Autorize structurally cannot reach.

For GraphQL and complex API surfaces, Autorize needs to be supplemented with manual testing using the methodology in Part 6.

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.

Part 11: How CodeAnt AI Finds IDOR at Scale

The fundamental problem with IDOR testing is the cross-user context requirement. Every IDOR test requires:

  1. Resource A owned by User X

  2. Attempt to access Resource A as User Y (who doesn't own it)

  3. Comparison of response

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 CVSS 9+ guarantee isn't marketing. It's operational: CodeAnt AI's testing methodology covers the authorization surface that automated scanners miss and that manual testing covers incompletely. IDOR is the single most common CVSS 7+ finding in modern SaaS applications. If we don't find one in your application, you don't pay.

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 does. CVSS 9+ or you don't pay.

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?

Table of Contents

Start Your 14-Day Free Trial

AI code reviews, security, and quality trusted by modern engineering teams. No credit card required!

Share blog: