Insecure Direct Object References: The Most Common Access Control Flaw
Broken access control sits at the top of the OWASP Top 10, and insecure direct object references are the single most common way it manifests. IDOR vulnerabilities are deceptively simple — an application uses a user-supplied identifier to look up a resource without checking whether the requesting user should have access to it. Change the number in the URL, and you are looking at someone else's data.
Despite being well-understood for decades, IDOR remains pervasive. It appears in everything from startup MVPs to enterprise platforms handling millions of records. The reason is structural: building features is faster than building authorization, and the vulnerability is invisible in normal usage. Everything works perfectly when users only access their own resources. The flaw only reveals itself when someone deliberately requests a resource that does not belong to them.
How IDOR Works
Every web application stores data in objects — user profiles, orders, documents, messages, invoices. To retrieve a specific object, the application needs an identifier. When that identifier comes from the client (a URL parameter, a form field, a request body, or even a cookie) and the server fetches the object without verifying ownership or permissions, an IDOR vulnerability exists.
The simplest example:
GET /api/users/1042/profile
Authorization: Bearer <token_for_user_1042>
→ 200 OK (user 1042's profile)
GET /api/users/1043/profile
Authorization: Bearer <token_for_user_1042>
→ 200 OK (user 1043's profile — IDOR)
The server authenticated user 1042 and confirmed they are a valid user. But it never checked whether user 1042 is authorized to view user 1043's profile. Authentication (who are you?) succeeded, but authorization (what are you allowed to do?) was never performed.
Sequential Identifiers Make It Trivial
When applications use auto-incrementing database IDs, IDOR exploitation becomes trivially automated. An attacker writes a simple loop:
for id in range(1, 100000):
response = requests.get(f'/api/invoices/{id}', headers=auth_headers)
if response.status_code == 200:
save(response.json())
In minutes, an attacker can exfiltrate the entire dataset. Sequential IDs also leak business intelligence — the ID value reveals how many objects exist, growth rates, and temporal patterns.
Horizontal vs. Vertical Privilege Escalation
Horizontal privilege escalation occurs when an attacker accesses resources belonging to another user with the same privilege level. User A reads User B's medical records. This is the classic IDOR scenario.
Vertical privilege escalation occurs when an attacker accesses resources or functions restricted to a higher privilege level. A regular user accesses admin endpoints by changing a role parameter or accessing an admin-only resource identifier.
Both types frequently coexist. An application that fails to check resource ownership often also fails to check role-based permissions.
Real-World Impact
Mass Data Exfiltration from a Financial Platform
During a penetration test of a fintech application, testers discovered that the API endpoint for retrieving transaction history accepted an account ID parameter:
GET /api/accounts/ACC-00847291/transactions
The account ID was a sequential identifier with a predictable prefix. By iterating through account numbers, the testers accessed transaction histories for every customer on the platform. Each response contained full transaction details: amounts, dates, counterparty names, and partial account numbers. The entire customer database — over 200,000 accounts — was accessible to any authenticated user.
The business impact was severe. Beyond the obvious privacy violation, the exposed data included enough detail to enable targeted social engineering attacks and financial fraud.
Medical Record Access Through Document References
A healthcare scheduling platform stored medical documents with sequential document IDs. The download endpoint:
GET /api/documents/download?docId=58291
By incrementing the document ID, testers accessed lab results, prescriptions, imaging reports, and clinical notes belonging to other patients. The application verified that the requester was an authenticated user but never checked whether the document belonged to a patient they were authorized to view.
The vulnerability exposed protected health information for tens of thousands of patients. Under healthcare data protection regulations, this represented a reportable breach from the moment the vulnerability was exploitable, regardless of whether anyone had actually exploited it.
Administrative Function Access
An e-commerce platform's admin panel was accessible through a separate URL path, but the API endpoints it called were the same ones available to regular users — just with different parameters. A regular user discovered that sending:
POST /api/orders/12345/refund
Processed the refund without checking whether the requesting user had administrative privileges. The authorization check existed only in the frontend — the admin UI showed the refund button, and the regular user UI did not. But the API itself performed no role verification.
Common IDOR Patterns
Direct Parameter Manipulation
The most straightforward pattern. A resource identifier in the URL path, query string, or request body is changed to reference a different resource.
/api/users/{userId}/settings
/api/orders?orderId={orderId}
/api/download?file={filename}
Body Parameter Tampering
IDOR is not limited to URL parameters. Request bodies in POST and PUT requests often contain resource identifiers that can be manipulated:
{
"userId": 1043,
"newEmail": "attacker@evil.com"
}If the server updates the email for whatever userId is provided in the body rather than the authenticated user's ID, the attacker can modify any account.
Indirect References Through Related Objects
Sometimes the IDOR is not on the primary object but on a related one. An attacker cannot directly access another user's profile but can access their messages, notifications, or files through a secondary identifier:
GET /api/conversations/7821/messages
The application checks that user 1042 belongs to conversation 7821 but does not check that conversation 7821 belongs to user 1042. If conversation IDs are guessable, the attacker reads private conversations.
Batch and Bulk Operations
APIs that accept arrays of identifiers are particularly dangerous:
POST /api/documents/bulk-download
{
"documentIds": [1001, 1002, 1003, 5847, 5848, 5849]
}Even if individual document access is protected, bulk operations sometimes skip per-item authorization checks for performance reasons.
File-Based IDOR
When applications store files with predictable names or paths:
/uploads/invoices/2026/user_1042_invoice_march.pdf
/uploads/invoices/2026/user_1043_invoice_march.pdf
Path prediction allows accessing other users' files, even without a database-backed identifier.
Prevention Strategies
Server-Side Authorization on Every Request
The only reliable defense against IDOR is verifying authorization on every request that accesses a resource. This check must happen on the server — never trust client-side enforcement.
# WRONG - fetches whatever ID is requested
@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user: User):
return db.query(Invoice).filter(Invoice.id == invoice_id).first()
# RIGHT - scopes query to the authenticated user
@app.get("/api/invoices/{invoice_id}")
def get_invoice(invoice_id: int, user: User):
invoice = db.query(Invoice).filter(
Invoice.id == invoice_id,
Invoice.owner_id == user.id
).first()
if not invoice:
raise HTTPException(status_code=404)
return invoiceNote the use of 404 rather than 403. Returning 403 Forbidden confirms that the resource exists, which is an information leak. Returning 404 Not Found reveals nothing.
Use Indirect References
Instead of exposing database IDs to the client, use per-session indirect reference maps. The server maintains a mapping between the identifiers shown to the user and the actual database IDs, scoped to the current user's session.
Alternatively, derive the resource from the authenticated user's context rather than accepting it as a parameter:
# Instead of: GET /api/users/{userId}/profile
# Use: GET /api/me/profile (always returns the authenticated user's profile)This eliminates the attack surface entirely for self-referential operations.
UUIDs Are Not a Fix (But They Help)
Replacing sequential integers with UUIDs (e.g., 550e8400-e29b-41d4-a716-446655440000) makes identifiers unpredictable, which raises the bar for exploitation. However, UUIDs are not a security control:
- UUIDs appear in URLs, which are logged, cached, and shared
- API responses often include UUIDs for related objects
- Browser history, referrer headers, and link sharing all leak UUIDs
- A single leaked UUID fully compromises that resource
Use UUIDs as a defense-in-depth measure alongside proper authorization, never as a replacement for it.
Resource-Level Policies
Implement authorization as a policy layer that is consistently applied across all endpoints. Frameworks and libraries like CASL, Casbin, or OPA (Open Policy Agent) allow defining authorization rules declaratively:
# Policy: Users can only read invoices they own
allow(user, "read", invoice) if
invoice.owner_id == user.id;
# Policy: Admins can read any invoice
allow(user, "read", invoice) if
user.role == "admin";
This centralizes authorization logic and prevents the inconsistencies that arise when each endpoint implements its own checks.
Enforce Authorization in the Data Layer
The most robust approach is to enforce authorization at the data access layer so that it is impossible to query for resources without scoping to the authorized user:
class InvoiceRepository:
def __init__(self, current_user: User):
self.user = current_user
def get(self, invoice_id: str) -> Invoice:
# Authorization is built into every query
return db.query(Invoice).filter(
Invoice.id == invoice_id,
Invoice.owner_id == self.user.id
).first()When authorization is enforced at this level, individual endpoint handlers cannot accidentally skip it.
Testing for IDOR
Systematic IDOR testing requires two authenticated sessions — typically two regular user accounts. For every endpoint that accepts a resource identifier:
- Authenticate as User A and note the identifiers in responses (IDs, UUIDs, filenames)
- Authenticate as User B and attempt to access User A's resources using those identifiers
- Test all HTTP methods — an endpoint might enforce authorization on GET but not on PUT or DELETE
- Test related objects — if you cannot access a user's profile, try their orders, messages, or uploaded files
- Test batch endpoints — mix authorized and unauthorized identifiers in bulk requests
- Test state-changing operations — can you modify or delete another user's resources?
- Check numeric, UUID, and slug-based identifiers — IDOR exists regardless of identifier format
Automated scanning has limited effectiveness against IDOR because the scanner needs application-level context to understand which resources belong to which user. Manual testing with business logic understanding remains essential.
Key Takeaways
IDOR vulnerabilities persist because they are invisible during normal development and testing. When developers test their own features, they naturally access their own resources, and everything works. The flaw only manifests when someone deliberately tries to access resources they should not.
Prevention requires treating authorization as a first-class architectural concern:
- Verify authorization on every request that accesses a resource, at the server level
- Scope all database queries to the authenticated user's permissions
- Use indirect references or derive resource identity from the session where possible
- Implement centralized authorization policies rather than per-endpoint checks
- Return 404 (not 403) for unauthorized resource access to prevent enumeration
- Test with multiple user accounts and verify every endpoint enforces proper access control
The cost of fixing IDOR in design is minimal. The cost of fixing it after a data breach is immeasurable.
Need your application tested for insecure direct object references? Get in touch.