Skip to content

CWE-566: Authorization Bypass Through User-Controlled Key

Overview

CWE-566 occurs when applications use user-controlled identifiers (such as user IDs, document IDs, account numbers, or resource keys) directly in database lookups or access decisions without verifying that the authenticated user is authorized to access that specific resource. This vulnerability, commonly known as Insecure Direct Object Reference (IDOR), enables horizontal privilege escalation where attackers can access other users' data simply by manipulating ID parameters.

Primary Defence: Always verify ownership or permissions before accessing resources. Include the authenticated user's ID in database queries (e.g., WHERE id = ? AND user_id = ?) to ensure users can only access their own data. Never trust user-supplied IDs alone - combine resource ID lookups with ownership checks at the database or authorization layer.

OWASP Classification

A01:2025 - Broken Access Control

Risk

High to Critical: Authorization bypass enables:

  • Accessing other users' sensitive data (horizontal privilege escalation)
  • Personal Identifiable Information (PII) exposure
  • Financial fraud through unauthorized account access
  • Data tampering and modification of others' resources
  • Complete violation of multi-tenant isolation
  • Compliance violations (GDPR, HIPAA, PCI-DSS)
  • Business logic bypass in multi-user systems

Remediation Steps

Core principle: Never use user-controlled keys/IDs to bypass authorization; enforce object-level access control on every resource lookup.

Trace the Data Path and Identify Authorization Gaps

Analyze the data flow from user input to resource access:

  • Source: Identify where resource IDs enter (URL parameters, POST data, API path parameters, query strings)
  • Authorization Check: Determine if/where ownership verification occurs
  • Data Access: Locate database queries or file access using the user-controlled ID
  • Response: Check if unauthorized access returns proper error codes (403/404)
  • Missing Validation: Identify direct lookups without ownership checks

Validate Ownership Before Access (Primary Defense)

Vulnerable pattern - Trusts user-provided ID without authorization:

function getDocument(doc_id):
    doc = database.get(doc_id)  // No authorization check!
    return doc

// Attacker: GET /api/document/9999 (someone else's document)
// Returns document even though attacker doesn't own it!

Secure pattern - Verify ownership before access:

function getDocument(doc_id, current_user):
    doc = database.findWhere(
        id = doc_id,
        owner_id = current_user.id  // Verify ownership!
    )
    if doc is null:
        return 404_NOT_FOUND
    return doc

Even better - Separate authorization check:

function getDocument(doc_id, current_user):
    doc = database.get(doc_id)

    // Explicit authorization check
    if doc.owner_id != current_user.id:
        return 403_FORBIDDEN

    return doc

Why this works: Always verify the current authenticated user has permission to access the requested resource. Never trust that because they know the ID, they should have access.

Scope All Queries to Current User

Always include the authenticated user's ID in resource queries to ensure automatic ownership filtering:

Vulnerable - Global query without ownership filter:

function getOrder(order_id):
    return database.findById(order_id)
    // Returns ANY order, regardless of ownership

Secure - Query scoped to current user:

function getOrder(order_id, current_user):
    return database.findWhere(
        id = order_id,
        user_id = current_user.id
    )
    // Only returns order if it belongs to current user

List operations - Always filter by user:

function getOrders(current_user):
    return database.findWhere(
        user_id = current_user.id
    )
    // Returns only current user's orders

Why this works: By embedding the user ID directly into database queries, you create a secure-by-default pattern where unauthorized access is impossible at the database level. The query physically cannot return resources that don't belong to the authenticated user, making this approach more reliable than application-level checks that might be bypassed.

Use Indirect References or Access Control Lists (Defense in Depth)

METHOD 1: Indirect references (session-based mapping)

function listDocuments(current_user):
    docs = database.findWhere(owner_id = current_user.id)

    // Create temporary session mappings
    session['doc_map'] = {
        'doc0': docs[0].id,
        'doc1': docs[1].id,
        ...
    }

    return [{'ref': 'doc0', 'name': docs[0].name}, ...]

function getDocument(ref, current_user):
    actual_id = session['doc_map'][ref]
    if actual_id is null:
        return 404_NOT_FOUND

    doc = database.get(actual_id)
    // Already validated through session mapping
    return doc

