Numeric or sequential ID in URL or request body directly references a server-side resource without authorization checks.
TL;DR
Direct object reference IDOR is the foundational form of the vulnerability class: an application exposes a numeric or sequential database primary key in a user-visible location — a URL path segment, query parameter, request body field, or HTTP header — and uses that client-supplied value as a direct database lookup key without verifying that the authenticated user owns the referenced record.
The vulnerability persists because sequential IDs are the default in every major relational database (PostgreSQL SERIAL, MySQL AUTO_INCREMENT, SQLite AUTOINCREMENT). Developers implement authentication correctly but skip the second authorization step: confirming this identity may access this specific record. The result is that authentication and authorization appear synonymous until a security tester supplies someone else's ID.
CWE-639 (Authorization Bypass Through User-Controlled Key) is the precise classification. OWASP A01:2021 and A01:2025 both position broken access control — of which direct IDOR is the most prevalent subtype — as the highest-risk web vulnerability category. OWASP API Security Top 10 positions BOLA (the API equivalent) at API1:2023, unchanged from 2019, because the pattern remains as common as ever despite being trivially preventable.
The attack has four injection points, all of which must be tested independently:
1. URL path segment:
GET /api/orders/1046 HTTP/1.1
Host: target.com
Authorization: Bearer <attacker_token>2. Query string parameter:
GET /api/orders?order_id=1046 HTTP/1.1
Host: target.com
Authorization: Bearer <attacker_token>3. Request body (PUT/PATCH/POST):
PATCH /api/orders/update HTTP/1.1
Content-Type: application/json
{"order_id": 1046, "status": "cancelled"}4. HTTP header:
GET /api/dashboard HTTP/1.1
X-User-ID: 1046
Authorization: Bearer <attacker_token>The enumeration strategy adapts to the ID format:
| ID Format | Enumeration Technique | Difficulty |
|---|---|---|
Sequential integer (1337) | Loop +1, -1, or ±100, ±1000 | Trivial |
Sequential string (INV-2024-04521) | Extract numeric suffix, increment | Easy |
Base64-encoded (eyJ1c2VyX2lkIjoxMzM3fQ==) | Decode → modify integer → re-encode | Easy |
Email address (?email=victim@corp.com) | OSINT/dictionary | Easy |
Filename (avatar_john_doe.jpg) | Username enumeration first | Medium |
| UUID v1 (timestamp) | Partial — sandwich attack on creation time | Medium |
| UUID v4 (random) | Infeasible without leakage | Hard |
| MD5/SHA1(id) | Precompute md5(1..10000) | Hard but finite |
| Variant | Technique | Impact |
|---|---|---|
| GET enumeration | Increment ID in GET requests | Full PII read |
| Write access IDOR | PUT/PATCH with another user's ID | Data modification |
| Delete IDOR | DELETE with another user's ID | Data destruction |
| Bulk/batch IDOR | Array of IDs in single request body | Mass exfiltration |
| Unauthenticated IDOR | No session token required at all | Maximum impact |
| Second-order (stored) | ID submitted now, consumed in a later request | Bypass DAST |
Bulk IDOR deserves special attention: some APIs accept arrays of IDs to reduce HTTP round-trips. A single request can exfiltrate thousands of records and often bypasses rate limiting because it counts as one request:
POST /api/documents/fetch
Authorization: Bearer <attacker_token>
{"document_ids": [1001, 1002, 1003, 5000, 9999, 50000]}If documents belonging to different users are returned, this is a confirmed mass IDOR exfiltrating all referenced records in a single call.
Optus Data Breach (September 2022) — A public-facing REST API at /api/customers/{id} returned customer records keyed by sequential integer IDs with no authentication. Attackers issued GET /api/customers/1 through GET /api/customers/9800000 iteratively, extracting names, dates of birth, addresses, and passport numbers for 9.8 million Australians. The fix had been applied to the main domain in 2021 after an internal audit but was never propagated to a secondary subdomain that remained externally accessible. Regulatory outcome: AUD $1.36 million fine from the Australian Communications and Media Authority.
CVE-2025-13526 — WordPress OneClick Chat to Order (Unauthenticated, 2025) — Versions of the WordPress plugin OneClick Chat to Order up to and including 1.0.8 accepted the order_id parameter in the plugin's order-details endpoint without any authentication or ownership validation. Any unauthenticated HTTP request could retrieve full order details including customer names, email addresses, phone numbers, billing and shipping addresses, ordered items, prices, and payment method metadata. The vulnerability was exploitable by any internet-connected client — no account required.
McDonald's McHire AI Chatbot (July 2025) — The lead_id parameter on the internal endpoint /api/lead/cem-xhr was a sequential integer. Researchers Ian Carroll and Sam Curry demonstrated that decrementing or incrementing the value returned another job applicant's complete chat transcript: name, phone, email, shift preferences, personality test outcomes, and session tokens reusable to impersonate candidates on the platform. Estimated exposure: 64 million applicants across all McDonald's locations using McHire.
CVE-2024-55471 — Oqtane Framework (December 2024) — The ASP.NET Core CMS UserController.Get endpoint performed no authorization check on the id parameter. Any authenticated user retrieved any other user's profile by incrementing id. The fix was shipped in Oqtane v6.0.1.
# Burp Suite Intruder — sequential ID fuzzing
# Set the ID parameter as the injection point
# Payload type: Numbers (sequential from 1000 to 2000, step 1)
# Grep response for: email, name, address, phone
# Command-line equivalent with curl
for id in $(seq 1040 1060); do
echo "Testing ID $id:"
curl -s -H "Authorization: Bearer $TOKEN_A" \
"https://target.com/api/orders/$id" | jq '.owner_email'
doneBurp Suite Autorize extension replays every intercepted authenticated request using a second user's session cookie. Responses are color-coded: red indicates likely vulnerable (access granted), green indicates protected. This covers all four injection point categories automatically for intercepted traffic.
Nuclei has community templates for sequential ID enumeration on common API patterns. Run with -t exposures/ids/ for ID-specific templates.
BreachVex detects direct IDOR by probing ID-bearing endpoints at sequential deltas around the baseline ID (±1, ±2, ±5, ±10, ±100, ±1000). Confirmation requires at least two corroborating signals: status-code bypass (403→200), ID echo in the response body, PII field divergence, or a JSON structure match with differing values. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N (6.5 base, escalates to 8.8 with write access).
Soft-404 responses mask some IDOR attempts: Django and Flask applications may return HTTP 200 with "Record not found" in the body for invalid IDs. Filter responses for markers like "not found", "does not exist", "invalid id" before concluding the endpoint is protected. A 200 status alone does not confirm access.
The fundamental fix: every database query on a user-owned resource must include the ownership constraint as a structural requirement. The ownership check cannot be omitted by mistake if it is part of the query itself.
# FastAPI / SQLAlchemy — VULNERABLE
@router.get("/api/invoices/{invoice_id}")
async def get_invoice(invoice_id: int, user: User = Depends(get_current_user)):
invoice = await db.get(Invoice, invoice_id) # fetches ANY invoice
return invoice
# FastAPI / SQLAlchemy — SAFE
@router.get("/api/invoices/{invoice_id}")
async def get_invoice(invoice_id: int, user: User = Depends(get_current_user)):
invoice = await db.execute(
select(Invoice)
.where(Invoice.id == invoice_id)
.where(Invoice.owner_id == user.id) # structural ownership gate
)
if not invoice:
raise HTTPException(status_code=404) # never 403
return invoice// Express / Sequelize — VULNERABLE
router.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await Order.findByPk(req.params.id); // any order
res.json(order);
});
// Express / Sequelize — SAFE
router.get('/api/orders/:id', authenticate, async (req, res) => {
const order = await Order.findOne({
where: {
id: req.params.id,
userId: req.user.id // ownership enforced at query level
}
});
if (!order) return res.status(404).json({ error: 'Not found' });
res.json(order);
});Auto-increment integers must not be exposed in public APIs. UUID v4 raises the enumeration bar from "trivial loop" to "infeasible brute force" (2¹²² possible values):
import uuid
from sqlalchemy import Column, String
class Invoice(Base):
# VULNERABLE — sequential int exposed in API
id = Column(Integer, primary_key=True) # internal DB use only
# SAFE — random UUID as public identifier
public_id = Column(String, default=lambda: str(uuid.uuid4()), unique=True)
# API exposes public_id, never id
@router.get("/api/invoices/{public_id}")
async def get_invoice(public_id: str, user: User = Depends(get_current_user)):
invoice = await db.execute(
select(Invoice)
.where(Invoice.public_id == public_id)
.where(Invoice.owner_id == user.id)
)
if not invoice:
raise HTTPException(status_code=404)
return invoiceUUID v4 prevents enumeration-based IDOR. Authorization checks remain mandatory — a shared document link gives any recipient a valid UUID they can use to access the resource if authorization is absent.
Centralizing ownership checks prevents the developer inconsistency that caused CVE-2026-29056 in Kanboard — where one code path (registration) lacked the ownership check that another (settings) had correctly implemented:
// Reusable middleware — authorization enforced before handler executes
const requireOwnership = (Model, idParam = 'id') => async (req, res, next) => {
const resource = await Model.findByPk(req.params[idParam]);
if (!resource) return res.status(404).json({ error: 'Not found' });
if (resource.userId !== req.user.id) {
return res.status(404).json({ error: 'Not found' }); // not 403
}
req.resource = resource;
next();
};
// Applied consistently across all resource routes
router.get('/api/documents/:id', authenticate, requireOwnership(Document), handler);
router.put('/api/documents/:id', authenticate, requireOwnership(Document), handler);
router.delete('/api/documents/:id', authenticate, requireOwnership(Document), handler);A direct object reference IDOR occurs when an application exposes internal database primary keys — typically sequential integers — in URLs, query parameters, or request body fields. An attacker increments or decrements the ID to access records belonging to other users.
The attacker authenticates as a legitimate user, notes a numeric identifier in a response (e.g., order_id=1045), then replaces it with adjacent values (1044, 1046, 1000, 2000) in subsequent requests using their own session token. If the server returns another user's record, the endpoint is vulnerable.
CVE-2025-13526 in the WordPress OneClick Chat to Order plugin (versions <= 1.0.8) allowed unauthenticated users to retrieve any order's full details — customer names, email addresses, phone numbers, billing/shipping addresses, and payment metadata — by manipulating the order_id parameter.
BreachVex tests ID-bearing endpoints, probing each at delta values such as ±1, ±2, ±5, ±10, ±100, and ±1000 from the baseline ID. Detection confirms via ID echo in the response body, PII divergence, or a status-code differential (403→200).
Always return 404. Returning 403 confirms the resource exists, creating an enumeration oracle that tells attackers exactly which IDs are valid. A consistent 404 forces attackers to distinguish 'does not exist' from 'exists but forbidden' without a deterministic signal.
Direct IDOR and BOLA describe the same vulnerability in different contexts. IDOR is the general web term; BOLA (Broken Object Level Authorization) is the API-specific term used by OWASP API Security Top 10 (API1:2023). Both involve accessing an object by a user-controlled ID without ownership validation.
Yes. Some APIs consume user IDs from custom headers such as X-User-ID, X-Account-ID, or X-Customer-ID. These are less commonly tested and frequently lack authorization middleware. Always test header-based ID parameters alongside URL and body parameters.
The Optus breach (September 2022) exposed 9.8 million customer records via a REST API endpoint that returned customer data keyed by sequential integer IDs with no authentication. Attackers iterated GET /api/customers/1 through /api/customers/9800000. It is the canonical real-world demonstration of direct IDOR at scale.