Back to Blog

How to Test for Insecure Direct Object References (IDOR) Vulnerabilities

A user changes their URL from /api/users/123 to /api/users/124 and sees someone else's data. IDOR is one of the most common yet overlooked vulnerabilities. Learn how to test for it systematically and prevent unauthorized data access.

Scanly App

Published

11 min read

Reading time

Related articles: Also see the OWASP context behind IDOR and the other top application vulnerabilities, a comprehensive guide to testing all forms of broken access control, and API security testing where IDOR vulnerabilities most commonly surface.

How to Test for Insecure Direct Object References (IDOR) Vulnerabilities

User Alice logs in. Her profile URL is /api/users/42. She changes it to/api/users/43. She now sees Bob's private data�email, address, order history. No authentication error. No authorization check. Just unrestricted access to anyone's data.

This is IDOR (Insecure Direct Object Reference), and it's everywhere.

IDOR consistently ranks in the OWASP Top 10 under "Broken Access Control." It's deadly simple: applications expose internal object IDs (database primary keys, file names) without checking if the current user should access them.

The scary part? 73% of APIs have IDOR vulnerabilities, according to security research. Most go undetected because:

  1. Functional tests only use valid user data
  2. Security scans don't test authorization logic
  3. Code review misses authorization checks in every endpoint

This guide shows you how to systematically test for IDOR vulnerabilities and implement authorization testing that catches them before attackers do.

Understanding IDOR

graph LR
    A[User Request] --> B{Authentication?}
    B -->|No| C[401 Unauthorized]
    B -->|Yes| D{IDOR Check}

    D -->|Vulnerable| E[Returns ANY Resource<br/>by ID]
    D -->|Secure| F{Authorization?}

    F -->|Unauthorized| G[403 Forbidden]
    F -->|Authorized| H[Returns User's Resource]

    style E fill:#ffccbc
    style H fill:#c5e1a5

IDOR vs Proper Authorization

// ? IDOR VULNERABLE: No authorization check
app.get('/api/orders/:id', authenticate, async (req, res) => {
  const order = await db.orders.findById(req.params.id);
  res.json(order); // Returns ANY order by ID!
});

// ? SECURE: Checks resource ownership
app.get('/api/orders/:id', authenticate, async (req, res) => {
  const order = await db.orders.findById(req.params.id);

  if (!order) {
    return res.status(404).json({ error: 'Order not found' });
  }

  // Authorization check
  if (order.userId !== req.user.id && !req.user.isAdmin) {
    return res.status(403).json({ error: 'Access denied' });
  }

  res.json(order);
});

Common IDOR Patterns

Type Example Vulnerable Parameter
Numeric ID /api/users/123 Sequential integer
UUID /api/users/a1b2c3-... UUID (harder but still IDOR)
Filename /files/invoice-2024.pdf Predictable filename
Email /api/users/alice@example.com Email address
Username /api/profiles/@alice Username
Query Parameter /api/data?userId=123 URL parameter
Body Parameter {"accountId": 123} POST/PUT body

The IDOR Testing Strategy

graph TD
    A[Identify Resources] --> B[Map Endpoints]
    B --> C[Test Horizontal Access]
    C --> D[Test Vertical Access]
    D --> E[Test State Changes]
    E --> F[Automate Tests]
    F --> G[Continuous Monitoring]

    style A fill:#bbdefb
    style C fill:#fff9c4
    style D fill:#fff9c4
    style E fill:#fff9c4
    style F fill:#c5e1a5

1. Discovery: Finding IDOR Candidates

// idor-discovery.ts
import { Page } from '@playwright/test';

interface IDORCandidate {
  endpoint: string;
  method: 'GET' | 'POST' | 'PUT' | 'DELETE';
  idParameter: string;
  idType: 'numeric' | 'uuid' | 'string';
  idLocation: 'path' | 'query' | 'body';
  exampleId: string;
}

class IDORDiscovery {
  private candidates: IDORCandidate[] = [];

  async discoverFromPage(page: Page): Promise<IDORCandidate[]> {
    // Intercept all API requests
    page.on('request', (request) => {
      const url = new URL(request.url());

      // Check path parameters
      const pathIds = this.extractPathIds(url.pathname);
      pathIds.forEach(({ param, type, value }) => {
        this.candidates.push({
          endpoint: this.normalizeEndpoint(url.pathname, param),
          method: request.method() as any,
          idParameter: param,
          idType: type,
          idLocation: 'path',
          exampleId: value,
        });
      });

      // Check query parameters
      const queryIds = this.extractQueryIds(url.searchParams);
      queryIds.forEach(({ param, type, value }) => {
        this.candidates.push({
          endpoint: url.pathname,
          method: request.method() as any,
          idParameter: param,
          idType: type,
          idLocation: 'query',
          exampleId: value,
        });
      });

      // Check body parameters (POST/PUT)
      if (['POST', 'PUT', 'PATCH'].includes(request.method())) {
        try {
          const body = JSON.parse(request.postData() || '{}');
          const bodyIds = this.extractBodyIds(body);
          bodyIds.forEach(({ param, type, value }) => {
            this.candidates.push({
              endpoint: url.pathname,
              method: request.method() as any,
              idParameter: param,
              idType: type,
              idLocation: 'body',
              exampleId: value,
            });
          });
        } catch (e) {
          // Not JSON body
        }
      }
    });

    return this.candidates;
  }

  private extractPathIds(pathname: string): Array<{ param: string; type: string; value: string }> {
    const ids: Array<{ param: string; type: string; value: string }> = [];
    const segments = pathname.split('/');

    segments.forEach((segment, index) => {
      // Numeric ID
      if (/^\d+$/.test(segment)) {
        ids.push({
          param: segments[index - 1] || 'id',
          type: 'numeric',
          value: segment,
        });
      }

      // UUID
      if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) {
        ids.push({
          param: segments[index - 1] || 'id',
          type: 'uuid',
          value: segment,
        });
      }
    });

    return ids;
  }

  private extractQueryIds(params: URLSearchParams): Array<{ param: string; type: string; value: string }> {
    const ids: Array<{ param: string; type: string; value: string }> = [];

    for (const [key, value] of params.entries()) {
      if (key.toLowerCase().includes('id') || key.toLowerCase().includes('user')) {
        ids.push({
          param: key,
          type: /^\d+$/.test(value) ? 'numeric' : 'string',
          value,
        });
      }
    }

    return ids;
  }

  private extractBodyIds(body: any, prefix = ''): Array<{ param: string; type: string; value: string }> {
    const ids: Array<{ param: string; type: string; value: string }> = [];

    for (const [key, value] of Object.entries(body)) {
      const fullKey = prefix ? `${prefix}.${key}` : key;

      if (key.toLowerCase().includes('id') && typeof value === 'string') {
        ids.push({
          param: fullKey,
          type: /^\d+$/.test(value) ? 'numeric' : 'string',
          value,
        });
      }

      if (typeof value === 'object' && value !== null) {
        ids.push(...this.extractBodyIds(value, fullKey));
      }
    }

    return ids;
  }

  private normalizeEndpoint(pathname: string, paramToReplace: string): string {
    return pathname
      .replace(/\/\d+/g, '/:id')
      .replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id');
  }
}