Why this works: Indirect references prevent ID enumeration attacks by never exposing real database IDs to users. Session-based mappings ensure users can only request resources they've already listed, preventing guessing attacks.

METHOD 2: UUIDs instead of sequential IDs

// Database schema
table documents {
    id: UUID (primary key)  // e.g., '550e8400-e29b-41d4-a716-446655440000'
    owner_id: INTEGER
    ...
}

// Harder to guess, but STILL need authorization checks!

Why this works: UUIDs (128-bit randomly generated identifiers) make it computationally infeasible for attackers to guess valid resource IDs through enumeration. However, this is defense-in-depth only - authorization checks are still mandatory since UUIDs may be exposed through other means (logs, URLs shared by users, browser history, etc.).

METHOD 3: Access Control Lists (ACLs)

function canAccess(user, document):
    return (
        document.owner_id == user.id OR
        user.id IN document.shared_users OR
        user.is_admin
    )

function getDocument(doc_id, current_user):
    doc = database.get(doc_id)

    if not canAccess(current_user, doc):
        return 403_FORBIDDEN

    return doc

Why this works: ACLs support complex sharing and permission scenarios beyond simple ownership. The canAccess function centralizes authorization logic, making it easier to audit and maintain. This pattern is essential for collaborative features where multiple users need access to the same resource.

Implement Centralized Authorization Logic

Reusable authorization function:

function requireOwnership(resource_id, current_user):
    resource = database.get(resource_id)

    if resource.owner_id != current_user.id:
        throw FORBIDDEN_ERROR

    return resource

Why this works: Centralizing authorization checks into reusable functions ensures consistent enforcement across your application. This reduces code duplication, makes security policies easier to audit and update, and prevents inconsistencies where some endpoints check authorization while others don't.

Fine-grained permissions - Bit flags for different access levels:

Permissions:
    READ = 1
    WRITE = 2
    DELETE = 4
    SHARE = 8

function hasPermission(user, resource, permission):
    if resource.owner_id == user.id:
        return true  // Owner has all permissions

    acl = database.getACL(
        user_id = user.id,
        resource_id = resource.id
    )

    if acl is null:
        return false

    return (acl.permissions & permission) == permission

function deleteDocument(doc_id, current_user):
    doc = database.get(doc_id)

    if not hasPermission(current_user, doc, Permissions.DELETE):
        return 403_FORBIDDEN

    database.delete(doc)
    return 204_NO_CONTENT

Why this works: Bit-flag permissions enable fine-grained access control where users can have different permission levels on the same resource. For example, a user might have READ permission but not DELETE permission on a shared document. Using bitwise operations is efficient and allows combining multiple permissions (e.g., READ | WRITE).

Test for IDOR Vulnerabilities

Manual testing strategies:

  • Cross-user access testing: Authenticate as User A and attempt to access resources belonging to User B by changing IDs in URLs or API requests. Verify that requests return 403 Forbidden, not 200 OK with other users' data.
  • Sequential ID enumeration: Try accessing resources with sequential or predictable IDs (1, 2, 3, etc.) to verify that authorization checks prevent unauthorized access. Most requests should return 403/404, not 200.
  • Unauthenticated access: Attempt to access protected resources without authentication tokens. Should return 401 Unauthorized.

Automated testing approach:

  • Multi-user test scenarios: Create multiple test users with their own resources, then verify each user can only access their own data and receives 403 errors when attempting to access others' resources.
  • Ownership verification tests: Ensure all resource access endpoints validate ownership by checking that queries are properly scoped to the authenticated user.
  • Authorization bypass attempts: Test edge cases such as null IDs, negative IDs, very large IDs, and special characters to ensure authorization checks handle all inputs securely.

Common Vulnerable Patterns

Direct Database Lookup Without Authorization

// VULNERABLE - No ownership check
function viewProfile(user_id):
    user = database.get(user_id)  // Returns ANY user!
    return render(user)

// Attacker: GET /profile/9999
// Can view any user's profile by changing the ID

Why this is vulnerable: The function trusts the user-supplied user_id without verifying the authenticated user has permission to view that profile. An attacker can enumerate IDs (1, 2, 3...) to access all user profiles in the system.

Account Number as Key

// VULNERABLE - No ownership verification
function viewAccount(account_num):
    account = database.findWhere(number = account_num)
    return account.balance  // No authorization check!

