Application maps a user-visible token (hash, GUID) to an internal resource, but the mapping is predictable or enumerable.
TL;DR
Indirect object reference IDOR occurs when an application replaces an internal database ID with a secondary identifier — a filename, hash, reference code, UUID, or slug — that appears opaque to clients but is actually predictable, reversible, or discoverable through legitimate application behavior. The security assumption is that if the identifier appears random, attackers cannot guess it. This assumption fails in three distinct ways: the identifier is computed from predictable inputs (MD5, Base64, UUID v1), it is leaked through legitimate application features (shared links, email notifications, Referer headers), or it uses a scheme with a narrow enough search space to brute-force.
CWE-639 covers this variant identically to direct IDOR: the authorization bypass is through a user-controlled key, regardless of whether that key is a sequential integer or a seemingly opaque reference. OWASP's IDOR Prevention Cheat Sheet explicitly distinguishes between using opaque references (a mitigation against enumeration) and implementing authorization (the actual security control). These are complementary but not interchangeable.
The key practitioner insight: switching from sequential integers to hashed or UUID-based references converts an IDOR from "trivially exploitable by any attacker" to "exploitable by a more skilled attacker." That is valuable defense-in-depth but does not eliminate the authorization requirement.
Base64-encoded ID — trivially decoded, not encrypted:
Token: eyJ1c2VyX2lkIjoxMzM3fQ==
Decode: {"user_id":1337}
Modify: {"user_id":1338}
Re-encode: eyJ1c2VyX2lkIjoxMzM4fQ==MD5(user_id) — computable for the entire integer ID space in seconds:
import hashlib
# The "opaque" reference
token_in_response = "8277e0910d750195b448797616e091ad" # md5("1337")
# Brute-force: compute md5(1) through md5(10000000)
for user_id in range(1, 10_000_001):
candidate = hashlib.md5(str(user_id).encode()).hexdigest()
if candidate == token_in_response:
print(f"Resolved: {user_id}") # finds 1337 almost instantly
breakSequential string reference — extract and increment the numeric component:
INV-2024-04521 → try INV-2024-04520, INV-2024-04522
ORD-2025-001337 → try ORD-2025-001336, ORD-2025-001338This is the most misunderstood distinction in indirect reference IDOR. UUID v1 encodes the current timestamp (100-nanosecond intervals since October 15, 1582) and the server's MAC address:
UUID v1: 11ef-8888-02c9-0000-0000-1eb6f832-0a01
↑ timestamp fields (predictable within clock window)
↑ MAC address (constant per server)
UUID v4: 550e8400-e29b-41d4-a716-446655440000
All fields randomly generated — 2^122 possible valuesThe UUID v1 sandwich attack: an attacker creates an account immediately before and immediately after a target user. The two resulting UUID v1 values bracket the target's creation time. The attacker brute-forces all UUID v1 values within that time window:
import uuid
import time
# Attacker registers account A — records uuid_a (timestamp T1)
# Target user registers between A and B — unknown uuid (timestamp T_target)
# Attacker registers account B — records uuid_b (timestamp T2)
# Brute-force: enumerate all UUID v1 values between T1 and T2
# The timestamp resolution is 100ns → typically < 10,000 values to test
# If the MAC component is constant (same server), only timestamp varies
def brute_force_uuidv1(uuid_before, uuid_after):
ts_before = uuid.UUID(uuid_before).time
ts_after = uuid.UUID(uuid_after).time
mac = uuid.UUID(uuid_before).node # constant per server
clock_seq = uuid.UUID(uuid_before).clock_seq # usually stable
for timestamp in range(ts_before + 1, ts_after):
candidate = uuid.UUID(fields=(
timestamp & 0xFFFFFFFF,
(timestamp >> 32) & 0xFFFF,
(timestamp >> 48) & 0x0FFF | 0x1000,
(clock_seq >> 8) | 0x80,
clock_seq & 0xFF,
mac
))
yield str(candidate) # test each against the APICVE-2024-45719 — Apache Answer exploited exactly this pattern. Password reset tokens were UUID v1 values. An attacker who obtained any UUID from the public API response could brute-force valid reset tokens for concurrent account registrations within the predictable timestamp window, enabling account takeover without the user's email access.
Second-order IDOR is temporally separated: the attacker supplies a reference during one operation (registration, profile creation, form submission), and the application consumes that reference in a later operation without re-validating ownership. DAST tools that replay individual requests cannot detect this because the authorization failure manifests in a different request from the injection.
Step 1: POST /api/register
{"email": "attacker@evil.com", "linked_account_id": 1338}
→ Server stores linked_account_id=1338 in attacker's profile
→ Response: 201 Created (no error — 1338 looks like a valid ID)
Step 2: (Later, different session) GET /api/dashboard
Authorization: Bearer <attacker_token>
→ Dashboard auto-fetches linked_account_id=1338 from attacker's profile
→ Returns User 1338's linked data without re-checking ownership
→ IDOR confirmed — data accessible only in step 2HTTP parameter pollution submits the same parameter multiple times to exploit framework-specific parsing behavior:
# Duplicate query parameters — different frameworks process differently
GET /api/orders?user_id=1337&user_id=1338
# JSON array in ID field — some ORMs process as OR query
{"user_id": [1337, 1338]}
# Wildcard injection — some APIs accept * as ID
{"user_id": "*"}| Variant | Technique | Reversibility |
|---|---|---|
| Base64(id) | Decode → modify integer → re-encode | Trivial |
| MD5(id) / SHA1(id) | Precompute entire integer ID space | Easy (< 1 second) |
| UUID v1 | Sandwich attack on creation timestamp | Medium (< 10,000 guesses) |
| Sequential string reference | Extract numeric suffix, increment | Easy |
| Filename enumeration | Username/pattern discovery first | Medium |
| Second-order stored | Inject in step 1, exploit in step 2 | Single-request DAST misses it |
| Parameter pollution | Duplicate params exploit parser quirks | Context-specific |
CVE-2024-45719 — Apache Answer (Predictable UUID v1 Tokens, 2024) — Apache Answer used UUID Version 1 for password reset tokens. UUID v1 encodes a timestamp and a MAC address component. An attacker who obtained any UUID v1 value from the application (publicly available in API responses) could enumerate valid reset tokens for accounts registered within a predictable time window using the sandwich attack. Brute-forcing the timestamp range with the known MAC component reduced the search space to thousands of candidates — feasible in seconds. Impact: account takeover without knowledge of the target's email or password.
CVE-2023-4836 — WordPress User Private Files Plugin — The plugin stored private user files at GET /wp-content/uploads/private/{user_id}/{filename}. While user_id is an integer (direct reference), the filename component was derived from the original upload name — a predictable indirect reference. Subscribers with low privilege could access private documents from any other user by guessing filenames (original upload names, common patterns, or enumerated via error messages).
CVE-2024-1626 — lunary-ai LLM Observability Platform (February 2024) — The lunary-ai platform, which stores LLM observability data including API keys and proprietary prompts, was vulnerable to IDOR allowing access to other users' LLM run data. References used to access run data were obtainable via legitimate API calls, enabling an authenticated user to pivot to any other user's AI session data and — critically — their OpenAI and Anthropic API keys.
Twitter/X API Breach (2022, 5.4 Million Records) — A Twitter API endpoint accepted an email address or phone number as an indirect reference and returned the associated account details. This is indirect object reference IDOR via attribute enumeration: the reference type is not a numeric ID but a personally identifiable attribute. Researcher submitted via HackerOne (bounty $5,040); data was later sold for $30,000.
md5(1) through md5(100) and compare to known references.M nibble at position 13 — 1 = UUID v1, 4 = UUID v4.import uuid
def identify_uuid_version(uuid_string):
"""Identify UUID version and assess predictability."""
u = uuid.UUID(uuid_string)
version = u.version
if version == 1:
# Timestamp is predictable — compute creation time
timestamp = u.time
# UUID v1 timestamp = 100-nanosecond intervals since 1582-10-15
import datetime
epoch = datetime.datetime(1582, 10, 15)
creation_time = epoch + datetime.timedelta(microseconds=timestamp // 10)
print(f"UUID v1 — created at: {creation_time} — PREDICTABLE")
print(f"MAC component: {hex(u.node)}")
elif version == 4:
print("UUID v4 — random — enumeration not feasible")
return versionFor UUID-based endpoints, BreachVex generates realistic adjacent UUIDs instead of null UUIDs (which return 404 for format reasons). For UUID v1 endpoints, it tests timestamp-adjacent UUIDs derived from the known clock window. For Base64 references, it decodes, increments, and re-encodes automatically.
BreachVex handles the indirect-reference taxonomy by detecting the reference format in endpoint URL templates before selecting the enumeration strategy.
OWASP's IDOR Prevention Cheat Sheet recommends an "indirect reference map": a server-side lookup table that maps randomly generated tokens to internal IDs. The application generates token = uuid4(), stores {token: internal_id} in a session-scoped or database-backed map, and exposes only the token. Resolving the token requires a database lookup that can enforce ownership simultaneously. This is architecturally more secure than computing a reversible hash because the mapping is not derivable from the token.
The most secure pattern: the server generates an opaque random token, stores the token-to-ID mapping, and resolves it on each request with an ownership check:
import uuid
from fastapi import HTTPException
# Generate and store opaque reference
async def create_invoice_reference(invoice: Invoice, owner_id: int) -> str:
token = str(uuid.uuid4()) # cryptographically random — not uuid1()
await db.execute(
insert(InvoiceToken).values(
token=token,
invoice_id=invoice.id,
owner_id=owner_id
)
)
return token # token is what the client sees, never the integer ID
# Resolve with ownership check — the mapping enforces authorization
async def resolve_invoice_token(token: str, requesting_user_id: int) -> Invoice:
mapping = await db.execute(
select(InvoiceToken)
.where(InvoiceToken.token == token)
.where(InvoiceToken.owner_id == requesting_user_id) # ownership gate
)
if not mapping:
raise HTTPException(status_code=404)
return await db.get(Invoice, mapping.invoice_id)Always use uuid.uuid4() for externally visible identifiers, never uuid.uuid1():
import uuid
# VULNERABLE — timestamp-predictable (CVE-2024-45719 pattern)
reset_token = str(uuid.uuid1()) # encodes current timestamp + MAC
# SAFE — cryptographically random, 2^122 possible values
reset_token = str(uuid.uuid4()) # no predictable structure
# Even safer: use secrets module for tokens that require cryptographic security
import secrets
reset_token = secrets.token_urlsafe(32) # 256 bits of entropy# VULNERABLE — stores attacker-supplied ID without validation
@router.post("/api/register")
async def register(data: RegistrationData):
user = User(
email=data.email,
linked_account_id=data.linked_account_id # stored without ownership check
)
await db.add(user)
# SAFE — verify ownership of the linked account at storage time
@router.post("/api/register")
async def register(data: RegistrationData, current_user: User = Depends(get_current_user)):
if data.linked_account_id:
account = await db.execute(
select(Account)
.where(Account.id == data.linked_account_id)
.where(Account.owner_id == current_user.id) # ownership check at storage
)
if not account:
raise HTTPException(status_code=404)
user = User(email=data.email, linked_account_id=data.linked_account_id)
await db.add(user)# VULNERABLE — opaque filename but no ownership check
def download_invoice(request):
ref = request.GET.get("ref")
invoice = Invoice.objects.get(ref=ref) # any user's invoice
return FileResponse(open(invoice.pdf_path, "rb"))
# SAFE — ownership enforced on the secondary identifier
def download_invoice(request):
ref = request.GET.get("ref")
invoice = get_object_or_404(Invoice, ref=ref, owner=request.user)
return FileResponse(open(invoice.pdf_path, "rb"))An indirect object reference replaces an internal database ID with a secondary identifier — a filename, hash, slug, or UUID — that maps server-side to the underlying record. The goal is to prevent enumeration. The vulnerability occurs when that secondary reference is predictable, reversible, or can be obtained by an attacker through legitimate application behavior.
No. MD5 is a one-way hash, but when applied to small integer IDs (1 to 10,000,000), the entire precomputed rainbow table can be generated in seconds. An attacker who knows the scheme — usually discovered by computing md5('1') and comparing to the reference in an API response — can enumerate all user MD5 hashes in milliseconds.
UUID v1 encodes a timestamp (100-nanosecond intervals since October 15, 1582) and a MAC address. If an attacker creates an account immediately before and after a target user (the 'sandwich'), they obtain two UUIDs bracketing the target's creation time. Brute-forcing the ~10,000 UUID v1 values in that time window is feasible in under a second on modern hardware.
CVE-2024-45719 in Apache Answer used UUID v1 for password reset tokens. An attacker who obtained any UUID v1 from the application (e.g., from a public API response) could brute-force valid reset tokens for other accounts within the predictable timestamp range, enabling account takeover.
UUID v4 prevents enumeration-based IDOR because it is cryptographically random (2^122 possible values). It does not prevent IDOR when the attacker legitimately obtains a reference — for example, from a shared document link, an email notification, a Referer header, or another user's API response. Authorization checks remain mandatory regardless of ID scheme.
Second-order IDOR occurs when a user-supplied identifier is stored during one request and consumed in a later request without re-validating ownership. Example: POST /register with {linked_account_id: 1338} stores the reference; later, GET /dashboard auto-fetches linked_account_id=1338 without checking whether the authenticated user is 1338. DAST tools that replay single requests cannot detect this.
HTTP parameter pollution submits the same parameter multiple times in one request: ?user_id=1337&user_id=1338. Framework behavior differs: some process the first occurrence, some the last, some concatenate. Testing parameter pollution can bypass filters that only sanitize a single parameter occurrence.
Use a server-side mapping table: the application generates a cryptographically random token (UUID v4 or securely generated slug), stores the mapping (token → internal_id) in the database, and exposes only the token to clients. The server resolves the token to the internal ID during request processing and enforces ownership at the database query level.