2. Horizontal IDOR Testing (Same Privilege Level)

// idor-horizontal-test.ts
import { test, expect } from '@playwright/test';

/**
 * Horizontal IDOR: User A accessing User B's resources (same role)
 */
test.describe('Horizontal IDOR Tests', () => {
  let userAToken: string;
  let userBToken: string;
  let userAOrderId: string;
  let userBOrderId: string;

  test.beforeAll(async ({ request }) => {
    // Create two regular users
    const userA = await request.post('/api/auth/register', {
      data: {
        email: 'alice@example.com',
        password: 'SecurePass123!',
      },
    });
    userAToken = (await userA.json()).token;

    const userB = await request.post('/api/auth/register', {
      data: {
        email: 'bob@example.com',
        password: 'SecurePass123!',
      },
    });
    userBToken = (await userB.json()).token;

    // Create orders for both users
    const orderA = await request.post('/api/orders', {
      headers: { Authorization: `Bearer ${userAToken}` },
      data: { items: [{ productId: 1, quantity: 2 }] },
    });
    userAOrderId = (await orderA.json()).id;

    const orderB = await request.post('/api/orders', {
      headers: { Authorization: `Bearer ${userBToken}` },
      data: { items: [{ productId: 2, quantity: 1 }] },
    });
    userBOrderId = (await orderB.json()).id;
  });

  test('should NOT allow user A to view user B order (GET)', async ({ request }) => {
    const response = await request.get(`/api/orders/${userBOrderId}`, {
      headers: { Authorization: `Bearer ${userAToken}` },
    });

    expect(response.status()).toBe(403); // Forbidden

    const body = await response.json();
    expect(body.error).toContain('Access denied');
  });

  test('should NOT allow user A to update user B order (PUT)', async ({ request }) => {
    const response = await request.put(`/api/orders/${userBOrderId}`, {
      headers: { Authorization: `Bearer ${userAToken}` },
      data: { status: 'cancelled' },
    });

    expect(response.status()).toBe(403);
  });

  test('should NOT allow user A to delete user B order (DELETE)', async ({ request }) => {
    const response = await request.delete(`/api/orders/${userBOrderId}`, {
      headers: { Authorization: `Bearer ${userAToken}` },
    });

    expect(response.status()).toBe(403);
  });

  test('should NOT allow accessing other user via query parameter', async ({ request }) => {
    // Many apps forget to check query params!
    const response = await request.get('/api/orders', {
      headers: { Authorization: `Bearer ${userAToken}` },
      params: { userId: 'user-b-id' }, // Trying to access B's orders
    });

    const orders = await response.json();

    // Should only see own orders
    orders.forEach((order: any) => {
      expect(order.userId).not.toBe('user-b-id');
    });
  });

  test('should NOT allow modifying other user ID in request body', async ({ request }) => {
    const response = await request.post('/api/orders', {
      headers: { Authorization: `Bearer ${userAToken}` },
      data: {
        userId: 'user-b-id', // Attempting to create order for B
        items: [{ productId: 3, quantity: 1 }],
      },
    });

    const order = await response.json();

    // Order should belong to A, not B
    expect(order.userId).not.toBe('user-b-id');
  });
});

