Insecure Direct Object References: Stopping IDOR Before It Hits Prod
Your application checks that a user is logged in. It does not check whether the resource they just requested actually belongs to them. That gap is an Insecure Direct Object Reference, and it shows up in production codebases far more often than most teams expect.
IDOR is consistently ranked among the top web application vulnerabilities because the fix looks obvious in hindsight, yet the mistake is trivially easy to make under deadline pressure. A single missing authorization check can expose every user's private data through a sequential ID or a predictable filename.
What You'll Learn
- Exactly what makes an endpoint vulnerable to IDOR and how attackers exploit it
- Why authentication alone does not protect you
- Concrete code patterns for object-level authorization in REST APIs
- Where IDOR hides beyond the obvious URL parameter
- How to test for IDOR systematically before your code ships
What an IDOR Actually Is (and Why It's So Common)
An IDOR occurs when your application uses a user-supplied identifier β an integer ID, a UUID, a filename, a slug β to look up a resource, and then returns or modifies that resource without confirming the requesting user is allowed to access it. The vulnerability is a missing authorization check, not a flaw in the identifier itself.
The term appears in the OWASP Top 10 under Broken Object Level Authorization (BOLA) in the API Security Top 10. The name changed but the vulnerability is identical: your API trusts the caller's ID claim without verifying ownership.
It's common for a simple reason. Developers write a controller that fetches a record by primary key, confirm the user is authenticated, and ship it. The ownership check is a separate thought that gets added later β or not at all.
The Anatomy of a Vulnerable Endpoint
Here is a minimal Django REST Framework view that contains a classic IDOR:
# VULNERABLE β do not use in production
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .models import Invoice
from .serializers import InvoiceSerializer
class InvoiceDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, invoice_id):
invoice = Invoice.objects.get(pk=invoice_id) # <-- fetches ANY invoice
serializer = InvoiceSerializer(invoice)
return Response(serializer.data)
The IsAuthenticated permission class confirms a valid session exists. It says nothing about whether invoice_id=4821 belongs to the logged-in user. An attacker who owns invoice 4820 can increment the ID by one and read your other customer's billing data.
The HTTP request looks completely normal:
GET /api/invoices/4821/ HTTP/1.1
Authorization: Bearer eyJhbGciOi...
No special tools required. A browser's address bar or curl is sufficient.
Why IDOR Slips Past Code Review
Code reviewers typically scan for dangerous operations: SQL concatenation, deserialization, file writes. An objects.get(pk=invoice_id) call looks inert. There is nothing syntactically wrong with it, and linters will not flag a missing business-logic check.
Automated SAST tools also struggle here. They can identify injection patterns, but an authorization omission requires understanding the application's data model and ownership semantics β context that static analysis rarely has.
The problem compounds in large APIs. An endpoint added for an internal dashboard may not receive the same review scrutiny as a public-facing route. That internal endpoint often becomes accessible to external users once authentication is in place, without anyone revisiting the authorization layer. For a look at how a similar oversight plays out in GraphQL APIs, see locking down your GraphQL API against batching and introspection attacks β the principle of restricting what any one caller can reach applies equally there.
The Right Fix: Object-Level Authorization
The fix is to scope every database query to the authenticated user's identity. Never fetch a record by its primary key alone, then check ownership afterwards. Fetch by primary key and owner in the same query so the database returns nothing if the caller doesn't own it.
# SAFE β object-level authorization
class InvoiceDetailView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, invoice_id):
invoice = Invoice.objects.filter(
pk=invoice_id,
owner=request.user # ownership enforced at query level
).first()
if invoice is None:
return Response(status=404) # do not leak 403 vs 404 difference
serializer = InvoiceSerializer(invoice)
return Response(serializer.data)
Returning 404 instead of 403 when a record doesn't belong to the caller is a deliberate choice. A 403 confirms the resource exists, which gives an attacker useful information. A 404 reveals nothing.
In Django, you can use get_object_or_404 with a queryset already scoped to the current user:
from django.shortcuts import get_object_or_404
def get_owned_invoice(user, invoice_id):
return get_object_or_404(Invoice, pk=invoice_id, owner=user)
Centralize Authorization with a Helper Function
Repeating the ownership filter in every view is error-prone. One engineer under pressure forgets the filter and you have a regression. The safer pattern is a single function (or a base queryset on a model manager) that always returns only the records the current user can see.
class InvoiceQuerySet(models.QuerySet):
def for_user(self, user):
return self.filter(owner=user)
class Invoice(models.Model):
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
# ... other fields
objects = InvoiceQuerySet.as_manager()
Now every view uses Invoice.objects.for_user(request.user).get(pk=invoice_id). The ownership constraint is impossible to forget because the scoped queryset is the only ergonomic path.
For role-based systems (where an admin may access any record, or a manager can access their team's records), extend for_user to handle those cases in one place. Views stay thin and declarative; authorization logic lives in a single, testable layer.
IDOR in Non-Obvious Places
URL path parameters are the textbook example, but IDOR surfaces in several other locations that are easy to overlook.
Request Body Parameters
A POST /api/messages/ endpoint that accepts {"thread_id": 99} in the body is vulnerable if the handler doesn't verify the caller belongs to thread 99. The ID is hidden in JSON rather than the URL, which makes it slightly less obvious during testing.
Query String Parameters
Filters like GET /api/reports/?user_id=42 let callers enumerate other users' reports if the user_id filter isn't restricted to the caller's own identity or an admin role.
File Downloads
Endpoints that serve files by name or path (GET /files/export_march_2024.csv) are common IDOR targets. If filenames are predictable, attackers can request files they don't own. This is closely related to path traversal; ensure both the filename and the ownership of the underlying record are validated together. The same principle of validating all external input applies to XML parser inputs that trust caller-supplied entity references.
Indirect References in Webhooks and Callbacks
Webhook payloads often include resource IDs. If your application processes an incoming webhook and trusts the order_id field without verifying it belongs to the account that owns the webhook endpoint, you have an IDOR in an asynchronous path that is even harder to spot in a code review.
Batch / Bulk Endpoints
An endpoint that accepts an array of IDs to act on in bulk (DELETE /api/items/ with {"ids": [1, 2, 3]}) must check authorization for every ID in the array, not just the first one. A common mistake is to authenticate the caller and then pass the whole array to a bulk-delete ORM call without per-item ownership filtering.
Testing for IDOR Before Your Attacker Does
Automated scanners miss most IDOR vulnerabilities because the exploitability depends on application semantics. You need a deliberate manual or semi-automated testing approach.
Two-Account Testing
Create two test accounts β call them Alice and Bob. Log in as Alice, create a resource, and capture the resource's ID. Log in as Bob and attempt to read, modify, and delete that resource using the same ID. If Bob succeeds, the endpoint is vulnerable. This is the most reliable basic test and takes minutes to set up.
Authenticated Fuzzing with a Proxy
Tools like Burp Suite's Intruder or OWASP ZAP allow you to replay captured requests with modified parameters. Record Alice's session accessing her own resources, then replay those requests with Bob's session cookie while iterating over a range of IDs. Responses that return 200 with data are confirmed findings.
Test Coverage at the Unit Level
Write explicit unit tests for every resource-fetch endpoint that prove cross-user access fails:
def test_cannot_access_another_users_invoice(api_client, invoice_factory, user_factory):
alice = user_factory()
bob = user_factory()
invoice = invoice_factory(owner=alice)
api_client.force_authenticate(user=bob)
response = api_client.get(f"/api/invoices/{invoice.pk}/")
assert response.status_code == 404
This test documents the expected behavior and will catch any regression where the ownership filter is accidentally removed. Treat missing authorization the same way you treat missing input validation: verify it with an automated test, not just a code review. You can extend the same discipline to JWT validation checks, where a misconfigured token can bypass authentication entirely before authorization is even reached.
Common Pitfalls When Fixing IDOR
Using UUIDs as a security control. Switching from sequential integers to UUIDs makes IDs harder to guess, but it does not fix an authorization gap. If an attacker can obtain a UUID through a different endpoint, log leak, or brute-force, they can still exploit the IDOR. UUIDs reduce the attack surface for enumeration; they don't replace ownership checks.
Checking ownership after fetching. Fetching a record and then checking if invoice.owner != request.user: raise PermissionDenied is functionally correct, but it requires an extra round-trip and, more importantly, it's easy to forget the check in a new code path. Scope the query at the source.
Trusting frontend-enforced access control. Hiding a link in the UI is not access control. Assume every API endpoint is reachable directly and enforce authorization server-side unconditionally. This is the same principle that applies to raw queries sneaking back into ORM-heavy codebases β the database doesn't know the UI never exposed a certain operation.
Forgetting write and delete paths. Teams sometimes add ownership checks to GET endpoints but forget PUT, PATCH, and DELETE. Run the two-account test against every HTTP method the endpoint supports.
Admin-only bypass without role verification. Adding an admin override (if request.user.is_staff: return Invoice.objects.get(pk=invoice_id)) is reasonable, but make sure the role check is explicit and tested. A developer accidentally marking an account as staff in a staging environment and shipping that data to production is a real incident pattern.
Wrapping Up: Next Steps
IDOR is one of those vulnerabilities where the gap between "this looks fine" and "this exposes every user's data" is a single missing filter condition. Here is what to do now:
- Audit your existing endpoints. For every route that accepts a resource identifier, verify the query is scoped to the authenticated user. Pay extra attention to POST body parameters and file download routes, not just URL path parameters.
- Add cross-user tests to your test suite. Write at least one negative test per resource type that proves User B cannot access User A's records. Run it in CI so regressions fail the build.
- Centralize ownership filtering. Introduce scoped querysets or repository methods that encode ownership as a default, so the safe path is also the easiest path for your team.
- Review bulk and batch endpoints separately. These are the most commonly missed IDOR surface area because developers think of them as operating on "the caller's data" without verifying each item in the list.
- Include IDOR in your threat model for new features. When a new endpoint is proposed, explicitly ask: "What happens if the caller supplies an ID that doesn't belong to them?" Making this a standard design question is cheaper than finding the answer in a bug bounty report.
Frequently Asked Questions
What is the difference between IDOR and broken access control?
Broken access control is the broader category covering any failure to enforce what authenticated users are allowed to do. IDOR is a specific type of broken access control where a user-supplied identifier is used to fetch a resource without verifying the caller owns or is permitted to access it.
Does using UUIDs instead of sequential IDs prevent IDOR vulnerabilities?
No. UUIDs make IDs harder to guess by eliminating sequential enumeration, but they don't replace server-side ownership checks. If an attacker obtains a UUID through any other means, such as a leaked log or another endpoint, the vulnerability still exists without a proper authorization check.
How do I test an API for IDOR vulnerabilities without specialized tools?
Create two separate test accounts, have one account create a resource and record its ID, then authenticate as the second account and attempt to access that same resource directly by ID. If the second account receives the data, the endpoint is vulnerable. This two-account approach requires no special tooling and covers the most common IDOR patterns.
Should IDOR return a 403 Forbidden or 404 Not Found when access is denied?
Returning 404 Not Found is generally preferred because it avoids confirming that the resource exists at all. A 403 tells an attacker the ID is valid but access is blocked, which can assist enumeration. Returning 404 for all unauthorized access leaks the least information.
Can IDOR vulnerabilities exist in GraphQL APIs, not just REST?
Yes. In GraphQL, any query or mutation that accepts an object identifier β such as a node ID or a database primary key β can be vulnerable if the resolver doesn't verify the caller is authorized to access that specific object. The same ownership-scoped query pattern applies.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!