// Attacker: GET /account/123456789
// Can view any account balance by guessing account numbers

Why this is vulnerable: Even when using non-sequential identifiers like account numbers, the lack of ownership verification allows attackers to access other users' financial data. The pattern assumes possession of the account number implies authorization, which is incorrect.

File Access by User-Controlled Path

// VULNERABLE - Direct file access
function download(filename):
    return sendFile('/uploads/' + filename)  // Anyone's files!

// Attacker: GET /download/user_123_private_document.pdf
// Can download any file by guessing filenames

Why this is vulnerable: File access based on user-controlled filenames without ownership verification allows attackers to download other users' files. Combined with predictable naming conventions (user IDs, timestamps), this enables systematic data theft.

API Endpoint With ID Parameter

// VULNERABLE - RESTful API without authorization
GET /api/orders/456 HTTP/1.1
Authorization: Bearer <valid_token>

// Returns order 456 regardless of who owns it

Why this is vulnerable: REST APIs that use resource IDs in the URL path are particularly susceptible to IDOR if authorization isn't enforced. Even with authentication, the API must verify the authenticated user owns the requested resource.

Bulk Operations Without Authorization

// VULNERABLE - Batch delete without ownership check
function deleteOrders(order_ids):
    for id in order_ids:
        database.delete(id)  // Deletes ANY orders!

// Attacker: POST /api/orders/delete
// Body: {"ids": [1, 2, 3, 4, 5, ...]}

Why this is vulnerable: Bulk operations amplify IDOR vulnerabilities, allowing attackers to affect multiple resources with a single request. Without per-resource authorization checks, an attacker can delete, modify, or access large datasets belonging to other users.

Attack Scenarios

Horizontal Privilege Escalation

// Normal request (User A viewing their own order)
GET /api/orders/123 HTTP/1.1
Authorization: Bearer <user_a_token>
 Returns User A's order 123

// Attack: User A tries to access User B's orders
GET /api/orders/124 HTTP/1.1
Authorization: Bearer <user_a_token>
 Should return 403 Forbidden
 If vulnerable: Returns User B's order 124!

Sequential ID Enumeration

// Attacker systematically accesses all resources
for id from 1 to 10000:
    response = GET /api/user/{id}
    if response.status == 200:
        steal_data(response.body)  // Harvest all user data

// Without authorization: Returns all user profiles
// With authorization: Returns 403 for all except attacker's own profile

Account Takeover via IDOR

// Attacker changes another user's email via IDOR
PUT /api/user/9999/email HTTP/1.1
Authorization: Bearer <attacker_token>
Content-Type: application/json

{"email": "attacker@evil.com"}

// If vulnerable: Changes victim's email
// Attacker can then reset password and takeover account

Scenario 4: Data Exfiltration Through File Download

// Attacker discovers predictable file naming pattern
GET /download/invoice_1234.pdf  → User A's invoice
GET /download/invoice_1235.pdf  → User B's invoice
GET /download/invoice_1236.pdf  → User C's invoice

// Systematic download of all invoices in the system

Secure Patterns

Query-Level Ownership Filtering

function getUserDocuments(current_user):
    return database.findWhere(
        owner_id = current_user.id
    )

Why this works: By scoping queries to the current user's ownership at the database level, only resources belonging to the authenticated user can be returned. This creates a secure-by-default pattern where unauthorized access is physically impossible - the database query itself prevents accessing other users' data, making it more reliable than application-level checks that might be bypassed or forgotten.

Explicit Authorization Check

function canAccessDocument(user, document):
    return (
        document.owner_id == user.id OR
        user.id IN document.shared_users OR
        user.is_admin
    )

function viewDocument(doc_id, current_user):
    doc = database.get(doc_id)
    if not canAccessDocument(current_user, doc):
        return 403_FORBIDDEN
    return render(doc)

Why this works: Separating authorization logic into a dedicated function ensures consistent, explicit verification before granting access. The function centralizes complex permission logic (ownership, sharing, admin rights), making authorization decisions clear, auditable, and easier to maintain. This pattern supports scenarios beyond simple ownership, such as shared documents or role-based access.

Resource-Based Authorization Middleware

