Skip to content

CWE-639: Authorization Bypass Through User-Controlled Key

Overview

Insecure Direct Object Reference (IDOR) occurs when an application exposes a direct reference to an internal object - a database key, filename, or path - and lets the authenticated user choose which one to access without verifying they are authorized to read or change it. An attacker edits the reference (changing GET /api/account?id=123 to id=124) to reach another user's data. It is one of the most common web vulnerabilities and usually trivial to exploit, because the request otherwise looks legitimate.

Relationship to Other CWEs

  • CWE-639 (this page) - the caller supplies the identifier that decides which record is reached, and nothing binds that record to the caller.
  • CWE-863 (Incorrect Authorization) - MITRE's direct parent. CWE-863 covers flawed authorization logic generally: a wrong comparison, an inverted condition, a role read from the request. CWE-639 is the case where the object identifier deciding which record is reached comes from the caller and nothing binds it to them.
  • CWE-566 (Authorization Bypass Through User-Controlled SQL Primary Key) - a variant, the same weakness manifesting as an unauthorized, user-controlled SQL primary key used in a query.
  • CWE-285 (Improper Authorization) - the class above CWE-863, and the page to use when a finding names authorization generally rather than an identifier the caller controls. CWE-862 (Missing Authorization) is CWE-863's sibling, for the case where no permission check runs on the path at all.

OWASP Classification

A01:2025 - Broken Access Control

Risk

High: IDOR enables horizontal privilege escalation (viewing or modifying another user's orders, invoices, or messages), systematic data enumeration by iterating IDs, account takeover (changing another user's email or password), and exposure of PII, financial, or health data, often triggering compliance violations (GDPR, HIPAA).

Remediation Steps

Core Principle: Never trust a user-supplied object identifier - verify server-side that the authenticated user actually owns or has permission for the specific object before returning or modifying it.

Trace the Data Path

  • Source: A key or identifier from the client - a URL path segment, query/body parameter, hidden form field, or cookie (order ID, document ID, account number).
  • Sink: The data access layer (query, file read, service call) that fetches or mutates the object identified by that key.
  • Missing Control: An ownership or permission check binding the requested object to the authenticated user, derived from server-side session/context rather than from the request itself.

Enforce Object-Level Authorization (Primary Defense)

Every lookup or mutation of a protected object must verify the current, server-authenticated user is allowed to act on that specific object - not just that the object exists.

// VULNERABLE - no ownership check
function getOrder(orderId):
    return db.query("SELECT * FROM orders WHERE id = ?", orderId)

// SECURE - scope the query to the current user
function getOrder(orderId, currentUser):
    order = db.query(
        "SELECT * FROM orders WHERE id = ? AND user_id = ?",
        orderId, currentUser.id)
    if not order:
        raise NotFoundError()   // do not reveal whether the ID exists at all
    return order

For resources admins may also access, widen the question rather than dropping it - build the scope from the caller's role and keep it in the query: an administrator's predicate is WHERE id = ? with no owner clause, an ordinary caller's is WHERE id = ? AND user_id = ?. Fetching by ID first and testing order.userId == currentUser.id OR currentUser.hasRole("ADMIN") afterwards reaches the same verdict, but puts the row in memory before the decision. That is where the two denials drift apart: the "not yours" branch now knows the record exists and the "no such ID" branch does not.

Use Access Control Lists for Shared Resources

When multiple users can legitimately access one object (shared documents, team resources), track explicit per-user permissions (owner/read/write) in an ACL table and check it on every access, instead of relying on ownership alone.

Reduce Enumeration with Indirect References (Defense in Depth)

Random, high-entropy identifiers (UUIDs) or session-scoped opaque references make IDs harder to guess or iterate than sequential integers. This is a hardening measure, not a substitute for the authorization check above. An attacker who already holds a valid UUID for someone else's object is stopped only by ownership or ACL enforcement.

