CWE-285: Improper Authorization - Python
Overview
In Django, Django REST Framework (DRF), and Flask applications, improper authorization commonly happens when a view or endpoint checks that a user is logged in (@login_required, IsAuthenticated) but never checks whether that specific user has permission to perform the requested action or touch the requested object. Authentication and authorization are separate checks - being logged in proves identity, not permission. Python web frameworks provide dedicated tools for the latter: Django's @permission_required and PermissionRequiredMixin, DRF's permission_classes, and Flask extensions such as Flask-Login (authentication) paired with Flask-Principal or hand-written decorators (authorization).
Primary Defence: Apply the most specific permission check available at the view or viewset level - @permission_required('app.change_report') for Django function-based views, permission_classes = [IsAdminUser] or a custom BasePermission subclass for DRF viewsets, PermissionRequiredMixin for Django class-based views. For object-level authorization, scope querysets to the authenticated user (Model.objects.filter(user=request.user)) or override get_object()/get_queryset() rather than filtering results after they have already been fetched.
Common Vulnerable Patterns
View Missing a Permission Check
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
# VULNERABLE - login_required only proves the user is authenticated, not
# that they may view billing data
@login_required
def billing_report(request):
invoices = Invoice.objects.all()
return JsonResponse({'invoices': list(invoices.values())})
Why this is vulnerable: login_required confirms request.user.is_authenticated, nothing more. Any logged-in user - not just staff or billing administrators - can call this view.
Unscoped Queryset (IDOR)
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
class OrderViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
# VULNERABLE - every authenticated user can list, retrieve, and edit
# every order, not just their own
queryset = Order.objects.all()
serializer_class = OrderSerializer
Why this is vulnerable: IsAuthenticated proves the caller has a valid session or token. Order.objects.all() returns every order in the database regardless of who is asking, so any authenticated user can retrieve or update another user's order by ID.
Trusting a Client-Supplied Role
from django.views.decorators.http import require_POST
@require_POST
@login_required
def create_user(request):
# VULNERABLE - role comes from POST data, which the caller controls
role = request.POST.get('role', 'standard_user')
User.objects.create(username=request.POST['username'], role=role)
return JsonResponse({'status': 'created'})
Why this is vulnerable: Reading role from request.POST instead of deriving it from a permission check on the caller lets any authenticated user create an account with any role the form accepts, including one with more privilege than the requester has.
Function-Level Check Without Object-Level Check (Flask)
from flask_login import login_required, current_user
@app.route('/orders/<int:order_id>')
@login_required
def view_order(order_id):
# VULNERABLE - proves the caller is logged in, but never checks that
# this order belongs to them
order = Order.query.get_or_404(order_id)
return jsonify(order.to_dict())
Why this is vulnerable: @login_required only confirms current_user is authenticated. Any logged-in user can view any order by changing order_id in the URL, because the handler never compares order.user_id to current_user.id.
Secure Patterns
Django Permission-Required Decorator
from django.contrib.auth.decorators import permission_required
# SECURE - raises PermissionDenied (403) if the user lacks this specific
# Django permission, instead of only checking they are logged in
@permission_required('billing.view_invoice', raise_exception=True)
def billing_report(request):
invoices = Invoice.objects.all()
return JsonResponse({'invoices': list(invoices.values())})
Why this works: permission_required checks Django's built-in permission system (app_label.codename), which is tied to the user's assigned permissions or group membership rather than just their authentication state. raise_exception=True returns a 403 for a logged-in user who lacks the permission, instead of the default behavior of redirecting to the login page - a redirect is the wrong response for someone who is already authenticated but not authorized.
DRF Scoped Queryset and Custom Permission Class
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated, BasePermission
class IsOwnerOrAdmin(BasePermission):
def has_object_permission(self, request, view, obj):
return obj.user_id == request.user.id or request.user.is_staff
class OrderViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsOwnerOrAdmin]
serializer_class = OrderSerializer
def get_queryset(self):
# SECURE - object-level scoping: only this user's orders are ever
# visible to list/retrieve, staff users see everything
user = self.request.user
if user.is_staff:
return Order.objects.all()
return Order.objects.filter(user=user)
Why this works: get_queryset() scopes every list and detail lookup to the authenticated user before DRF even evaluates object-level permissions, so a non-staff user cannot retrieve another user's order regardless of the ID in the URL - it is not in the queryset DRF searches. IsOwnerOrAdmin.has_object_permission() adds a second, explicit check for actions (like update or delete) where DRF calls check_object_permissions() after fetching a specific instance, so even a coding mistake that widens the queryset later still has this second layer.
The scoping is doing more work here than the permission class. has_object_permission() is only consulted when a single instance is fetched, so it never runs for list - a viewset relying on it alone returns the whole table from GET /orders/, which is the endpoint that leaks the most and the one nobody tests. Scoping also collapses two answers into one: measured on Django 6.1 with DRF 3.18, alice requesting another user's order and alice requesting an ID that does not exist both returned 404, and has_object_permission() was never reached in either case. Returning 403 for a row that exists but is not yours confirms the ID is real and turns the detail route into an enumeration oracle one request at a time.
Django Class-Based View with PermissionRequiredMixin
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.views.generic import DeleteView
# SECURE - PermissionRequiredMixin checks the permission before dispatch()
# runs the view logic
class InvoiceDeleteView(PermissionRequiredMixin, DeleteView):
model = Invoice
permission_required = 'billing.delete_invoice'
raise_exception = True
def get_queryset(self):
# Object-level: staff can delete any invoice, others only their own
qs = super().get_queryset()
if self.request.user.is_staff:
return qs
return qs.filter(owner=self.request.user)
Why this works: PermissionRequiredMixin runs the permission check in dispatch(), before any view logic executes, so the check cannot be accidentally bypassed by a subclass overriding a later method. Combining it with a scoped get_queryset() covers both the function-level question (does this user have the delete_invoice permission at all) and the object-level question (is this the invoice they are allowed to delete).
Deriving Role From the Server, Not the Request
@require_POST
@permission_required('accounts.add_user', raise_exception=True)
def create_user(request):
# SECURE - role is a fixed default; request.POST['role'] is never used
User.objects.create(username=request.POST['username'], role='standard_user')
return JsonResponse({'status': 'created'})
Why this works: permission_required('accounts.add_user') ensures only callers with that permission reach the view, and the new user's role is set from a fixed value in server code rather than trusted from POST data - closing the privilege-escalation path even if an attacker adds a role field to the request.
Flask Ownership in the Query
import sqlalchemy as sa
from flask_login import login_required, current_user
@app.route('/orders/<int:order_id>')
@login_required
def view_order(order_id):
# SECURE - ownership is part of what is asked, not a check applied to
# the answer, so "not yours" and "does not exist" are the same miss
stmt = sa.select(Order).where(Order.id == order_id)
if not current_user.is_admin:
stmt = stmt.where(Order.user_id == current_user.id)
order = db.first_or_404(stmt)
return jsonify(order.to_dict())
@app.route('/orders')
@login_required
def list_orders():
# SECURE - the same predicate scopes the collection endpoint
stmt = sa.select(Order)
if not current_user.is_admin:
stmt = stmt.where(Order.user_id == current_user.id)
return jsonify([o.to_dict() for o in db.session.scalars(stmt)])
Why this works: The ownership predicate is part of the SELECT, so the row is never loaded for a caller who may not have it. That is the same property DRF's scoped get_queryset() gives a viewset, written by hand for a framework that has no equivalent hook - and it survives being reused, because a new route that copies the statement copies the constraint with it. db.first_or_404() is Flask-SQLAlchemy 3.x's statement-based replacement for Model.query.get_or_404(); it aborts with a 404 when the statement matches nothing, which after scoping means both "no such order" and "not yours".
It also removes an existence oracle rather than documenting one. The obvious alternative - db.get_or_404(Order, order_id) followed by abort(403) when order.user_id != current_user.id - answers 404 for an ID that does not exist and 403 for one that belongs to someone else, so walking order_id maps which orders are real. Measured on Flask 3.1.3 with Flask-SQLAlchemy 3.1.1: as alice, the load-then-check route returned 403 for bob's order and 404 for order 999, while the version above returned 404 for both. The list endpoint matters as much as the detail one and is the half that gets left out - a has_object_permission-style hook never fires for it, so an unscoped select(Order) hands back every row with no check to skip.
Framework-Specific Guidance
Django and Django REST Framework
- Use Django's built-in permission and group system (
Permission,Group,user.has_perm()) rather than hand-rolled role checks stored as free-text fields - it integrates with@permission_required,PermissionRequiredMixin, and the admin site. - For DRF, prefer overriding
get_queryset()for scoping and aBasePermissionsubclass'shas_object_permission()for update/delete.GenericAPIView.get_object()runsfilter_queryset(self.get_queryset())before its lookup, so a scoped queryset does cover detail actions and custom@action(detail=True)methods that callself.get_object(). What escapes it is a query the view issues itself -Model.objects.get(pk=...)inside a custom action - which reaches neither the queryset norcheck_object_permissions(). - Avoid
permission_classes = [AllowAny]on a viewset except for genuinely public data; it is easy to leave in place after a viewset that started public later grows privileged actions.permission_classes = []is the quieter version of the same mistake: it reads as "no extra permissions beyond the default" and actually replacesDEFAULT_PERMISSION_CLASSESwith an empty list, so the viewset is open to anonymous callers no matter how strict the global setting is. Measured on DRF 3.18 withDEFAULT_PERMISSION_CLASSESset toIsAdminUser, an anonymousGETon a viewset declaringpermission_classes = []returned200and the whole table, while the same request to a sibling viewset returned403.
Flask
- Flask has no built-in authorization framework; combine an authentication extension (Flask-Login, Flask-JWT-Extended) with either Flask-Principal or a small custom decorator that reads role/permission from the authenticated user object, not from request data.
- Apply the authorization decorator directly on each route (
@login_required, then a role/permission decorator) rather than checkingcurrent_usermanually inside the view body, so the check cannot be skipped by a route that forgets to call it.
Testing
A re-scan sees the permission class or decorator and stops there. It cannot tell a scoped queryset from one that returns nothing, and it cannot see which of two denials a caller received. Assert these with APIClient or Django's test client and two user fixtures, alice and bob:
- The owner still gets their own record.
alicerequesting her own order returns200with the order body. A control that denies everyone passes every rejection test below unchanged, so this assertion goes first. - The two denials are indistinguishable.
alicerequestingbob's order andalicerequesting an ID that exists in no row must return the same status and the same body. With a scopedget_queryset()both are404; a403for one and a404for the other enumerates the table regardless of which status is which. - The collection endpoint is scoped.
GET /orders/asalicereturns exactly her orders. Assert the count and the IDs, not just200- a permission class that only implementshas_object_permission()is never consulted forlist, so this endpoint passes an object-level review while returning every row. - Every custom action goes through
self.get_object(). Call each@action(detail=True)on the viewset asbobwithalice's ID and assert404. An action that runsModel.objects.get(pk=pk)itself returns200here while the standard retrieve route returns404. - A permission gap is a denial, not a pass. A user with no Django permission assigned, and a user whose role string does not match any branch of the check, both receive
403from@permission_required(..., raise_exception=True)- not200, and not a302to the login page, which is what an omittedraise_exception=Trueproduces for an already-authenticated caller. - Client-supplied privilege fields are ignored.
POSTa body carryingrole,is_stafforpermissionsset to an elevated value; assert the created row's field holds the server-side default and thatUser.objects.get(...).is_staffisFalse. Asserting only the response status misses a value that was written and not echoed.
Common Pitfalls
- Treating
@login_required/IsAuthenticatedas sufficient: both prove the caller is logged in, not that they have the specific permission the view requires - a view guarded only by authentication is reachable by any account on the system. - Filtering results after fetching instead of scoping the queryset: looping over
Order.objects.all()and discarding rows that do not belong to the user still executes a query that reads every user's data, and is easy to get wrong (e.g., forgetting the filter in one branch); scope the queryset itself with.filter(user=request.user). get_queryset()scoping that a custom DRF@actionbypasses: a custom action that callsModel.objects.get(pk=...)directly instead ofself.get_object()skips both the scoped queryset andcheck_object_permissions()- always route through DRF's object-lookup methods for actions that need enforcement.- Trusting role or permission fields in request data: any value read from
request.POST,request.data, or query parameters must be treated as attacker-controlled; the authorization decision belongs torequest.user's server-side permissions, not client input.