// Authorization applied before accessing resource
function authorizationMiddleware(request, current_user):
    resource_id = request.params.id
    resource = database.get(resource_id)

    if not resource:
        return 404_NOT_FOUND

    if resource.owner_id != current_user.id:
        return 403_FORBIDDEN

    // Attach to request for handler to use
    request.resource = resource
    continue_to_handler()

function viewDocument(request):
    // Resource already authorized and attached
    return render(request.resource)

Why this works: Authorization middleware enforces access control before request handlers execute, preventing accidental authorization bypasses. By checking permissions in middleware, you ensure all endpoints automatically have authorization protection. The authorized resource is attached to the request, eliminating redundant database queries and reducing the chance of time-of-check/time-of-use vulnerabilities.

UUIDs with Ownership Verification (Defense in Depth)

// Database schema with UUID primary keys
table documents {
    id: UUID (primary key)  // Non-sequential, hard to guess
    owner_id: INTEGER
    created_at: TIMESTAMP
}

function getDocument(doc_uuid, current_user):
    doc = database.findWhere(
        id = doc_uuid,
        owner_id = current_user.id  // Still verify ownership!
    )
    if not doc:
        return 404_NOT_FOUND
    return doc

Why this works: UUIDs (128-bit randomly generated identifiers) make it computationally infeasible for attackers to guess valid resource IDs through enumeration attacks. However, UUIDs alone don't prevent IDOR - authorization checks are still mandatory since UUIDs may be exposed through logs, shared URLs, browser history, or error messages. The combination of unpredictable IDs (UUIDs) and ownership verification provides defense-in-depth: UUIDs prevent casual enumeration, while authorization checks ensure even known UUIDs can't be exploited.

Common Mistakes to Avoid

Returning 404 Instead of 403

// WRONG - Leaks resource existence
function getDocument(doc_id, current_user):
    doc = database.get(doc_id)
    if not doc:
        return 404_NOT_FOUND  // Resource doesn't exist
    if doc.owner_id != current_user.id:
        return 404_NOT_FOUND  // Pretending it doesn't exist
    return doc

// CORRECT - Proper error codes
function getDocument(doc_id, current_user):
    doc = database.get(doc_id)
    if not doc:
        return 404_NOT_FOUND
    if doc.owner_id != current_user.id:
        return 403_FORBIDDEN  // Exists but not authorized
    return doc

Why this is vulnerable: Returning 404 for unauthorized access attempts to hide resource existence, but this creates information leakage through timing attacks and doesn't follow HTTP semantics. Use 403 Forbidden to indicate the resource exists but the user isn't authorized. However, for high-security scenarios, consistently returning 404 can prevent enumeration.

Checking Authorization After Using the Data

// WRONG - Using data before authorization check
function displayDocument(doc_id, current_user):
    doc = database.get(doc_id)
    doc_title = doc.title  // Using data before check!

    if doc.owner_id != current_user.id:
        return 403_FORBIDDEN

    return render(doc)

// CORRECT - Check authorization first
function displayDocument(doc_id, current_user):
    doc = database.get(doc_id)

    if doc.owner_id != current_user.id:
        return 403_FORBIDDEN  // Check BEFORE using any data

    return render(doc)

Why this matters: Even reading fields from unauthorized resources can leak data through error messages, logs, or timing side channels. Always verify authorization before accessing any resource properties.

Relying on UUIDs Alone

// WRONG - No authorization check because "UUIDs are secure"
function getDocument(uuid):
    return database.get(uuid)  // UUID is hard to guess, but still need authz!

// CORRECT - UUIDs + authorization
function getDocument(uuid, current_user):
    doc = database.findWhere(
        id = uuid,
        owner_id = current_user.id  // Verify ownership
    )
    return doc

Why this matters: UUIDs make enumeration harder but don't prevent IDOR. UUIDs can be leaked through logs, analytics, shared links, browser history, or error messages. Always implement authorization checks regardless of ID format.

Only Checking Authorization on GET Requests

// WRONG - Authorization only on read
function getOrder(order_id, current_user):
    return database.findWhere(id = order_id, user_id = current_user.id)  // ✓

function updateOrder(order_id, new_data):
    database.update(order_id, new_data)  // No authorization!

function deleteOrder(order_id):
    database.delete(order_id)  // No authorization!

