Skip to content

CWE-639: Authorization Bypass Through User-Controlled Key (Insecure Direct Object Reference - IDOR)

Overview

Insecure Direct Object Reference (IDOR) occurs when an application exposes direct references to internal objects (database keys, filenames, directory paths) and fails to verify the user is authorized to access the referenced object. Attackers simply modify the object reference (e.g., changing GET /api/account?id=123 to id=124) to access unauthorized data.

IDOR is a specific type of broken access control where the vulnerability stems from exposing and trusting direct object references (sequential IDs, filenames, API parameters, hidden form fields, direct paths) without authorization checks.

OWASP Classification

A01:2025 - Broken Access Control

Risk

IDOR vulnerabilities enable unauthorized data access and manipulation:

  • Horizontal privilege escalation: Access other users' data (view other customers' orders, invoices, messages)
  • Data enumeration: Systematically harvest all records by iterating IDs
  • Privacy violations: Access PII, financial records, health information without authorization
  • Account takeover: Modify other users' email addresses, passwords
  • Business data theft: Export competitor pricing, customer lists, proprietary data
  • Compliance violations: Unauthorized access violates GDPR, HIPAA, SOC 2
  • Financial fraud: Access and manipulate payment information, refund requests
  • Information disclosure: Learn about other users, order volumes, pricing

IDOR is one of the most common web vulnerabilities and often trivial to exploit.

Remediation Strategy

Implement Object-Level Authorization (Primary Defense)

Verify user authorization for every object access:

Authorization pattern for EVERY protected resource:

BAD - No authorization check:

function getOrder(orderId) {
    return database.query("SELECT * FROM orders WHERE id = ?", orderId);
    // Anyone can access any order by knowing/guessing the ID
}

GOOD - Verify ownership:

function getOrder(orderId, currentUser) {
    // Check both existence AND ownership
    order = database.query(
        "SELECT * FROM orders WHERE id = ? AND user_id = ?",
        orderId, currentUser.id
    );

    if (!order) {
        // Don't reveal whether order exists or access denied
        throw NotFoundException("Order not found");
    }

    return order;
}

GOOD - With role-based access:

function getOrder(orderId, currentUser) {
    order = database.query("SELECT * FROM orders WHERE id = ?", orderId);

    if (!order) {
        throw NotFoundException("Order not found");
    }

    // Verify user owns order OR has admin privileges
    if (order.user_id != currentUser.id && !currentUser.hasRole('ADMIN')) {
        throw ForbiddenException("Access denied");
    }

    return order;
}

Why this works: Every object access explicitly verified; users can only access authorized resources.

Use Indirect Object References

Replace predictable IDs with unpredictable references:

Indirect reference approaches:

  1. Use UUIDs instead of sequential IDs:

    • Database ID: 1, 2, 3, 4 (predictable, enumerable)
    • UUID: a3f2e8d9-4b7c-4e2a-9d3f-8c1e5b7a9f2e (unpredictable)

    Still need authorization check, but enumeration is impractical GET /api/orders/a3f2e8d9-4b7c-4e2a-9d3f-8c1e5b7a9f2e

  2. Session-based mapping:

    // Server creates session-specific mapping
    session['order_refs'] = {
        '1': 'db_id_12847',
        '2': 'db_id_19234',
        '3': 'db_id_10293'
    }
    
    // User sees: GET /api/orders/1 (session-specific ID)
    // Server maps to: database ID 12847
    // Different user's session: 1 maps to different database ID
    
  3. Encrypted references:

    // Encrypt the real ID with session key
    encryptedId = encrypt(databaseId, sessionKey)
    GET /api/orders/{encryptedId}
    
    // Server decrypts and verifies ownership
    databaseId = decrypt(encryptedId, sessionKey)
    

CRITICAL: Even with indirect references, ALWAYS verify authorization

Scope Queries to Current User

Design queries to inherently limit access:

User-scoped data access:

BAD - Trust user-provided ID:

