Broken Access Control and IDOR: When Logging In Is Not the Same as Being Allowed
Django Security Series — Post 6 | Series II: Broken Access Control
OWASP A01:2021 — Broken Access Control | Reading time: ~15 min
Series I was about injection — data crossing into code at an interpreter boundary. Across five posts the interpreter changed (the SQL engine, the browser, the template engine, the OS shell, the XML parser) but the root cause never did: untrusted input was parsed as syntax instead of treated as a value. Series II opens a completely different failure mode. The code is syntactically correct, the queries are parameterised, the templates are escaped, and the shell is never invoked — but the application still hands one user another user's data, because it checked who you are without ever checking what you are allowed to touch.
This is Broken Access Control, OWASP's #1 risk category, and the concrete form it takes most often in a Django application is IDOR — Insecure Direct Object Reference. The pattern is disarmingly simple. A view accepts an object identifier in the URL — usually the database primary key — looks the object up, and returns it. The view is behind @login_required, so unauthenticated visitors are redirected to the login page. But once you are logged in, any logged-in user can request any object, because the lookup never asks whether the requesting user owns it or has any relationship to it. Change /invoice/41/ to /invoice/42/ and you are reading someone else's invoice. The ORM does exactly what you told it to — get_object_or_404(Invoice, pk=42) — and what you told it has no concept of ownership.
The reason this is the #1 web vulnerability and not the #10 is that the gap is invisible at every stage of normal development. The code reads cleanly, the tests pass (they were written by the same developer who wrote the view, using the same user), the code review sees a @login_required decorator and moves on, and the feature works perfectly in every demo — because demos do not involve a second user trying the first user's IDs. The failure is not a missing library or a misconfigured setting; it is a missing concept — the difference between authentication ("who are you?") and authorization ("are you allowed to do this to this specific object?"). Django gives you authentication almost for free and leaves authorization almost entirely to you, and the gap between the two is where IDOR lives.
In this post we look at how IDOR works at the application layer, why Django's authentication system does not cover it, the specific DRF and class-based-view patterns that make it easy to ship, and how to close the gap by scoping every queryset to the authenticated user — plus the DRF has_object_permission trap that silently skips the check you thought you had.
The Attack: What It Is and How It Works
Access control is a two-step process: authentication establishes identity (who is making this request?), and authorization establishes permission (is that identity allowed to perform this action on this resource?). Broken access control is any failure in the second step. The most common concrete form is IDOR — Insecure Direct Object Reference — where a direct reference to an internal object (a database primary key, a filename, a sequential order number) is exposed to the user, and the application never verifies that the authenticated user is entitled to the object that reference points to.
The mechanics are straightforward. A Django view receives a request for /invoice/42/. It calls Invoice.objects.get(pk=42) — or, equivalently, get_object_or_404(Invoice, pk=42) — and returns the result. The primary key 42 is a direct reference: it maps one-to-one to a database row, and the user controls it via the URL. The view is decorated with @login_required, so an anonymous visitor is redirected to the login page. But once logged in, any authenticated user can supply any integer, and the ORM will return whatever row matches — regardless of who created it, who owns it, or whether the requesting user has any business seeing it. The developer confused authenticated (you have a valid session) with authorised (you may access this specific object), and the gap is the whole vulnerability.
IDOR comes in two flavours, and the distinction matters because the impact differs:
| Type | What the attacker does | Example |
|---|---|---|
| Horizontal | Accesses another user's resources at the same privilege level | User A reads User B's invoices, medical records, messages |
| Vertical | Accesses resources of a higher-privileged role | A regular user accesses an admin dashboard endpoint or modifies a staff-only record |
Horizontal IDOR is by far the more common variant in Django applications. The attacker is a normal, authenticated user — they logged in legitimately, they have a valid session, and they simply change the ID in the URL, the query parameter, or the request body. No exploit toolkit, no injection payload, no bypass — just a different number.
How Attackers Exploit It
The attack requires nothing more than a browser and basic curiosity. An attacker who can see their own invoice at /invoice/41/ tries /invoice/40/, /invoice/42/, and so on. If the view returns data for IDs they do not own, the application is vulnerable. In practice, the process is automated in seconds:
- The attacker logs in legitimately and notes the URL of their own resource — for example,
/api/documents/1053/. - They write a simple script (or use Burp Suite's Intruder) to iterate over a range of IDs:
1to10000. - For each ID, they send an authenticated
GETrequest (their session cookie is attached automatically). - Any response that returns
200 OKwith a response body is another user's data. A404or403means the view is protected — but in a vulnerable application, every ID returns200.
The attack scales trivially because primary keys are sequential integers by default. The attacker does not need to guess — they just count. Even when an application uses UUIDs, a single leaked UUID (in an email link, a shared URL, a referrer header, or an API response that lists resources without scoping) gives the attacker a valid reference to try, and the absence of an ownership check means it works.
The relevant MITRE ATT&CK mapping is T1190 — Exploit Public-Facing Application for the initial exploitation of the web application, combined with the broader access-control failure that the OWASP Top 10 captures as A01. Where the IDOR is used to exfiltrate data at scale (as in the incident below), T1530 — Data from Cloud Storage or T1213 — Data from Information Repositories may also apply, depending on the storage backend.
Real-World Incidents
First American Financial Corporation — IDOR Data Exposure (2019)
In May 2019 the security journalist Brian Krebs reported that First American Financial Corporation, one of the largest title insurance companies in the United States, had exposed approximately 885 million records dating back to 2003 through a straightforward IDOR vulnerability. The company's website allowed title agents to share document images via a direct URL — and that URL contained a sequential, predictable document number. Changing the number returned a different customer's document. No authentication was required at all: anyone with the URL pattern could retrieve any document by iterating the ID. The exposed records included Social Security numbers, driver's licence images, bank account numbers, tax records, and wire transfer receipts — everything needed for identity theft on an industrial scale. The vulnerability had been identified in an internal security review months earlier and never remediated. The New York Department of Financial Services fined the company, and the U.S. Securities and Exchange Commission charged First American with disclosure-controls failures, because it issued public statements about the incident without its senior executives knowing the vulnerability had already been flagged internally.
The lesson for Django developers is that IDOR is not an exotic attack — it is the most mundane vulnerability there is. There was no SQL injection, no zero-day, no sophisticated exploit chain. The entire breach was one missing authorization check on a view that served documents by sequential ID. A single line — the equivalent of adding owner=request.user to a queryset lookup — would have prevented the exposure of 885 million records. The initial access maps to MITRE ATT&CK T1190 (Exploit Public-Facing Application), and the data collection maps to T1213 (Data from Information Repositories) — the attacker simply read documents the application served to anyone who asked.
Django's Default Protections
Django's authentication framework is excellent — and that is precisely the problem, because its excellence creates a false sense of completeness. Here is what Django does give you:
@login_required/LoginRequiredMixin— redirects unauthenticated users to the login page. This is authentication: it establishes identity. It says nothing about what the authenticated user may access.request.user.is_authenticated— the boolean you check in a template or view. Same scope: identity, not permission.- The
authpermissions system (has_perm,PermissionRequiredMixin,@permission_required) — checks whether a user holds a model-level permission likeblog.change_post. This is coarse-grained authorization: "can this user change any post?" It does not answer "can this user change this specific post?" - Session-key rotation on login (
login()callscycle_key()) — prevents session fixation (Post 12 in this series) but is irrelevant to authorization.
Here is what Django does not give you:
- Object-level authorization. The ORM will happily return any row you ask for.
Invoice.objects.get(pk=42)returns invoice 42 regardless of who is asking. There is no built-in "only return objects this user owns" filter — the developer must add it to every queryset in every view. Django's own documentation is explicit about this: the auth framework provides the foundation for object permissions —has_perm()accepts an optionalobjargument — but ships no concrete implementation in core, and points to third-party backends likedjango-guardianfor apps that need it. - Automatic queryset scoping. Unlike some frameworks that apply tenant or user filters at the model-manager level, Django's default manager returns
all(). Every view starts from the full table unless the developer narrows it. - DRF's
has_object_permissionenforcement on list views. DRF's permission system has a subtle but critical design:has_object_permission()is only called whenself.get_object()is called — which happens on retrieve, update, and delete, but never on list. AModelViewSetwhoseget_queryset()returnsModel.objects.all()will list every row in the table to every authenticated user, even ifhas_object_permissionwould have denied access to each one individually. The permission you thought you had is silently skipped.
The gap is simple to state: Django handles authentication out of the box and leaves object-level authorization entirely to the developer. Every IDOR in a Django application lives in that gap.
Vulnerable Pattern: What NOT to Do
Pattern 1 — A detail view that checks authentication but not ownership
# INSECURE — any authenticated user can read any invoice by changing the PK
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, render
@login_required
def invoice_detail(request, pk):
invoice = get_object_or_404(Invoice, pk=pk) # no ownership check
return render(request, 'billing/invoice_detail.html', {'invoice': invoice})
What goes wrong: User A is logged in and views their invoice at /invoices/41/. They change the URL to /invoices/42/ and receive User B's invoice. The @login_required decorator confirmed that someone is logged in — it never asked whether that someone owns invoice 42. The ORM returned the row because pk=42 exists, and the view served it without question.
Pattern 2 — A DRF ModelViewSet with an unscoped queryset
# INSECURE — queryset returns every document in the database
from rest_framework import viewsets, permissions
from .models import Document
from .serializers import DocumentSerializer
class DocumentViewSet(viewsets.ModelViewSet):
queryset = Document.objects.all() # every row, every user
serializer_class = DocumentSerializer
permission_classes = [permissions.IsAuthenticated]
What goes wrong: The IsAuthenticated permission class ensures a valid session, but queryset = Document.objects.all() hands DRF every row in the Document table. The list endpoint (GET /api/documents/) returns all documents for all users. The detail endpoint (GET /api/documents/42/) returns any document by PK. The developer may have added a custom has_object_permission on the permission class, but DRF only calls it on get_object() — the list action bypasses it entirely.
Pattern 3 — An update view that mutates another user's object
# INSECURE — any authenticated user can edit any other user's profile
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect
from .forms import ProfileForm
from .models import Profile
@login_required
def edit_profile(request, pk):
profile = get_object_or_404(Profile, pk=pk) # no ownership check
if request.method == 'POST':
form = ProfileForm(request.POST, instance=profile)
if form.is_valid():
form.save()
return redirect('profile_detail', pk=pk)
else:
form = ProfileForm(instance=profile)
return render(request, 'accounts/edit_profile.html', {'form': form})
What goes wrong: This is the write-side of IDOR, and it is worse than the read-side. User A navigates to /profiles/42/edit/ and submits a POST — they are now editing User B's profile. The view looked up profile 42, bound the form to it, and saved the attacker's data over the legitimate user's record. A @login_required decorator sat at the gate and let it all through.
Secure Implementation: The Django Way
Rule 1 — Scope every queryset to the authenticated user
The primary fix is one concept applied everywhere: never look up an object from the full table — always filter by ownership first. When the queryset is scoped to the requesting user, an ID that belongs to another user simply does not exist in the result set, and the lookup returns a 404 — exactly the same response as a genuinely nonexistent ID. The attacker learns nothing, and the data stays private.
# SECURE — the queryset is scoped to the requesting user; other users' invoices don't exist
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, render
@login_required
def invoice_detail(request, pk):
invoice = get_object_or_404(Invoice, pk=pk, owner=request.user)
return render(request, 'billing/invoice_detail.html', {'invoice': invoice})
The fix is the addition of owner=request.user to the lookup. get_object_or_404 now queries Invoice.objects.filter(pk=pk, owner=request.user) — if the invoice belongs to a different user, the filter returns an empty queryset, and Django raises a 404. No information about whether invoice 42 exists is disclosed; the attacker sees the same response they would get for a nonexistent ID.
For write operations, the same principle applies — scope the lookup before binding the form:
# SECURE — edit_profile scoped to request.user
@login_required
def edit_profile(request, pk):
profile = get_object_or_404(Profile, pk=pk, user=request.user)
if request.method == 'POST':
form = ProfileForm(request.POST, instance=profile)
if form.is_valid():
form.save()
return redirect('profile_detail', pk=pk)
else:
form = ProfileForm(instance=profile)
return render(request, 'accounts/edit_profile.html', {'form': form})
In many cases you can go further and eliminate the PK from the URL entirely. A user editing their own profile does not need to supply an ID — the view already knows who they are:
# SECURE — no PK in the URL; the profile is resolved from the session
@login_required
def edit_my_profile(request):
profile = get_object_or_404(Profile, user=request.user)
# ... form handling unchanged ...
Rule 2 — In DRF, override get_queryset() and implement has_object_permission()
DRF's ModelViewSet is the most common IDOR vector in modern Django applications because its ergonomic defaults — a class-level queryset and a ModelSerializer — make it trivially easy to expose every row in a table. The fix has two parts, and both are required:
Part A — Override get_queryset() to scope the base queryset. This protects the list action (which never calls get_object() and therefore never triggers has_object_permission):
# SECURE — every query is scoped to the requesting user
from rest_framework import viewsets, permissions
from .models import Document
from .serializers import DocumentSerializer
class DocumentViewSet(viewsets.ModelViewSet):
serializer_class = DocumentSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Document.objects.filter(owner=self.request.user)
Now GET /api/documents/ returns only the requesting user's documents, and GET /api/documents/42/ returns a 404 if document 42 belongs to someone else — because it is not in the queryset at all.
Part B — Add has_object_permission() as a defence-in-depth layer. This catches any code path that calls get_object() (retrieve, update, partial_update, destroy) in case a future refactor changes the queryset:
# SECURE — object-level permission as a second gate
from rest_framework import permissions
class IsOwner(permissions.BasePermission):
"""Object-level permission: only the owner may access the object."""
def has_object_permission(self, request, view, obj):
return obj.owner == request.user
Wire it into the viewset:
class DocumentViewSet(viewsets.ModelViewSet):
serializer_class = DocumentSerializer
permission_classes = [permissions.IsAuthenticated, IsOwner]
def get_queryset(self):
return Document.objects.filter(owner=self.request.user)
The scoped get_queryset() is the primary control; IsOwner.has_object_permission() is the backstop. Together they close both the list and detail paths.
Rule 3 — Use UserPassesTestMixin for class-based views that need flexible checks
When the ownership model is more complex than a single owner FK — for example, a document shared with a team, or a record accessible by role — UserPassesTestMixin lets you express the check as a method:
# SECURE — class-based view with an explicit ownership test
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.views.generic import DetailView
from .models import MedicalRecord
class MedicalRecordDetailView(LoginRequiredMixin, UserPassesTestMixin, DetailView):
model = MedicalRecord
def test_func(self):
record = self.get_object()
return (
record.patient == self.request.user
or self.request.user.groups.filter(name='doctors').exists()
)
UserPassesTestMixin calls test_func() before the view runs. If it returns False, the user gets a 403 Forbidden. This is the cleanest way to express multi-condition authorization in Django's class-based view system without reaching for a third-party library.
Rule 4 — Treat UUIDs as obscurity, not as security
A common but misguided response to IDOR is to replace sequential integer PKs with UUIDs, on the theory that an attacker cannot guess a random 128-bit identifier. This is defence in depth at best and false security at worst:
- UUIDs leak. They appear in URLs, in
Refererheaders, in email links, in API responses that list related objects, in browser history, and in logs. Once one UUID is known, the lack of an ownership check means it works. - UUIDs do not compose with authorization. A UUID tells the server which object the client wants, not whether the client is allowed to have it. The authorization check is still missing.
- UUIDs remove enumerability, which slows a brute-force scan, but they do not prevent an attacker who obtains a single valid reference from exploiting it.
Use UUIDs for public-facing identifiers if you want to — they are a reasonable layer of obscurity that raises the bar for casual enumeration — but never treat them as a substitute for the ownership check. The queryset must still be scoped to the user.
# UUIDs are fine as a public identifier, but the ownership check is still required
import uuid
from django.db import models
class Document(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey('auth.User', on_delete=models.CASCADE)
# ...
# The view STILL scopes by owner — the UUID alone is not the access control
@login_required
def document_detail(request, pk):
doc = get_object_or_404(Document, pk=pk, owner=request.user)
return render(request, 'docs/detail.html', {'document': doc})
IDOR Prevention Checklist
| Control | What it covers |
|---|---|
Scope every queryset by request.user (or the user's org/team) |
The primary IDOR vector — an unscoped lookup returns any object by PK |
In DRF, override get_queryset() (not just queryset) |
The list-action bypass — has_object_permission is never called on list |
Add has_object_permission() as a defence-in-depth layer |
The detail/update/delete path — catches regressions if the queryset scope changes |
Return 404 (not 403) for objects outside the user's scope |
Information leakage — a 403 confirms the object exists; a 404 reveals nothing |
| Eliminate PKs from URLs where possible (resolve from the session) | Reduces the attack surface — no ID to enumerate if the view resolves from request.user |
| Use UUIDs as public identifiers (defence in depth, not primary control) | Casual enumeration — raises the bar for brute-force scanning, but does not replace the ownership check |
Testing Your Defence
Unit Tests
# tests/test_idor.py
from django.test import TestCase
from django.contrib.auth.models import User
from billing.models import Invoice
class IDORTests(TestCase):
def setUp(self):
self.user_a = User.objects.create_user('alice', password='testpass123')
self.user_b = User.objects.create_user('bob', password='testpass123')
self.invoice_a = Invoice.objects.create(
owner=self.user_a, amount=100, reference='INV-001'
)
self.invoice_b = Invoice.objects.create(
owner=self.user_b, amount=200, reference='INV-002'
)
def test_user_can_access_own_invoice(self):
"""An authenticated user can view their own invoice."""
self.client.login(username='alice', password='testpass123')
response = self.client.get(f'/invoices/{self.invoice_a.pk}/')
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'INV-001')
def test_user_cannot_access_other_users_invoice(self):
"""User A must NOT be able to view User B's invoice — the view
must return 404, not 200 with another user's data."""
self.client.login(username='alice', password='testpass123')
response = self.client.get(f'/invoices/{self.invoice_b.pk}/')
self.assertEqual(response.status_code, 404)
def test_unauthenticated_user_is_redirected(self):
"""An unauthenticated request must redirect to login, not serve data."""
response = self.client.get(f'/invoices/{self.invoice_a.pk}/')
self.assertEqual(response.status_code, 302)
self.assertIn('/login', response.url)
def test_user_cannot_update_other_users_invoice(self):
"""User A must NOT be able to modify User B's invoice."""
self.client.login(username='alice', password='testpass123')
response = self.client.post(
f'/invoices/{self.invoice_b.pk}/edit/',
{'amount': 0, 'reference': 'HACKED'},
)
self.assertEqual(response.status_code, 404)
self.invoice_b.refresh_from_db()
self.assertEqual(self.invoice_b.amount, 200) # unchanged
self.assertEqual(self.invoice_b.reference, 'INV-002') # unchanged
DRF API Tests
# tests/test_idor_api.py
from rest_framework.test import APITestCase
from django.contrib.auth.models import User
from documents.models import Document
class DocumentAPIIDORTests(APITestCase):
def setUp(self):
self.user_a = User.objects.create_user('alice', password='testpass123')
self.user_b = User.objects.create_user('bob', password='testpass123')
self.doc_a = Document.objects.create(owner=self.user_a, title='Alice doc')
self.doc_b = Document.objects.create(owner=self.user_b, title='Bob doc')
def test_list_returns_only_own_documents(self):
"""GET /api/documents/ must return only the requesting user's documents."""
self.client.force_authenticate(user=self.user_a)
response = self.client.get('/api/documents/')
titles = [d['title'] for d in response.data]
self.assertIn('Alice doc', titles)
self.assertNotIn('Bob doc', titles)
def test_retrieve_other_users_document_returns_404(self):
"""GET /api/documents/<bob's pk>/ must return 404, not 200."""
self.client.force_authenticate(user=self.user_a)
response = self.client.get(f'/api/documents/{self.doc_b.pk}/')
self.assertEqual(response.status_code, 404)
def test_delete_other_users_document_returns_404(self):
"""DELETE /api/documents/<bob's pk>/ must return 404 and leave the row intact."""
self.client.force_authenticate(user=self.user_a)
response = self.client.delete(f'/api/documents/{self.doc_b.pk}/')
self.assertEqual(response.status_code, 404)
self.assertTrue(Document.objects.filter(pk=self.doc_b.pk).exists())
Manual Verification
# Log in as user A, note an invoice ID that belongs to user A
curl -c cookies.txt -X POST https://staging.example.com/login/ \
-d "username=alice&password=testpass123&csrfmiddlewaretoken=..."
# Request user A's own invoice — expect 200
curl -b cookies.txt https://staging.example.com/invoices/41/
# Request user B's invoice — expect 404 (not 200 with user B's data)
curl -b cookies.txt https://staging.example.com/invoices/42/
Automated Scanning
OWASP ZAP and Burp Suite include active-scan rules that detect IDOR by replaying requests with a different authenticated session and comparing responses. In a CI pipeline, ZAP's activeScan policy with the "Access Control Testing" add-on can flag views that return 200 for cross-user object access. A simpler, project-specific check: write a management command or test that iterates every URL pattern accepting a <pk> or <int:pk> argument and asserts that a second user's request returns 404 or 403.
Post 6 opens Series II with the most common form of the most common web vulnerability: an object lookup that checks identity but not entitlement. The habit to carry forward is a one-line code-review reflex — every get_object_or_404, every Model.objects.get(pk=...), and every DRF get_queryset() must answer the question: is this queryset scoped to the requesting user? If it is not, the view is an IDOR. Treat an unscoped lookup on user-owned data as a merge-blocking finding, the same way Series I treated an unparameterised raw() query or a bare etree.fromstring.
Post 7 takes the same failure vertical: Privilege Escalation — where the attacker does not just read another user's data but promotes themselves to a higher role, flipping is_staff or is_superuser through a form or serializer that was never supposed to expose those fields.
Further Reading
- Django Docs — Permissions and Authorization
- Django Docs — Custom Permissions
- DRF Docs — Object-Level Permissions
- PortSwigger Web Security Academy — Insecure Direct Object References (IDOR)
- OWASP A01:2021 — Broken Access Control
- OWASP — IDOR Prevention Cheat Sheet
- OWASP — Authorization Cheat Sheet
- MITRE ATT&CK — T1190 Exploit Public-Facing Application
- Web Security for Developers: Real Threats, Practical Defense (Malcolm McDonald, No Starch Press) — Chapter 11: Access Control
Next in this series → Post 7: Privilege Escalation: How fields = '__all__' Hands an Attacker the Keys to Your Django Admin