3. Vertical IDOR Testing (Privilege Escalation)

// idor-vertical-test.ts

/**
 * Vertical IDOR: Regular user accessing admin resources (privilege escalation)
 */
test.describe('Vertical IDOR Tests', () => {
  let userToken: string;
  let adminToken: string;
  let adminId: string;

  test.beforeAll(async ({ request }) => {
    // Create regular user
    const user = await request.post('/api/auth/register', {
      data: { email: 'user@example.com', password: 'Pass123!' },
    });
    userToken = (await user.json()).token;

    // Create admin user
    const admin = await request.post('/api/auth/register', {
      data: { email: 'admin@example.com', password: 'Pass123!', role: 'admin' },
    });
    const adminData = await admin.json();
    adminToken = adminData.token;
    adminId = adminData.userId;
  });

  test('should NOT allow regular user to access admin panel', async ({ request }) => {
    const response = await request.get('/api/admin/dashboard', {
      headers: { Authorization: `Bearer ${userToken}` },
    });

    expect(response.status()).toBe(403);
  });

  test('should NOT allow regular user to list all users', async ({ request }) => {
    const response = await request.get('/api/admin/users', {
      headers: { Authorization: `Bearer ${userToken}` },
    });

    expect(response.status()).toBe(403);
  });

  test('should NOT allow regular user to modify user roles', async ({ request }) => {
    const response = await request.put(`/api/users/${adminId}`, {
      headers: { Authorization: `Bearer ${userToken}` },
      data: { role: 'admin' }, // Attempting privilege escalation
    });

    expect(response.status()).toBe(403);
  });

  test('should NOT allow regular user to delete other users', async ({ request }) => {
    const response = await request.delete(`/api/users/${adminId}`, {
      headers: { Authorization: `Bearer ${userToken}` },
    });

    expect(response.status()).toBe(403);
  });
});

4. Automated IDOR Scanner

// idor-scanner.ts
interface IDORTestResult {
  endpoint: string;
  vulnerable: boolean;
  severity: 'critical' | 'high' | 'medium';
  details: string;
  proof: any;
}

class IDORScanner {
  async scanEndpoint(
    candidate: IDORCandidate,
    user1Token: string,
    user2Token: string,
    user1Id: string,
    user2Id: string,
  ): Promise<IDORTestResult> {
    // Test if user1 can access user2's resource
    const url = this.buildTestUrl(candidate, user2Id);

    const response = await fetch(url, {
      method: candidate.method,
      headers: {
        Authorization: `Bearer ${user1Token}`,
        'Content-Type': 'application/json',
      },
    });

    const vulnerable = response.status === 200;

    return {
      endpoint: candidate.endpoint,
      vulnerable,
      severity: this.determineSeverity(candidate, vulnerable),
      details: vulnerable ? `User 1 can access User 2's resource at ${url}` : `Properly secured: ${response.status}`,
      proof: vulnerable ? await response.json() : null,
    };
  }

  private buildTestUrl(candidate: IDORCandidate, targetUserId: string): string {
    let url = `${process.env.API_BASE_URL}${candidate.endpoint}`;

    if (candidate.idLocation === 'path') {
      url = url.replace(':id', targetUserId);
    } else if (candidate.idLocation === 'query') {
      url += `?${candidate.idParameter}=${targetUserId}`;
    }

    return url;
  }

