IDOR and Privilege Escalation
Why IDOR Matters
Imagine you log into an e-commerce website and view your order at /api/orders/1001. Out of curiosity, you change the URL to /api/orders/1002 — and suddenly you are looking at another customer's order with their full name, address, and credit card details. That is IDOR.
Insecure Direct Object Reference (IDOR) is one of the most common and most dangerous API vulnerabilities. According to OWASP, it falls under Broken Object Level Authorization (BOLA), which is the #1 item on the OWASP API Security Top 10. Why? Because APIs rely on object identifiers (IDs, UUIDs, slugs), and developers often forget to check whether the authenticated user actually owns the requested resource.
Why this matters for your career:
- BOLA/IDOR is the most reported vulnerability in bug bounty programs
- It is often the easiest to find and the most rewarding to exploit
- A single IDOR can expose millions of user records
- Understanding authorization logic is a core skill for senior backend engineers
What Is IDOR?
IDOR stands for Insecure Direct Object Reference. It occurs when an API exposes a direct reference to an internal object (database ID, file path, key) without verifying that the user is authorized to access that object.
Horizontal vs. Vertical Privilege Escalation
| Type | Description | Example | |:-----|:------------|:--------| | Horizontal | Accessing data at the same privilege level but belonging to another user | User A views User B's orders | | Vertical | Accessing functionality at a higher privilege level | Regular user performs admin actions |
Real-World Example
GET /api/orders/1001 → Alice's order ✅ (Alice is logged in)
GET /api/orders/1002 → Bob's order ❌ (No ownership check!)
Common IDOR Locations
| API Endpoint Pattern | Risk | Attack Vector |
|:---------------------|:----:|:-------------|
| /api/users/{id} | 🔴 High | Change the user ID |
| /api/orders/{order_id} | 🔴 High | Change the order ID |
| /api/documents/{uuid} | 🟡 Medium | Try known UUIDs |
| /api/invoices/batch | 🔴 High | Add extra IDs in batch requests |
| /api/profile | 🟢 Low | Usually tied to session, harder to exploit |
How to Detect and Exploit IDOR
Step 1: Identify Object References
Look for endpoints that take user-controlled identifiers:
/api/users/123
/api/orders/ORD-456
/api/documents/a1b2c3d4
POST /api/transfer {"from_account": 123, "to_account": 456}
Step 2: Create Two User Accounts
The most reliable way to test IDOR:
- Create User A and User B
- Log in as User A, get a resource ID
- Log in as User B, try to access User A's resource
- If successful — IDOR confirmed
Step 3: Brute Force IDs
import requests
base_url = "https://api.target.com"
headers = {"Authorization": "Bearer victim_token_here"}
# Try sequential IDs
for user_id in range(1, 100):
r = requests.get(f"{base_url}/api/users/{user_id}", headers=headers)
if r.status_code == 200:
print(f"[!] IDOR: Accessed user {user_id}")
print(f" Response: {r.text[:200]}")
Step 4: Test Batch and Array Endpoints
Modern APIs often accept arrays — making IDOR even easier:
# Vulnerable batch endpoint
POST /api/orders/batch
{"order_ids": ["1001", "1002", "1003", "1004"]}
# Response includes ALL orders, even those not belonging to the user!
How to Fix IDOR: Implementation Patterns
❌ Vulnerable Pattern: No Ownership Check
// ❌ DANGEROUS: No ownership verification
app.get('/api/orders/:id', async (req, res) => {
const order = await db.query(
'SELECT * FROM orders WHERE id = $1',
[req.params.id]
)
res.json(order)
})
✅ Secure Pattern: Ownership Check
// ✅ SECURE: Verify ownership before returning data
app.get('/api/orders/:id', async (req, res) => {
const order = await db.query(
'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id] // user.id comes from JWT/session
)
if (!order) {
return res.status(404).json({ error: 'Order not found' })
}
res.json(order)
})
❌ Vulnerable Pattern: Sequential Integer IDs
# ❌ DANGEROUS: Sequential IDs make enumeration trivial
@app.get("/api/users/{user_id}")
async def get_user(user_id: int):
user = await db.fetch_one("SELECT * FROM users WHERE id = $1", user_id)
return user
✅ Secure Pattern: UUID + Ownership + Authorization Middleware
import uuid
from fastapi import Depends, HTTPException, status
# Generate UUIDs instead of sequential IDs
new_user_id = str(uuid.uuid4()) # "550e8400-e29b-41d4-a716-446655440000"
@app.get("/api/users/{user_id}")
async def get_user(
user_id: str,
current_user: dict = Depends(get_current_user)
):
# 1. Verify the requested user is the current user (horizontal check)
# 2. Or verify the current user has admin role (vertical check)
if user_id != current_user["user_id"] and current_user["role"] != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this resource"
)
user = await db.fetch_one(
"SELECT id, name, email FROM users WHERE id = $1",
user_id
)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
Defense Strategies Comparison
| Strategy | Effectiveness | Implementation Effort | Limitation | |:---------|:-------------:|:---------------------:|:-----------| | UUID instead of sequential IDs | 🟡 Low | Easy | Obscurity is not security — UUIDs can still be leaked | | User ID from session/JWT | 🟢 Medium | Easy | Prevents tampering but not reuse | | Ownership check in every query | 🟢🔴 High | Medium | Requires consistent implementation | | RBAC middleware | 🟢🔴 High | Medium-High | Protects vertical escalation | | Dedicated authorization service | 🟢🔴 Very High | High | Best for microservices |
Building an IDOR Testing and Protection Suite
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer
import jwt, uuid
from typing import Optional
app = FastAPI()
security = HTTPBearer(auto_error=False)
SECRET = "your-secret-key-at-least-256-bits"
# --- In-memory database for demonstration ---
database = {
"users": {
"user_1_uuid": {"id": "user_1_uuid", "name": "Alice", "role": "user"},
"user_2_uuid": {"id": "user_2_uuid", "name": "Bob", "role": "user"},
"admin_uuid": {"id": "admin_uuid", "name": "Charlie", "role": "admin"},
},
"orders": {
"ord_001": {"id": "ord_001", "user_id": "user_1_uuid", "amount": 100},
"ord_002": {"id": "ord_002", "user_id": "user_2_uuid", "amount": 200},
}
}
# --- Auth Dependency ---
def get_current_user(credentials=Depends(security)) -> dict:
"""Extract and verify the current user from JWT."""
if credentials is None:
raise HTTPException(status_code=401, detail="Not authenticated")
try:
payload = jwt.decode(
credentials.credentials, SECRET, algorithms=["HS256"]
)
return payload
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
# --- ❌ VULNERABLE ENDPOINT (No ownership check) ---
@app.get("/vulnerable/orders/{order_id}")
async def get_order_vulnerable(
order_id: str,
_: dict = Depends(get_current_user)
):
"""This endpoint does NOT check ownership!
Any authenticated user can see any order."""
order = database["orders"].get(order_id)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return order
# --- ✅ SECURE ENDPOINT (With ownership check) ---
@app.get("/secure/orders/{order_id}")
async def get_order_secure(
order_id: str,
current_user: dict = Depends(get_current_user)
):
"""This endpoint checks that the order belongs to the current user."""
order = database["orders"].get(order_id)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
# Ownership check
if order["user_id"] != current_user["user_id"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not own this order"
)
return order
# --- ✅ ADMIN CHECK ---
@app.get("/admin/users")
async def list_all_users(current_user: dict = Depends(get_current_user)):
"""Only admins can list all users."""
if current_user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return list(database["users"].values())
IDOR Prevention Checklist
| Check | Implemented? | Notes |
|:------|:------------:|:------|
| Every endpoint checks resource ownership | ☐ | Add WHERE user_id = current_user to every query |
| UUIDs used instead of sequential IDs | ☐ | Not a security fix but raises the bar |
| Batch endpoints validate all items | ☐ | Filter out unauthorized items server-side |
| Admin endpoints have role checks | ☐ | RBAC middleware for vertical escalation |
| User ID comes from session/token | ☐ | Never trust user-supplied IDs for authorization |
| Tests cover cross-user access | ☐ | Automated IDOR testing in CI/CD |
Common Pitfalls
| Mistake | Why It Is Dangerous | Fix | |:--------|:-------------------|:----| | Using sequential integer IDs | Enables easy enumeration | Use UUIDs or hashed identifiers | | Only checking if user is authenticated | Authentication != authorization | Always check ownership | | Skipping authorization on batch endpoints | Mass IDOR — leak many records at once | Validate every item individually | | Trusting user-supplied IDs in requests | Attackers can manipulate any parameter | Derive ownership from session/JWT | | Relying on UUID obscurity | UUIDs can be leaked via referrer headers, logs | Always add explicit authorization checks |
Summary
IDOR is the most common authorization vulnerability in APIs — but it is also one of the easiest to fix once you know what to look for.
- What IDOR is: Accessing resources that belong to other users by manipulating object identifiers
- Why it matters: It is the #1 item on the OWASP API Security Top 10
- How to detect: Create two user accounts, try to access each other's resources, test batch endpoints
- How to fix: Always verify ownership in every data query, use UUIDs, implement RBAC middleware
What Is Next: SQL and NoSQL Injection
Now that you understand authorization flaws, the next chapter dives into injection attacks — where attackers bypass authentication entirely and interact directly with the database through unsanitized inputs. You will learn SQL injection techniques, the lesser-known NoSQL injection for MongoDB, and how parameterized queries can save your data.
IDOR Prevention Code
from fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
import uuid
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl='token')
# Use UUIDs instead of sequential IDs
@app.get('/api/documents/{doc_id}')
async def get_document(doc_id: str, token: str = Depends(oauth2_scheme)):
user = verify_token(token)
doc = get_document_from_db(doc_id)
if doc.owner_id != user.id and user.role != 'admin':
raise HTTPException(403, 'Access denied')
return doc
@app.post('/api/documents')
async def create_document(data: dict, token: str = Depends(oauth2_scheme)):
user = verify_token(token)
doc_id = str(uuid.uuid4())
create_document_in_db(id=doc_id, owner_id=user.id, **data)
return {'id': doc_id}
## IDOR Testing Checklist
| Test | How | Expected Result |
|------|-----|----------------|
| Change user ID | /api/users/1 -> /api/users/2 | Should get 403 |
| Change order ID | /api/orders/100 -> /api/orders/101 | Should get 403 |
| Access admin endpoint | /api/admin/users as regular user | Should get 403 |
| Use UUID enumeration | /api/docs/abc123 -> abc124 | Should get 404 |
| Batch IDOR | POST /api/batch with multiple IDs | Should filter unauthorized |
| GraphQL batching | Query multiple users in one request | Should block unauthorized ones |
### Prevention Code
```python
from fastapi import FastAPI, Depends, HTTPException, Security
from fastapi.security import OAuth2PasswordBearer
import uuid
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
def get_current_user(token: str = Depends(oauth2_scheme)):
payload = verify_jwt(token)
return payload
@app.get("/api/users/{user_id}/documents/{doc_id}")
async def get_document(user_id: int, doc_id: int, current_user: dict = Depends(get_current_user)):
# 1. Verify user can access this user_id
if user_id != current_user["id"] and current_user["role"] != "admin":
raise HTTPException(status_code=403, detail="Forbidden")
# 2. Verify document belongs to this user
doc = await database.fetch_one(
"SELECT * FROM documents WHERE id = :doc_id AND user_id = :user_id",
{"doc_id": doc_id, "user_id": user_id}
)
if not doc:
raise HTTPException(status_code=404, detail="Not found")
return {"id": doc["id"], "title": doc["title"], "content": doc["content"]}
@app.post("/api/documents")
async def create_document(data: dict, current_user: dict = Depends(get_current_user)):
doc_id = str(uuid.uuid4())
await database.execute(
"INSERT INTO documents (id, user_id, title, content) VALUES (:id, :uid, :title, :content)",
{"id": doc_id, "uid": current_user["id"], "title": data["title"], "content": data.get("content", "")}
)
return {"id": doc_id}
Summary
IDOR vulnerabilities are prevented by authorization checks on every resource access, UUID-based IDs, and indirect reference maps.
Key takeaways: | Always check: does this resource belong to the requesting user? | | Test horizontal IDOR: same role, different user ID | | Test vertical IDOR: lower privilege accessing higher privilege | | Use UUIDs to prevent enumeration | | Indirect reference maps: user sees ref-code, server maps to real ID | | GraphQL: depth limiting and query costing prevent abuse | | Automation: iterate through IDs programmatically and check access |
Next Chapter: Rate Limiting
The next chapter covers rate limiting testing and bypass techniques.
Key Remediation
Always validate that the authenticated user owns the requested resource before returning data. Use UUIDs instead of sequential IDs to prevent enumeration.
Key rule: Never trust user-supplied IDs without ownership verification.