CWE-566: Authorization Bypass Through User-Controlled SQL Primary Key
Overview
CWE-566 occurs when a SQL query uses a user-controlled identifier (a user ID, document ID, account number, or resource key) as the primary key in a WHERE clause without verifying that the authenticated user is authorized to access that row. The weakness is commonly called Insecure Direct Object Reference (IDOR): changing an ID parameter returns another user's data, which is horizontal privilege escalation. Parameterizing the query does not help, because parameterization prevents injection and has nothing to say about authorization.
Relationship to Other CWEs
CWE-566 is a child of CWE-639 (Authorization Bypass Through User-Controlled Key) - CWE-639 is the general weakness of trusting a user-controlled object key for a data-access decision, and CWE-566 is the specific case where that key is used directly as a SQL primary key.
OWASP Classification
A01:2025 - Broken Access Control
Risk
High to Critical: Changing an ID parameter returns another user's data - horizontal privilege escalation against profiles, orders, documents, and anything else keyed by an identifier the caller can edit, exposing the PII those records hold and bringing the usual regulatory obligations (GDPR, HIPAA, PCI DSS) with it. The same gap on a write or bulk endpoint lets one request modify or delete resources the caller does not own, and a bulk delete over caller-supplied IDs turns that into mass deletion. Where responses vary with existence, iterating the ID space maps the whole table without a single request succeeding.
Remediation Steps
Core Principle: A user-controlled key is not an authorization decision. Enforce object-level access control on every resource lookup, for writes and bulk operations as well as reads.
Trace the Data Path
- Source: Where a resource ID enters the request (URL parameter, POST body, API path parameter, query string)
- Sink: The database query or file access that uses that ID
- Missing control: No check that the authenticated user owns or has permission for the resource that ID refers to
Scope Every Query to the Authenticated User (Primary Defense)
// VULNERABLE - trusts the ID alone, no ownership check
function getOrder(order_id):
return database.find_by_id(order_id) // returns ANY order
// SECURE - the query itself can only return the caller's own data
function getOrder(order_id, current_user):
return database.find_where(id = order_id, user_id = current_user.id)
Why this works: With the authenticated user's ID in the query, the database predicate is what stops another user's row from coming back, so there is no separate check to forget or bypass. Loading the row first and checking ownership afterward leaves a window in which unauthorized data is held in memory; a scoped query never holds it at all.
Support Shared or Role-Based Access With an Explicit Check
Where access is not strictly single-owner (shared documents, admin overrides), an ownership-scoped query is too restrictive. Use an explicit authorization function and call it before returning or acting on the resource:
function can_access(user, resource):
return resource.owner_id == user.id
or user.id in resource.shared_with
or user.is_admin
function getDocument(doc_id, current_user):
doc = database.get(doc_id)
if doc is null or not can_access(current_user, doc):
log_authorization_denial(current_user, doc_id, exists = doc is not null)
return 404 // same response either way - see the next section
return doc
Return the Same Response for "Doesn't Exist" and "Not Yours"
An endpoint that answers 404 for a missing ID and 403 for someone else's is an existence oracle: the caller learns which IDs are real by watching the status code change, which is exactly the enumeration the fix was meant to stop. Default to returning 404 for both.
Note what that costs on the monitoring side. An ownership-scoped query returns nothing for a missing row and nothing for someone else's row, so it cannot tell you which one happened - "log the distinction, return one response" is only free where the handler already looked the row up unscoped and compared owners. Where the query is scoped and the distinction genuinely matters for alerting, buy it with a deliberate unscoped existence check on the denial path, and treat its result as log-only: the moment it reaches the response body, the status code, or the response time, the oracle is back.
Two cases justify a distinct 403: the resource's existence is already public knowledge to this caller (a workspace they can see but not open), or the decision is made without consulting the database at all, so it has no existence to leak. Both are deliberate decisions - the default is 404.
A third case is not a decision at all but worth recognising when you meet it. Framework method security - Spring's @PreAuthorize, and anything else that turns a boolean predicate into a single denial - typically answers 403 for a missing resource and an unauthorized one alike, because the predicate returns false either way. A uniform 403 is not an oracle and does not need fixing. It is only worth converting to 404 for consistency with the rest of the API, and it is worth testing, because the uniformity is a property of every branch behind the predicate rather than of the annotation.
The same rule applies to anything else that varies with existence: an error message, a response time, or a batch response that names which IDs were rejected and why.
Centralize Authorization Logic
Duplicate, ad hoc ownership checks scattered across handlers are how IDOR gaps happen - one endpoint gets the check, a related one (a nested resource, a bulk operation, a newly added route) doesn't. Put the check in one reusable function or middleware and apply it consistently to every route that accepts a resource identifier:
function require_ownership(resource_id, current_user):
resource = database.get(resource_id)
if resource is null or resource.owner_id != current_user.id:
// One raise for both branches, so the caller cannot tell them apart.
// NotFoundError rather than ForbiddenError, per the section above -
// a uniform 403 also holds, but only if it stays uniform
raise NotFoundError
return resource
Add Unpredictable IDs as Defense in Depth
Using UUIDs or other high-entropy identifiers instead of sequential integers makes casual enumeration harder, but it is not a substitute for an authorization check - IDs leak through logs, shared links, browser history, and error messages regardless of format. Every example above applies the same way whether the ID is a sequential integer or a UUID.
Apply Authorization to Every Operation, Including Bulk Operations
Authorization gaps are most often found on write and bulk endpoints, not the read endpoint that got the most review attention. A bulk delete that acts on caller-supplied IDs without checking ownership turns a single request into mass unauthorized deletion.
Prefer scoping the bulk query itself - WHERE id IN (...) AND owner_id = ? - over looping and checking each item. A statement that cannot select an unowned row cannot act on one either, whereas a loop has to be right on every path through it, including the error path and the early return. Where the operation genuinely has to be per-item, put the scoped lookup inside the loop rather than a single check at the top of the handler.
Then decide, rather than fall into, what a mixed batch does: fail entirely, or proceed with the subset the caller owns. Both are defensible; code that never decides tends to do one on one endpoint and the other elsewhere.
Test the Fix
- Authenticate as User A, attempt to access, modify, and delete User B's resources by changing IDs - every attempt should be rejected, writes as well as reads
- Enumerate sequential/predictable IDs and confirm a nonexistent ID and another user's ID produce the same status code and body - a difference between them is an existence oracle
- Test bulk/batch endpoints specifically - a single unauthorized ID mixed into an otherwise-valid batch should not slip through
- Re-scan with the security scanner to confirm the finding is resolved
Common Vulnerable Patterns
A lookup by ID with no ownership check
// VULNERABLE - no ownership check at all
function viewProfile(user_id):
return render(database.get(user_id))
// Attack: GET /profile/9999 returns any user's profile by changing the ID
Why this is vulnerable: the query is correct and the question was wrong. database.get(user_id) asks for the row with this primary key and returns it, which is exactly what it was written to do - there is no injection, no parsing error, and nothing for a scanner looking at the query to object to. What is absent is any statement that the caller is entitled to that row, and the identifier deciding which row to return arrived in the request.
The fix that generalises is to put ownership into the query rather than beside it - WHERE id = ? AND owner_id = ?, a repository method that takes both, or a base query scoped to the caller. A check applied to the answer has to be repeated at every place that asks the question, and the places that forget are the ones nobody lists; a constraint that is part of the question travels with it, so a handler added later that skips the check is still limited to rows the caller could have seen.
A bulk operation authorized once, or not at all
// VULNERABLE - bulk operation with no per-item authorization
function deleteOrders(order_ids):
for id in order_ids:
database.delete(id) // deletes ANY order, not just the caller's own
Why this is vulnerable: the collection endpoint is usually the one left uncovered, and it is the one that reaches the whole table. Object-level checks that hook a single-resource route do not fire here, and the single-resource route being correctly protected is what creates the confidence that the resource is protected. One extra identifier in a list costs the attacker nothing.
Adding a check to the loop raises the mixed-batch question set out under "Apply Authorization to Every Operation" above. Three related traps are worth knowing because each silently produces "authorized": an all-match test over an empty list returns true in most languages, an IN-style query quietly omits identifiers that do not exist rather than reporting them, and a check that counts returned rows against the requested count conflates "not yours" with "not there".
A check that answers from two places
// VULNERABLE - the check runs, but the status code leaks which IDs exist
function getDocument(doc_id, current_user):
doc = database.get(doc_id)
if doc is null:
return 404
if doc.owner_id != current_user.id:
return 403 // "real document, not yours" - walk the ID space and map the whole table
Why this is vulnerable: the authorization is right and the responses give the answer away anyway. Two different pieces of code decide the outcome - the existence check and the ownership check - and they return different statuses, so the pair of responses distinguishes "no such document" from "a document that is not yours". Iterating the identifier space then enumerates every row in the table, including how many there are and roughly when each was created, without a single request succeeding.
This is the shape to recognise, because the natural fix creates it. Adding a missing-record check ahead of the authorization call looks like defensive tidiness and splits one decision across two paths, and the earlier path answers first - for a caller the authorization would have refused outright. A caller with no entitlement to a record should not be able to learn whether it exists, which means one decision and one response: return the not-found status for both, and let the log record which it actually was.
Authorization enforced only in the client
// VULNERABLE - authorization checked only in client-side JavaScript
if current_user.id == document.owner_id:
api_client.delete_document(document_id)
Why this is vulnerable: everything here runs on a machine the attacker controls, so the condition is a suggestion. The API call it guards can be made directly, with any document identifier, by anyone who has read the page's own network traffic.
What makes this survive review is that it works. The control is genuinely absent from the interface for users who should not have it, so the feature demonstrably cannot be misused by clicking, and a manual test confirms as much. That is a statement about the interface and not about the endpoint, which has no check on it at all - the client-side condition is a reasonable thing to keep for the interface it shapes, and it is not the place the decision is made.
Secure Patterns
// SECURE - decided by comparing two IDs, before any lookup happens
function viewProfile(user_id, current_user):
if user_id != current_user.id and not current_user.is_admin:
return 403 // 403 is safe here: nothing was queried, so nothing about
// whether user_id exists can leak. Contrast getDocument below
return render(database.get(user_id))
// SECURE - per-item authorization inside the bulk operation
function deleteOrders(order_ids, current_user):
for id in order_ids:
order = database.find_where(id = id, user_id = current_user.id)
if order is null:
continue // skip silently, or fail the whole batch, per your API's contract
database.delete(id)
// SECURE - the ownership predicate is in the query, so there is one outcome
// to report and no second code path that could disagree with it
function getDocument(doc_id, current_user):
doc = database.find_where(id = doc_id, user_id = current_user.id)
if doc is null:
// Cannot say whether the row was missing or someone else's - see
// "Return the Same Response" above for when that is worth a second query
log_denied_access(current_user, doc_id)
return 404
return doc
Why this works: Every path that returns or mutates data passes through an authorization check keyed on the authenticated user rather than on the ID the caller supplied, including inside the bulk loop, where a single check at the top of the handler would leave the remaining items unchecked. The decision is made server-side, so disabling client-side JavaScript or calling the API directly does not get past it.
Common Pitfalls
- Ownership checked on step one of a multi-step flow but not on later steps: A checkout or approval workflow verifies that the resource belongs to the current user at the first step, then a later step (payment confirmation, final submit) re-reads the resource from an ID carried forward in a hidden field or session value, on the basis that it was already checked. A request that jumps straight to the later step with a different ID never passes through the earlier check, because that step's handler does not verify ownership itself.
- Caching an authorized lookup under a key that omits the requesting user: A
getResource(id)call wrapped in a cache for performance, with the ownership check applied only when the cache is populated, serves the first user's cached result to a second user who asks for the same ID. The cache key needs the user's identity in it as well as the resource ID. - Fixing the ID in the URL but missing an ID carried elsewhere in the same request: A route like
/api/orders/{orderId}gets a correct ownership check, while the request body or an included object graph carries a second identifier (parent_order_id,linked_invoice_id) that the same handler dereferences unchecked. The fix covers the ID the developer noticed rather than every identifier the handler uses. - Comparing two attacker-controlled values instead of one trusted and one supplied: An authorization check that compares the resource's owner field against an unverified claim decoded from a client-supplied token, or against a second value read from the same request that supplied the resource ID, passes without ever consulting a trusted, server-side identity source. The check runs; it just does not check anything the attacker cannot also set.
Language-Specific Guidance
Framework-specific examples and patterns:
- C# - ASP.NET Core, Entity Framework Core, resource-based authorization
- Java - Spring Boot, Spring Security, JPA/Hibernate with authorization checks
- JavaScript/Node.js - Express REST APIs, composite
{ _id, userId }filters in Mongoose and Sequelize - Python - Flask, Django, FastAPI with ownership verification patterns