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:
-
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 - Database ID:
-
Session-based mapping:
-
Encrypted references:
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:
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:
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).
- Identify direct object references
- Locate missing authorization checks
- Trace object access pattern
- Add object-level authorization: Verify current user owns or has permission for the requested object before returning it
- Implement access control checks: Add ownership verification to every object retrieval (query filters, ACL lookups, permission checks)
- Test with different users: Attempt to access User A's resources as User B using modified IDs (should return 403/404)
- Consider indirect references: Replace sequential IDs with UUIDs or session-specific mappings to prevent enumeration
Horizontal Privilege Escalation
Test accessing other users' resources:
- Login as User A
- Access own resource:
GET /api/orders/123→ 200 OK -
Note resource belongs to User A
-
Login as User B
- Try accessing User A's resource:
GET /api/orders/123Expected: 403 Forbidden or 404 Not Found -
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:
-
Original request:
POST /api/transfer -
Modified request (change from_account to someone else's):
POST /api/transfer
Expected: Request rejected; cannot transfer from account you don't own
File Access Testing
Test direct file access:
- Upload file as User A: /uploads/user_123_document.pdf
- Login as User B
- 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:
{
"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