// CORRECT - Authorization on all operations
function updateOrder(order_id, new_data, current_user):
    order = database.findWhere(id = order_id, user_id = current_user.id)
    if not order:
        return 403_FORBIDDEN
    database.update(order_id, new_data)

function deleteOrder(order_id, current_user):
    order = database.findWhere(id = order_id, user_id = current_user.id)
    if not order:
        return 403_FORBIDDEN
    database.delete(order_id)

Why this matters: IDOR vulnerabilities affect all CRUD operations (Create, Read, Update, Delete), not just read access. Attackers can modify or delete other users' resources if authorization isn't enforced on write operations.

Client-Side Authorization Checks

// WRONG - Authorization in client-side JavaScript
// frontend.js
if (currentUser.id === document.ownerId) {
    apiClient.deleteDocument(documentId);  // Can be bypassed!
}

// CORRECT - Authorization on server
// Server-side handler
function deleteDocument(doc_id, current_user):
    doc = database.get(doc_id)
    if doc.owner_id != current_user.id:
        return 403_FORBIDDEN  // Server enforces authorization
    database.delete(doc_id)

Why this matters: Client-side checks can be trivially bypassed by modifying JavaScript, using browser DevTools, or making direct API calls with tools like curl. All authorization must be enforced server-side.

Inconsistent Authorization Across Endpoints

// WRONG - Some endpoints check, others don't
function getOrder(order_id, current_user):
    return database.findWhere(id = order_id, user_id = current_user.id)  // GOOD

function getOrderItems(order_id):
    return database.getItems(order_id)  // Forgot to check!

// CORRECT - Consistent authorization
function getOrderItems(order_id, current_user):
    order = database.findWhere(id = order_id, user_id = current_user.id)
    if not order:
        return 403_FORBIDDEN
    return order.items

Why this matters: Inconsistent authorization creates security gaps where related endpoints might leak data even when primary endpoints are protected. Use centralized authorization middleware or functions to ensure consistency.

Language-Specific Guidance

For detailed, framework-specific code examples and patterns, see:

  • C# - ASP.NET Core, Entity Framework Core, resource-based authorization
  • Java - Spring Boot, Spring Security, JPA/Hibernate with authorization checks
  • Python - Flask, Django, FastAPI with ownership verification patterns

Verification and Testing

Manual Testing Strategies

  • Cross-user access testing: Authenticate as User A and attempt to access resources belonging to User B by changing IDs in URLs or API requests. Verify that requests return 403 Forbidden, not 200 OK with other users' data.
  • Sequential ID enumeration: Try accessing resources with sequential or predictable IDs (1, 2, 3, etc.) to verify that authorization checks prevent unauthorized access. Most requests should return 403/404, not 200.
  • Unauthenticated access: Attempt to access protected resources without authentication tokens. Should return 401 Unauthorized.

Automated Testing Approach

  • Multi-user test scenarios: Create multiple test users with their own resources, then verify each user can only access their own data and receives 403 errors when attempting to access others' resources.
  • Ownership verification tests: Ensure all resource access endpoints validate ownership by checking that queries are properly scoped to the authenticated user.
  • Authorization bypass attempts: Test edge cases such as null IDs, negative IDs, very large IDs, and special characters to ensure authorization checks handle all inputs securely.

Security Testing Tools

  • DAST Tools: OWASP ZAP, Burp Suite Professional - Test for IDOR in running applications
  • SAST Tools: Static code analysis to identify missing authorization checks
  • API Security Testing: Postman, REST Client with multiple user contexts

Security Checklist

  • All resource access endpoints verify ownership or authorization
  • Database queries include authenticated user ID in WHERE clauses
  • No direct lookups (no bare get(id) or findById(id) without authorization)
  • Authorization checks applied to ALL operations (GET, POST, PUT, DELETE, PATCH)
  • Proper HTTP status codes (403 for unauthorized, 401 for unauthenticated)
  • UUIDs used for resource IDs (defense-in-depth, still requires authorization)
  • Authorization logic centralized in reusable functions or middleware
  • Multi-user test scenarios verify isolation
  • IDOR attack payloads tested and blocked
  • Client-side authorization checks not relied upon (server-side only)
  • Bulk operations include per-resource authorization checks
  • Related/nested resources properly authorized (e.g., order items belong to user's order)

Additional Resources