Test with Malicious Inputs

  • Horizontal escalation: log in as user B, request user A's resource by ID (GET, PUT, DELETE, and creation with another user's ID in the body) - every case must be refused rather than returning the data.
  • Indistinguishable denials: request an ID that does not exist and an ID owned by another user, and assert both return the identical status and body. A 404 for one and a 403 for the other is an existence oracle regardless of which way round they are, because it lets a caller walk the ID space reading which records exist. Scoping ownership into the query - WHERE id = ? AND owner_id = ? - makes the two cases the same result by construction rather than by a rule each new route has to repeat.
  • ID enumeration: iterate a range of IDs against the endpoint - only the caller's own resources should return 200.
  • Parameter tampering: change a body-level identifier such as from_account to an ID the caller does not own and confirm the request is rejected.
  • Mass assignment: submit extra fields such as user_id or is_admin in an update request and confirm they are ignored or rejected, not applied.
  • Re-scan with the security scanner to confirm the finding is resolved.

Common Vulnerable Patterns

// VULNERABLE - trusts the client-supplied ID with no ownership check
function getDocument(documentId):
    return db.query("SELECT * FROM documents WHERE id = ?", documentId)
// Attack: an authenticated low-privilege user requests another user's
// document ID directly and receives it in full.

Why this is vulnerable: the query is parameterised, so there is no injection, and it returns exactly the row it was asked for. The identifier deciding which row that is arrived from the client, and nothing establishes that this caller is entitled to it. Authentication proved who they are and stopped there, so the distance between a user reading their own document and reading everyone's is one digit in a URL.

The fix that generalises is to move ownership into the query rather than to place a check beside it: WHERE id = ? AND owner_id = ?, a repository method that requires both, or a base queryset already scoped to the caller. A check applied to the result has to be repeated everywhere the question is asked, and the places that forget are the ones nobody has listed: a bulk endpoint, an export, a newly added handler. A constraint that is part of the question travels with it, so code that skips the check is still confined to rows the caller could have seen.

Making identifiers unguessable is worth doing, but it is not this fix. A random identifier raises the cost of finding a valid one and does nothing once one is known, and identifiers leak routinely.

Secure Patterns

// SECURE - authorization derived from server-side session, not the request
function getDocument(documentId, currentUser):
    permission = db.query(
        "SELECT permission FROM doc_permissions WHERE document_id = ? AND user_id = ?",
        documentId, currentUser.id)
    if not permission:
        raise ForbiddenError()
    return loadDocument(documentId)

Why this works: the object is only ever returned after checking a permission record tied to the authenticated user's identity, which the client cannot forge. Changing the documentId in the request no longer helps an attacker, because an ID with no permission row for that caller is rejected.

This example answers ForbiddenError where the ownership-scoped one above answers NotFoundError. Either is fine. What matters is that within one example both refusals - the document that belongs to someone else and the document that does not exist - take the same branch and produce the same response, because there is no permission row in either case. Pick one status for the codebase and use it for both.

Common Pitfalls

  • Switching to UUIDs instead of adding an authorization check: replacing sequential integer IDs with random UUIDs and treating the bypass as fixed. A UUID prevents an attacker from guessing another user's ID, but does nothing against one who already holds a valid UUID, leaked via a URL, a referrer header, a shared link, or another user. The missing ownership check is the bug, not the enumerable identifier.
  • Checking authorization on read but not on write: adding an ownership check to the GET endpoint for a resource while the PUT, PATCH, DELETE, or export/download endpoint for the same object ID skips it, on the assumption that "if they got this far they must be authorized". Each operation and each route touching the object needs its own check, because an HTTP request carries no proof that a prior check happened elsewhere.
  • Enforcing ownership in the UI instead of the server: hiding the links or buttons that would let a user reach another user's resource while the API endpoint behind them performs no ownership check. The identifier is still valid and reachable directly through browser dev tools, a proxy, or a replayed request.
  • Scoping the list endpoint but not the detail endpoint: filtering an index/list query to the current user's own records (WHERE user_id = ?) while the single-object detail endpoint for the same resource type still queries by ID alone (WHERE id = ?). The correctly scoped list makes authorization look handled while the detail endpoint stays exploitable by ID manipulation.

Additional Resources