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:2025 โ Broken Access Control | Reading time: ~17 min
๐งช Run it yourself. This attack ships as a runnable lab in django-security-lab: a
@login_requirednote view that looks an object up by primary key with no ownership scope, and a secure twin that scopes the lookup torequest.user. Everything runs from the command line โ log in asalice, read bob's note by its id on/idor/vulnerable/<pk>/and watch the flag leak, then get an indistinguishable404on/idor/secure/<pk>/. Clone it and reproduce every step.
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, 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.
The scenario I kept coming back to while learning this class is a civic-petition platform โ petitions belong to their creators, signatures to the people who signed them. A logged-in citizen opens their own petition at /petition/41/, changes the 41 to 42, and the view โ guarded by nothing more than @login_required โ hands them someone else's. No exploit, no payload, one changed digit. Make that leaked record a signature instead โ a full name, a CPF (Brazil's individual taxpayer ID), an email โ and the stakes change entirely: disclosing another person's data without authorization is not just a logic bug but a legal one, a security incident that Brazil's LGPD โ its GDPR-equivalent data-protection law โ makes reportable when it may cause relevant risk or harm (the Real-World Incidents below show what that costs in practice). That is the gap this post is about โ the distance between authenticated and authorized is one forgotten queryset filter wide, and on the wrong table it carries legal weight.
This post covers how IDOR works, why Django's authentication does not cover it, and how to close it by scoping every lookup to the authenticated user, in function views and in Django REST Framework (DRF).
The Attack: What It Is and How It Works
A Django view receives a request for /invoices/42/. It calls get_object_or_404(Invoice, pk=42) and returns the result. The view is decorated with @login_required, so anonymous visitors get redirected to the login page. Looks secure. Except User A can change the URL from /invoices/41/ to /invoices/42/ and read User B's invoice without any resistance at all. The ORM returned the row because pk=42 exists. The decorator confirmed that someone is logged in. Nobody asked whether that someone owns invoice 42. The primary key is a direct reference: it maps one-to-one to a database row, and the user controls it through the URL.
That missing question is the gap between authentication (who is making this request?) and authorization (is that identity allowed to perform this action on this resource?). Broken Access Control, OWASP's #1 risk category, is any failure in the second step, and the form of it this post covers is IDOR (Insecure Direct Object Reference): a direct reference to an internal object, usually a database primary key, is exposed in the URL, and the application never verifies that the authenticated user has any relationship to the object it points to. The developer confused "authenticated" (you have a valid session) with "authorised" (you may access this specific object). That confusion is the entire vulnerability, and it is invisible at every stage of normal development: the code reads cleanly, the tests pass because the developer who wrote the view tested it as one user, review sees @login_required and moves on, and demos never involve a second user trying the first user's IDs.
The most common form is horizontal IDOR: User A reads User B's invoices, medical records or messages, at the same privilege level. The attacker is a normal, authenticated user who logged in legitimately, has a valid session, and simply changes the ID in the URL, the query parameter, or the request body. No exploit toolkit, no injection payload, no bypass. Just a different number.
The attack requires nothing more than a browser and basic curiosity. An attacker who can see their own invoice at /invoices/41/ tries /invoices/40/, /invoices/42/, and so on; if the view returns data for IDs they do not own, the application is vulnerable โ and in practice the whole thing is automated in seconds. The attacker logs in legitimately, notes the URL of their own resource (say /api/documents/1053/), and points a short script โ or Burp Suite's Intruder โ at a range of IDs, sending an authenticated GET for each with their session cookie attached automatically. A 200 proves nothing by itself, since the attacker's own records return one too, and on a vulnerable view a 404 may only mean that no record has that ID: the leak is a 200 carrying a record another account owns, and the protection is a 404 or 403 on an ID known to belong to one. In a vulnerable application every ID that exists returns 200, whoever owns it.
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 same missing scope leaks in other ways. A list endpoint that returns every row hands over the whole table in one request instead of one ID at a time. An edit view that looks the object up without a scope lets the attacker overwrite another user's record instead of reading it. And vertical IDOR reaches a resource above the attacker's privilege level, such as a staff-only record, which is close to privilege escalation, the subject of the next post.
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 files dating back to 2003 through a straightforward IDOR vulnerability. The company sent customers links to their documents, and each link carried a nine-digit document number that appeared to be issued sequentially. 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 and statements, mortgage and tax records, and wire transfer receipts โ everything needed for identity theft on an industrial scale. The company's own information-security staff had identified the vulnerability several months earlier and had not remediated it. The U.S. Securities and Exchange Commission charged First American with disclosure-controls failures in 2021 (a US$487,616 penalty), because its public statements about the incident were made without its senior executives knowing the vulnerability had already been flagged internally, and in 2023 the New York Department of Financial Services settled its cybersecurity charges against the title-insurance subsidiary for a US$1 million penalty.
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; the Django equivalent of the fix is owner=request.user on a lookup behind @login_required. In ATT&CK terms, exploiting the flaw would map to MITRE ATT&CK T1190 (Exploit Public-Facing Application) and harvesting the documents to T1213 (Data from Information Repositories): an attacker would only have had to read what the application served to anyone who asked. The reports establish the exposure, not that anyone harvested it.
Sources: KrebsOnSecurity โ First American Financial Corp. Leaked Hundreds of Millions of Title Insurance Records; SEC โ SEC Charges Issuer With Cybersecurity Disclosure Controls Failures; NYDFS โ $1 million settlement with First American Title Insurance Company
Django's Default Protections
Django's authentication framework is excellent, and that is 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, which gets its own post in this series, but is irrelevant to authorization.
Here is what Django does not give you:
- Object-level authorization. The ORM will 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 documentation says so: the permission framework "has a foundation for object permissions" โhas_perm()accepts an optionalobjargument โ but no implementation in core, so object permission checks "will always returnFalseor an empty list" until a custom authentication backend supplies the logic. The one exception is an active superuser, for whomhas_perm()always returnsTrue, object or not. That backend is what third-party packages likedjango-guardianprovide. - 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. - Object checks in DRF that run by themselves. DRF runs object-level permissions only when a view calls
self.get_object(), and only the ones you write:IsAuthenticated, the usualpermission_classes, is authentication again, and its object check allows everything. AModelViewSetwhose queryset isModel.objects.all()therefore servesGET /api/documents/42/to any logged-in user who asks for 42.
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.
The Vulnerable Pattern and the Fix
Pattern 1 โ A Detail View That Checks Authentication but Not Ownership
The lab's note view is the textbook case, and the fix is one keyword argument:
@login_required
def note_detail(request, pk):
- note = get_object_or_404(Note, pk=pk)
+ note = get_object_or_404(Note, pk=pk, owner=request.user)
return HttpResponse(
On the vulnerable view, alice requests /idor/vulnerable/<bob's pk>/ and gets Bob's note, flag and all. On the secure view, get_object_or_404 queries Note.objects.filter(pk=pk, owner=request.user): Bob's note is not in that result set, so Django raises a 404 โ the same response as a genuinely nonexistent id, which tells the attacker nothing about whether note 42 exists. Both views are in the lab: views_vulnerable.py and views_secure.py.
Pattern 2 โ A DRF ModelViewSet with an Unscoped Queryset
DRF's class-based design looks like it should handle permissions automatically. The developer sets permission_classes = [permissions.IsAuthenticated], perhaps adds a custom has_object_permission, and assumes the viewset is locked down. But queryset = Document.objects.all() hands DRF every row in the table, so GET /api/documents/42/ returns document 42 to any logged-in user who changes the ID. The fix scopes the queryset and keeps an object-level check behind it:
+class IsOwner(permissions.BasePermission):
+ def has_object_permission(self, request, view, obj):
+ return obj.owner == request.user
+
class DocumentViewSet(viewsets.ModelViewSet):
- queryset = Document.objects.all()
serializer_class = DocumentSerializer
- permission_classes = [permissions.IsAuthenticated]
+ permission_classes = [permissions.IsAuthenticated, IsOwner]
+
+ def get_queryset(self):
+ return Document.objects.filter(owner=self.request.user)
Now GET /api/documents/42/ returns a 404 if document 42 belongs to someone else, because it is not in the queryset at all. The scoped get_queryset() is the primary control; IsOwner is the backstop on the paths that call get_object(), in case a later refactor widens the queryset. One detail follows from removing the class-level queryset: the router can no longer derive a name from it, so register the viewset with an explicit basename.
Three rules apply to both patterns rather than one. First, keep the scope in the query in class-based views too: override get_queryset() with the ownership filter instead of loading the object inside UserPassesTestMixin.test_func(), because a test that loads the object first answers 403 for a record that exists and 404 for one that does not โ the existence leak the scoped 404 avoids. When ownership is more than one foreign key โ a document shared with a team, a record a role may read โ express it as a Q filter in that same get_queryset(). Second, return 404 rather than 403 for objects outside the user's scope: a 403 on an enumerable ID confirms the record exists, so a probe can map which records are real. The only price is a legitimate user who mistypes their own URL getting a bare 404, and that is a wording problem for the 404 page. Third, treat UUIDs as obscurity, not as access control: they stop casual enumeration, which is worth having, but they leak through URLs, Referer headers, email links, API responses and logs, and once one is known, an unscoped lookup serves it as readily as a sequential integer.
Often the ID need not be in the URL at all. A view that shows users their own profile already knows who they are, so get_object_or_404(Profile, user=request.user) behind a URL with no pk leaves nothing to enumerate.
The Analyst's View
IDOR is the cleanest example in this series of the distinction the CySA+ draws between authentication and authorization โ and of why access control is a preventive control you design in rather than bolt on. Django hands you authentication almost for free; authorization is yours to enforce on every object access, and the enforcement belongs in the query (owner=request.user), not in a downstream if a later refactor can quietly drop. Scoping the queryset is the preventive control; returning an indistinguishable 404 is a small extra layer on top (it denies the attacker even the confirmation that an object exists); and a DRF has_object_permission() check is a second, defence-in-depth gate on the paths that do call get_object().
What makes IDOR a distinctly analyst problem is what happens after you find one in your own application: there is no signature to match and no vendor patch to apply. The finding is "this endpoint returns objects it shouldn't," and confirming it means reasoning about who should reach what, then testing the boundary with a second account. That is why the scanners in the next section can point at the suspicious lookup but cannot return the verdict โ the ownership model lives in your head and your data, not in the code's syntax. What you can automate is "an object lookup with no owner scope"; the judgement, "and that object is somebody else's," stays human.
Catching It Automatically
Testing Your Defence
The proof needs two users โ one who owns the object and one who doesn't โ and asserts that the stranger gets a 404 without the owner's flag. This is the lab's test, trimmed to the parts that matter:
def setUp(self):
self.alice = User.objects.create_user("alice", password="labpass")
self.bob = User.objects.create_user("bob", password="labpass")
self.bob_note = Note.objects.create(
owner=self.bob, title="Bob's private note", body=f"secret: {FLAG}"
)
self.client.force_login(self.alice)
def test_exploit_is_blocked_on_secure_view(self):
resp = self.client.get(f"/idor/secure/{self.bob_note.pk}/")
self.assertEqual(resp.status_code, 404)
self.assertNotIn(FLAG, resp.content.decode())
Point it at /idor/vulnerable/ and it fails: Alice gets a 200 with Bob's flag in the body. A test written with one user cannot fail this way, which is why the bug survives single-user test suites. The lab's tests.py adds the other half: Alice still reads her own note on the secure view. For a DRF API, the same two users and the same assertion work on the detail route: authenticate as Alice, GET /api/documents/<Bob's id>/, and expect a 404.
Scanning It
Run the standard scanners this series has leaned on since Post 1 against the lab's two views and something instructive happens: neither one finds the bug. Like SSTI and XXE before it, IDOR is a class the off-the-shelf tools miss outright โ and here not because a rule is missing, but because there is no dangerous call to flag.
Bandit โ which walks the Python AST looking for risky constructs by name โ reports nothing on either view. Its checks target dangerous constructs (eval, subprocess โฆ shell=True, mark_safe, yaml.load, hardcoded passwords), and get_object_or_404(Note, pk=pk) is an ordinary call. The only thing Bandit flags in the whole module is a hardcoded password in the test file โ the fixture users' labpass โ which is noise unrelated to the class, and a useful reminder that a clean report on a vulnerable view is a miss, not a pass.
Semgrep's community packs (p/django, p/python, p/owasp-top-ten) report nothing either โ 156 rules (151 Python, 5 multilang), zero findings. Semgrep's own documentation explains why, and it doubles as the licence to write our own rule: IDOR "is closer to a logic flaw: it's the absence of an authorization check at the right point in the request flow," so Semgrep's advice is to "write custom rules for your application that describe the access control logic you're expecting." The vulnerable and the fixed view differ by a single application-specific predicate โ owner=request.user โ and no generic rule can know that a Note has an owner that should match the requester.
So the companion repo ships a custom Semgrep rule for this post, rules/idor.yaml. It is deliberately syntactic: it flags a get_object_or_404 / get_list_or_404 lookup that carries no owner= or user= keyword, so it fires on the vulnerable view and stays silent on the owner-scoped fix, the split the standard tools could not make. CI checks both outcomes and runs the rule against its fixture.
semgrep scan --config rules/idor.yaml labs/post_06_idor/views_vulnerable.py # 1 finding (line 23)
semgrep scan --config rules/idor.yaml labs/post_06_idor/views_secure.py # 0 findings
Being syntactic, it errs both ways, and each case below is a line in its fixture, checked with Semgrep 1.170.0. It flags lookups that are correctly scoped another way โ through a field it does not know (account=request.user), a related manager (get_object_or_404(request.user.notes, pk=pk)) or a pre-filtered queryset โ and lookups of objects nobody owns, such as a published blog post fetched by slug. It goes silent on any owner= or user= keyword whatever its value, so user=request.GET["uid"] passes, and it never sees the same bug written as Model.objects.get(pk=pk) or as an unscoped DRF queryset โ Pattern 2's queryset = Document.objects.all() passes it silently. Treat a finding as "check who should reach this object," not as a verdict.
There is deliberately no push-button DAST here. IDOR is a runtime class, so a black-box scanner can reach it โ but a crawler walking /idor/vulnerable/1/, /2/, /3/ sees three 200s and no error, with no way to know that note 2 belongs to Bob and should have been off-limits to Alice. OWASP ZAP's and Burp's access-control testing can assist โ drive two authenticated sessions and diff what each may reach โ but a human still supplies the ownership intent. The captured Bandit, Semgrep, and custom-rule runs are committed under scans/ so you can read what each reported; tests.py remains the runnable proof of the leak itself.
The habit this post left me with: every get_object_or_404 and every Model.objects.get(pk=...) on an object somebody owns has to answer one question โ is this queryset scoped to the requesting user? If it isn't, and no check after the lookup refuses the request, the view is an IDOR.
Post 7 takes the same failure vertical: Privilege Escalation, where the attacker doesn't 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-security-lab โ this post's runnable lab (
labs/post_06_idor/) - The custom Semgrep rule for this post โ
rules/idor.yaml(with its test fixture,rules/idor.py) - 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:2025 โ 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: Permissions
Next in this series โ Post 7: Privilege Escalation: How fields = '__all__' Hands an Attacker the Keys to Your Django Admin