GraphNode
Back to all posts
AppSec

IDOR: Why /api/users/{id} Still Leaks Data in 2026

| 11 min read |GraphNode Research

In May 2021, Pen Test Partners researcher Jan Masters published a disclosure that turned into the textbook IDOR case study of the decade. He had been testing Peloton's APIs and discovered that several endpoints powering the company's leaderboard and account features accepted a numeric user ID and returned the corresponding profile information without verifying the requesting user had any business asking for it. Age, gender, city, weight, workout statistics, and birthday were readable for any of the roughly three million account holders by a request that simply substituted one ID for another, and at one point in the disclosure window the requests did not even need to be authenticated. Masters reported the issue in January 2021. The endpoints stayed exploitable for more than ninety days while the company's disclosure team failed to acknowledge the report and eventually shipped a partial fix that still allowed authenticated members to scrape the same data. TechCrunch ran the story, the CEO issued a statement, and the disclosure became required reading in every API security training course written since. Insecure Direct Object Reference is older than the OWASP Top 10 itself, and the Peloton case shows it ships in the APIs of well-resourced consumer companies with security teams.

The pattern is consistent. The US Postal Service ran an IDOR on its Informed Visibility API in 2018 that exposed account details for sixty million users until Brian Krebs reported it. Facebook's 2018 access-token incident that disclosed fifty million accounts traced back to an authorization gap in the View As feature where a profile-lookup ID was trusted without ownership verification. T-Mobile's January 2023 breach touching thirty-seven million customer records was, per the company's SEC filing, an API abuse where the attacker iterated through identifiers and pulled records the API should never have returned. This article walks through what IDOR mechanically is, how the OWASP API Top 10 reframed it as Broken Object Level Authorization, what vulnerable and fixed code looks like in Express and Django, why the bug class refuses to die, and what to do about it before the next API endpoint near you starts shipping arbitrary user records.

What IDOR Actually Is

Insecure Direct Object Reference is the failure mode where a server uses an attacker-controlled identifier to look up an object and returns that object without verifying the requester is authorized to see it. The identifier arrives in a URL path, a query string, a request body, a header, or a hidden form field; the server takes the value at face value, hands it to the database, and returns whatever comes back. The vulnerability has nothing to do with authentication. The user is logged in. The session is valid. The cookie is signed. What is missing is the second check — the one that asks not "is this person who they claim to be" but "is this person allowed to read this specific record." When that check is absent, every record in the table reachable through that endpoint becomes readable, writable, or deletable by every authenticated user with the imagination to change a number in a URL. The underlying assumption that produces the bug is structural: web frameworks routinely treat URL path parameters as inputs to a database lookup, and mapping /api/orders/:id directly to db.orders.findById(req.params.id) is precisely what the framework is designed to encourage. The authorization gap is invisible at the framework level because the framework cannot know which records belong to which users; that knowledge lives in application logic the developer has not yet written.

BOLA, or Why the API Industry Renamed the Same Bug

The OWASP API Security Top 10, first published in 2019 and refreshed in 2023, lists Broken Object Level Authorization as API1 — the number one risk in API-driven applications. It is the same bug as IDOR. The rename matters because the API context shifted what the typical exploit looks like. In a server-rendered web application, IDOR usually appears in HTML form actions and link parameters, where a tester has to navigate a UI to find vulnerable identifiers. In a JSON API consumed by a mobile or single-page application, every endpoint is documented in OpenAPI specs, sniffed in proxy traces, or guessable from naming conventions the framework imposes; the tester has the entire object graph in front of them and can iterate through identifiers programmatically. APIs also tend to be wider than UI surfaces — the same data exposed in three or four forms in a browser ships in dozens of granular endpoints in an API, multiplying the number of places the ownership check has to live. Treating BOLA as a separate Top 10 entry was a deliberate decision to force API designers to think about object-level authorization as a first-class concern.

The Vulnerable Express Handler You Have Probably Shipped

Express makes the unsafe shape almost inevitable. The framework's routing model encourages parameterized URLs that map directly to database lookups, and the most common ORM signatures in Node.js — findById, findOne({ _id }), findByPk — take an identifier and return a record without any authorization context. The pattern below appears in tens of thousands of production codebases:

// VULNERABLE
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.orders.findById(req.params.id);
  if (!order) return res.status(404).json({ error: 'Not found' });
  res.json(order);
});

The requireAuth middleware is doing its job. The user is authenticated. req.user.id is populated. The handler then ignores it entirely and returns whatever order matches the path parameter. Every order in the database is readable by every logged-in user. The fix is to push the authorization predicate into the database query itself, where it cannot be forgotten by a developer who is reading the handler in isolation:

// FIXED
app.get('/api/orders/:id', requireAuth, async (req, res) => {
  const order = await db.orders.findOne({
    _id: req.params.id,
    userId: req.user.id,
  });
  if (!order) return res.status(404).json({ error: 'Not found' });
  res.json(order);
});

Two changes carry the weight. The query now matches on both the requested ID and the authenticated user's ID, so an order that does not belong to the requester returns no row and the handler responds with the same 404 it would for a nonexistent order. Returning 404 instead of 403 matters; a 403 response confirms the resource exists, which leaks enumeration information to an attacker probing the endpoint. For multi-role applications where several users legitimately access the same resource — a doctor reading a patient's chart, an admin reading any user's order — the inline predicate stops scaling and the right move is an explicit authorization helper such as authorize(req.user, 'read', order) that delegates to a policy module like Cedar, OPA, or a colocated permissions.ts file.

The Same Bug in Django

Django's class-based views and DRF viewsets ship IDOR equally easily. The default RetrieveAPIView with a queryset = Order.objects.all() returns any object whose primary key matches the URL parameter, regardless of which user is logged in:

# VULNERABLE
from rest_framework import generics
from .models import Order
from .serializers import OrderSerializer

class OrderDetailView(generics.RetrieveAPIView):
    queryset = Order.objects.all()
    serializer_class = OrderSerializer
    permission_classes = [IsAuthenticated]

IsAuthenticated confirms the request has a valid session. It does not confirm the session owner has any relationship to the requested order. The fix is to override get_queryset so the queryset itself is scoped to the requesting user, which means the lookup DRF performs on the URL parameter is bounded by the ownership predicate and a foreign user's ID returns the same 404 a missing record would:

# FIXED
from rest_framework import generics
from .models import Order
from .serializers import OrderSerializer

class OrderDetailView(generics.RetrieveAPIView):
    serializer_class = OrderSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        return Order.objects.filter(user=self.request.user)

For applications with richer authorization rules, Django's per-object permission systems — django-guardian, django-rules, or a custom BasePermission with has_object_permission — push the check into the same place the rest of the authorization rules live. The queryset DRF uses to resolve the URL parameter must be scoped before the lookup runs, not validated after the object is in hand.

Why IDOR Refuses to Die

Three structural reasons keep IDOR alive in 2026. First, the implicit assumption that authentication implies authorization is baked into the way most engineers learn web development. The login wall feels like the gate; once a user is past it, the framework's request object carries their identity through every handler, and the engineer's mental model treats the session as a license to operate on the application's data. Second, MVC frameworks separate the model layer from the controller layer in ways that make object-level authorization a no-man's-land — models know about data shape, controllers know about request handling, and neither layer naturally owns the question "should this caller see this row." Third, code reviewers rarely check ownership. A pull request that adds a new endpoint reads as a small diff: a route, a handler, a query, a serializer. The reviewer would have to mentally simulate every other authenticated user calling the endpoint with a different ID to notice the gap, and that simulation is not on the typical checklist. Until ownership verification is treated as a first-class concern with tooling that flags its absence, the next greenfield API will ship the same default Peloton shipped in 2021.

The UUID Misconception