// User provides accountId, we trust it
SELECT * FROM accounts WHERE id = {userProvidedAccountId}

GOOD - Scope to current user:

// Automatically limit to current user's accounts
SELECT * FROM accounts 
WHERE id = {userProvidedAccountId} 
  AND user_id = {currentUserId}

BETTER - Don't expose IDs at all:

// List user's accounts without needing IDs
SELECT * FROM accounts WHERE user_id = {currentUserId}

Database-level security:

  • Use row-level security (PostgreSQL RLS, Oracle VPD)
  • Create views that filter by current user
  • Use stored procedures with built-in authorization

Implement Access Control Lists (ACLs)

For shared resources, track explicit permissions:

ACL pattern for shared documents:

// Documents table
| id  | filename      | owner_id |
|-----|---------------|----------|
| 101 | report.pdf    | 5        |

// Document permissions table (ACL)
| document_id | user_id | permission |
|-------------|---------|------------|
| 101         | 5       | owner      |
| 101         | 7       | read       |
| 101         | 9       | write      |
// Authorization check
function getDocument(documentId, currentUser) {
    // Check if user has ANY permission on this document
    permission = database.query(
        "SELECT permission FROM doc_permissions 
         WHERE document_id = ? AND user_id = ?",
        documentId, currentUser.id
    );

    if (!permission || !['owner', 'read', 'write'].includes(permission)) {
        throw ForbiddenException("Access denied");
    }

    return loadDocument(documentId);
}

Remediation Steps

Core principle: Enforce object-level authorization; never trust user-supplied object identifiers (prevent IDOR).

  1. Identify direct object references
  2. Locate missing authorization checks
  3. Trace object access pattern
  4. Add object-level authorization: Verify current user owns or has permission for the requested object before returning it
  5. Implement access control checks: Add ownership verification to every object retrieval (query filters, ACL lookups, permission checks)
  6. Test with different users: Attempt to access User A's resources as User B using modified IDs (should return 403/404)
  7. Consider indirect references: Replace sequential IDs with UUIDs or session-specific mappings to prevent enumeration

Horizontal Privilege Escalation

Test accessing other users' resources:

  1. Login as User A
  2. Access own resource: GET /api/orders/123 → 200 OK
  3. Note resource belongs to User A

  4. Login as User B

  5. Try accessing User A's resource: GET /api/orders/123 Expected: 403 Forbidden or 404 Not Found
  6. Repeat with:

    • GET requests (read)
    • PUT requests (update)
    • DELETE requests (delete)
    • POST requests (create with other user's ID)

Expected: All cross-user access attempts denied

ID Enumeration

Test systematic data harvesting:

# Iterate through sequential IDs
for id in range(1, 1000):
    response = requests.get(f'/api/documents/{id}')
    if response.status_code == 200:
        print(f'Accessible: {id}')

Expected: Only IDs user owns return 200; others return 403/404

Parameter Tampering

Test modifying hidden or non-obvious parameters:

  1. Original request: POST /api/transfer

    {"from_account": 123, "to_account": 456, "amount": 100}
    
  2. Modified request (change from_account to someone else's): POST /api/transfer

    {"from_account": 999, "to_account": 456, "amount": 100}
    

Expected: Request rejected; cannot transfer from account you don't own

File Access Testing

Test direct file access:

  1. Upload file as User A: /uploads/user_123_document.pdf
  2. Login as User B
  3. Try accessing: GET /uploads/user_123_document.pdf

Expected: Access denied

Test path traversal combined with IDOR:

  • /api/files?path=../../../etc/passwd
  • /download?file=../../other_user/secrets.txt

Expected: All path traversal attempts blocked

Mass Assignment Testing

Test adding unexpected fields to modify other users' data:

POST /api/users/profile
{
  "name": "John Doe",
  "email": "john@example.com",
  "user_id": 999,  // Trying to update different user
  "is_admin": true  // Trying to elevate privileges
}

Expected: Extra fields ignored or rejected; profile update only affects current user

Additional Resources