Safeguard
Application Security

Finding and fixing IDOR vulnerabilities in Python

Broken Object Level Authorization has held the #1 spot on the OWASP API Security Top 10 since 2019 — and Django's get_object_or_404() does nothing to stop it.

Safeguard Research Team
Research
6 min read

Insecure Direct Object Reference — reclassified by OWASP as Broken Object Level Authorization, or BOLA, and tracked as API1 — has held the #1 spot on every edition of the OWASP API Security Top 10 since the list debuted in 2019, and it kept that rank in the 2023 edition too. The bug is almost embarrassingly simple to describe: an endpoint takes an identifier like /api/orders/482, fetches the record with that ID, and returns it without checking whether the requesting user actually owns order 482. Change the number, get someone else's invoice, medical record, or private message. Vendor telemetry from Salt Security, recirculated widely by Imperva and others, has put BOLA/IDOR-style exploitation at around 40% of observed malicious API traffic — a vendor figure rather than an OWASP-audited one, but directionally consistent with how often the flaw shows up in bug bounty reports. What makes IDOR persistent in Python specifically is that neither of the two dominant frameworks, Django and Flask, block it by default: both will happily fetch and return any row you ask for unless you write the ownership check yourself. This post walks through where that check goes missing, how to find it in review, and the exact code patterns that close it in Django, Django REST Framework, and Flask/SQLAlchemy apps.

Why doesn't Django's get_object_or_404() prevent IDOR?

get_object_or_404() is a lookup helper, not an authorization control — it takes a model (or queryset) and a filter, returns the matching row, and raises Http404 if nothing matches. Called as get_object_or_404(Invoice, pk=pk), it will return any invoice in the entire table as long as the primary key exists, regardless of who is asking. The Django REST Framework documentation is explicit that object-level permission checks are a separate step: generic views call self.check_object_permissions(self.request, obj) after retrieval, and that call only does anything if you've attached an object-level permission class — DRF's default IsAuthenticated only checks that a user is logged in, not that they own the specific object. The framework-correct fix, per DRF's own generic-views and filtering docs, is to scope the queryset itself before the lookup ever runs: get_object_or_404(Invoice.objects.filter(owner=request.user), pk=pk). Filtering at the queryset level means an ID belonging to another user simply doesn't exist in the search space, so the 404 is truthful rather than a permission check bolted on after the fact.

What does a safe DRF view actually look like?

In Django REST Framework, the idiomatic place to enforce ownership is overriding get_queryset() on your viewset or generic view, because every generic action — list, retrieve, update, destroy — is built on top of it. A viewset that defines queryset = Invoice.objects.all() as a class attribute is vulnerable by construction: retrieve() calls get_object(), which calls get_queryset(), and if that queryset was never narrowed, any authenticated user can pull any row by guessing sequential IDs. The fix is to replace the class attribute with a method: def get_queryset(self): return Invoice.objects.filter(owner=self.request.user). DRF's docs also recommend pairing this with a custom permissions.BasePermission subclass implementing has_object_permission(self, request, view, obj) for cases with more nuanced rules — for example, letting an org admin see any invoice inside their own org, not just ones they personally created. Relying on get_queryset() alone is the simpler, more foolproof default because it removes the vulnerable row from the dataset entirely, rather than trusting every code path to remember to call a permission check.

How does the same bug show up in Flask?

Flask has no ORM-level authorization primitive comparable to a scoped Django queryset, so IDOR protection is entirely the developer's responsibility on every single route. A typical vulnerable pattern looks like @app.route("/documents/<int:doc_id>") def get_document(doc_id): doc = Document.query.get_or_404(doc_id); return jsonify(doc.to_dict())get_or_404() behaves exactly like Django's helper, checking existence only. The fix is a manual ownership filter on the SQLAlchemy query itself: doc = Document.query.filter_by(id=doc_id, user_id=current_user.id).first_or_404(). Because Flask-SQLAlchemy has no concept of a "current user's queryset" the way DRF does, teams that don't centralize this pattern end up rewriting the same filter_by(user_id=...) clause across dozens of routes, and it's exactly the kind of one-line omission that's easy to miss on the twentieth endpoint after getting the first nineteen right. Wrapping the check in a shared decorator or a repository-layer helper function that every route calls — rather than inlining the filter per view — is the practical way large Flask codebases keep this consistent.

Where should engineers look for IDOR during code review?

Grep for every place a URL path parameter, query string value, or request body field is used directly in a database lookup, then check whether that lookup includes any clause tying the object back to the authenticated identity. In Django, that means searching for get_object_or_404(, .objects.get(, and .objects.filter( calls that take a pk or id kwarg sourced from request data without a corresponding owner=, user=, or created_by= filter alongside it. In DRF, search every ViewSet and GenericAPIView subclass for a queryset = Model.objects.all() class attribute — that pattern is the single most common source of BOLA in Django REST Framework apps because it's the framework's own quickstart-tutorial default. In Flask, search for .get(, .get_or_404(, and .first() calls following a .query on any SQLAlchemy model. None of these searches require a specialized tool; they're findable with a codebase-wide regex pass, which is precisely why IDOR is a review-checklist item rather than something that needs specialized tooling to catch.

Can static analysis or dependency scanning catch this automatically?

Not reliably, and that's an important limitation to be honest about. IDOR is a business-logic and access-control flaw, not a pattern-matchable code construct or a known-vulnerable dependency — a SAST tool can flag that a database call exists, but it has no way to know that "invoice 482" is supposed to belong exclusively to the user who created it, because that's a fact about your data model, not your syntax. Software composition analysis and SBOM-based tools, including supply-chain platforms like Safeguard, are built to catch a different category entirely: known CVEs in the packages your project depends on, and the transitive dependency risk that comes with pulling in Django, Flask, or their extensions. That coverage is genuinely valuable — a project can be fully patched against every disclosed CVE in its dependency tree and still ship an IDOR in code its own engineers wrote yesterday. Closing that gap is what manual code review, DRF/Flask-aware linting rules for missing ownership filters, and authenticated penetration testing are for; there's no substitute for a human or a rule engine that understands what "ownership" means in your specific schema.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.