A common defense raised in design reviews is that the application uses UUIDs instead of sequential integers, so attackers cannot guess valid identifiers. This is security through obscurity dressed in the vocabulary of cryptography. UUIDs are not secrets. They appear in URLs pasted into chat applications, logged by proxies and CDN caches, indexed by browser histories, captured in screen recordings shared in support tickets, and stored in third-party analytics platforms. Real IDOR exploitation rarely depends on guessing identifiers from scratch — the attacker harvests legitimate IDs from leaked sources, intercepts requests through a proxy and substitutes captured IDs into another session's request, scrapes IDs from adjacent endpoints that list them, or pulls them out of referrer headers. The Peloton case is illustrative because Pen Test Partners did not need to enumerate at all; adjacent endpoints already leaked the IDs. UUIDs raise the cost of one specific attack technique by perhaps an order of magnitude. They do nothing about the underlying authorization gap.

Detection: Where Each Layer Earns Its Keep

IDOR has a fingerprint static analysis catches when the analyzer is willing to trace data flow across the boundary between the request object and the database call. SAST follows the path from req.params.id, request.GET['id'], or @PathVariable Long id into the ORM call that uses it, and flags handlers where the lookup runs on the URL parameter without an accompanying predicate that joins on the authenticated user identity. A finding fires when an authenticated handler invokes findById, get_object_or_404, findByPk, or the language equivalent on a request-derived value with no ownership filter in the same flow.

DAST attacks the running application by replaying authenticated requests with substituted identifiers and watching for 200 responses on resources that should return 404. Forced-browsing tools such as Burp's Autorize and ZAP's HUD automate the substitution and diff responses across two authenticated sessions. Manual testing finds the deepest IDORs because a human tester reads the OpenAPI spec, notices the same customer ID appears in four different endpoints, and tries each with another customer's ID even when only one was in scope. SCA does not help here — the bug is application logic, not a vulnerable dependency. The cheapest layer remains SAST on the diff that introduced the unsafe handler.

Prevention Checklist

Six rules close the overwhelming majority of real-world IDOR. Apply them in order; each later rule assumes the earlier ones are in place.

  • Never trust an ID parameter from the request as a license to access the underlying object. Treat every ID arriving from a URL, query string, body, or header as attacker-controlled.
  • Always include the authenticated user identity in the database query. Use WHERE id = :id AND owner_id = :user, not a two-step SELECT followed by an in-memory check someone will eventually forget.
  • Use an authorization framework instead of inline checks. Spring Security's @PreAuthorize, Django's per-object permissions, Cedar, OPA, or a colocated policy module centralizes the rules and makes them auditable.
  • Reject with 404, not 403, when ownership fails. A 403 confirms the record exists, leaking enumeration data to attackers probing the endpoint.
  • Emit audit logs for failed authorization attempts. A spike in 404s for valid identifiers belonging to other users is the signal a probing attacker generates; without logs, the probe is invisible until the data is on a forum.
  • Review every CRUD endpoint with ownership as a checklist item. Pull-request templates that ask reviewers to confirm the ownership check lives in the database query catch the missed override while the fix is still a one-line change.

Where GraphNode SAST Fits

GraphNode SAST traces the data flow from request parameters into ORM lookup calls across 13+ languages, flagging the specific handlers where the lookup runs on a URL-derived identifier without a corresponding ownership predicate in the same query. Findings surface on the diff that introduced the unsafe handler, with the engineer who wrote it. IDOR is the classic A01 Broken Access Control case in OWASP's web Top 10 and BOLA in the API Top 10; SAST catches the missed ownership check at the only point in the lifecycle where the fix is still a one-line change to the query.

Closing

IDOR has a documented fix that fits inside a single WHERE clause. Despite that, it shipped in Peloton's leaderboard API in 2021, in the US Postal Service's Informed Visibility API in 2018, in the Facebook View As feature in 2018, in T-Mobile's customer API in 2023, and in the next greenfield service spinning up next door. The pattern persists because authentication still feels like authorization to most engineers, MVC frameworks have no natural home for object-level checks, and code reviewers rarely simulate adjacent users when reading a handler diff. The teams that stop shipping IDOR move detection upstream into the diff, gate the ownership predicate at code review, and treat every CRUD endpoint as a place where the question "who is allowed to see this row" has to be answered explicitly.

GraphNode SAST flags missing ownership checks across 13+ languages — request a demo.

Request Demo