Skip to content

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 a BasePermission subclass's has_object_permission() for update/delete. GenericAPIView.get_object() runs filter_queryset(self.get_queryset()) before its lookup, so a scoped queryset does cover detail actions and custom @action(detail=True) methods that call self.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 nor check_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 replaces DEFAULT_PERMISSION_CLASSES with an empty list, so the viewset is open to anonymous callers no matter how strict the global setting is. Measured on DRF 3.18 with DEFAULT_PERMISSION_CLASSES set to IsAdminUser, an anonymous GET on a viewset declaring permission_classes = [] returned 200 and the whole table, while the same request to a sibling viewset returned 403.

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 checking current_user manually 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. alice requesting her own order returns 200 with the order body. A control that denies everyone passes every rejection test below unchanged, so this assertion goes first.
  • The two denials are indistinguishable. alice requesting bob's order and alice requesting an ID that exists in no row must return the same status and the same body. With a scoped get_queryset() both are 404; a 403 for one and a 404 for the other enumerates the table regardless of which status is which.
  • The collection endpoint is scoped. GET /orders/ as alice returns exactly her orders. Assert the count and the IDs, not just 200 - a permission class that only implements has_object_permission() is never consulted for list, 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 as bob with alice's ID and assert 404. An action that runs Model.objects.get(pk=pk) itself returns 200 here while the standard retrieve route returns 404.
  • 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 403 from @permission_required(..., raise_exception=True) - not 200, and not a 302 to the login page, which is what an omitted raise_exception=True produces for an already-authenticated caller.
  • Client-supplied privilege fields are ignored. POST a body carrying role, is_staff or permissions set to an elevated value; assert the created row's field holds the server-side default and that User.objects.get(...).is_staff is False. Asserting only the response status misses a value that was written and not echoed.

Common Pitfalls

  • Treating @login_required/IsAuthenticated as 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 @action bypasses: a custom action that calls Model.objects.get(pk=...) directly instead of self.get_object() skips both the scoped queryset and check_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 to request.user's server-side permissions, not client input.

Additional Resources