  private determineSeverity(candidate: IDORCandidate, vulnerable: boolean): 'critical' | 'high' | 'medium' {
    if (!vulnerable) return 'medium';

    // Critical if reading/modifying sensitive data
    if (candidate.endpoint.includes('user') || candidate.endpoint.includes('account')) {
      return 'critical';
    }

    // Critical if write operations
    if (['DELETE', 'PUT', 'PATCH'].includes(candidate.method)) {
      return 'critical';
    }

    return 'high';
  }

  async scanApplication(): Promise<IDORTestResult[]> {
    console.log('?? Starting IDOR vulnerability scan...');

    // Create two test users
    const [user1, user2] = await this.createTestUsers();

    // Discover IDOR candidates
    const discovery = new IDORDiscovery();
    const candidates = await discovery.discoverFromPage(/* page */);

    console.log(`Found ${candidates.length} potential IDOR endpoints`);

    // Test each candidate
    const results: IDORTestResult[] = [];
    for (const candidate of candidates) {
      const result = await this.scanEndpoint(candidate, user1.token, user2.token, user1.id, user2.id);
      results.push(result);

      if (result.vulnerable) {
        console.log(`??  VULNERABLE: ${candidate.endpoint}`);
      }
    }

    const vulnerableCount = results.filter((r) => r.vulnerable).length;
    console.log(`\n?? Scan complete: ${vulnerableCount}/${results.length} vulnerable endpoints`);

    return results;
  }

  private async createTestUsers(): Promise<[{ token: string; id: string }, { token: string; id: string }]> {
    // Implementation depends on your auth system
    return [
      { token: 'user1-token', id: 'user1-id' },
      { token: 'user2-token', id: 'user2-id' },
    ];
  }
}

5. CI/CD Integration

# .github/workflows/idor-scan.yml
name: IDOR Security Scan

on:
  pull_request:
  schedule:
    - cron: '0 3 * * *'

jobs:
  idor-scan:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Start test environment
        run: |
          docker-compose up -d
          npm run db:migrate

      - name: Run IDOR scanner
        run: |
          npm install
          npx ts-node security/idor-scanner.ts

      - name: Check for vulnerabilities
        run: |
          VULNS=$(jq '.vulnerableCount' idor-report.json)
          if [ "$VULNS" -gt 0 ]; then
            echo "? Found $VULNS IDOR vulnerabilities"
            exit 1
          fi

Prevention Strategies

Strategy Implementation Effectiveness
Authorization Middleware Centralized access control ?????
Indirect Object References Map UUID to internal ID ?????
Attribute-Based Access Control Fine-grained permissions ?????
Rate Limiting ID Enumeration Slow down brute force ?????
Audit Logging Detect unauthorized access ?????

Secure Implementation Example

// Reusable authorization middleware
function authorizeResource(resourceType: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const resource = await db[resourceType].findById(req.params.id);

    if (!resource) {
      return res.status(404).json({ error: 'Not found' });
    }

    // Check ownership
    if (resource.userId !== req.user.id && !req.user.isAdmin) {
      // Log unauthorized access attempt
      await auditLog.log({
        action: 'UNAUTHORIZED_ACCESS_ATTEMPT',
        userId: req.user.id,
        resource: resourceType,
        resourceId: req.params.id,
      });

      return res.status(403).json({ error: 'Access denied' });
    }

    req.resource = resource;
    next();
  };
}

// Usage
app.get('/api/orders/:id', authenticate, authorizeResource('orders'), (req, res) => {
  res.json(req.resource); // Already authorized
});

Conclusion

IDOR is one of the easiest vulnerabilities to introduce and one of the most damaging. 73% of APIs have them, but systematic testing catches them before attackers do.

Key takeaways:

  1. Test horizontal and vertical access for every resource
  2. Automate IDOR testing in CI/CD pipelines
  3. Implement authorization middleware for all endpoints
  4. Use indirect object references (UUIDs over sequential IDs)
  5. Log and monitor authorization failures

Start testing today:

  1. Run IDOR discovery on your app
  2. Test horizontal access (user A ? user B resources)
  3. Test vertical access (user ? admin resources)
  4. Automate tests in CI/CD
  5. Implement centralized authorization

Don't wait for a security researcher to report stolen data. Test for IDOR now.

Ready to automate security testing? Sign up for ScanlyApp and catch IDOR vulnerabilities before they become breaches.

Related Posts