Skip to content

CWE-862: Missing Authorization - Python

Overview

In Django and Django REST Framework, Missing Authorization typically appears as a view that requires login_required/IsAuthenticated but never checks permissions or object ownership, or as a new ViewSet/view added without the permission_classes used by comparable endpoints. Flask has no built-in authorization layer at all, so the same gap shows up as a route protected by @login_required (Flask-Login) with no follow-up permission or ownership check. The fix is a permission decorator or class that checks role/permission, plus an object-level check for anything operating on a specific record - DRF's has_object_permission() is the standard hook for that, but it covers the detail actions only, so a ViewSet also needs get_queryset() scoped to the caller or its list action still returns everything. Flask has no equivalent hook and needs a manual decorator.

Common Vulnerable Patterns

Django View With Login Only

# VULNERABLE - login_required confirms authentication, not authorization
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404
from django.http import JsonResponse

@login_required
def refund_order(request, order_id):
    order = get_object_or_404(Order, pk=order_id)
    order.refund()  # any authenticated user can call this
    return JsonResponse({"status": "refunded"})

# Attack: any authenticated user sends POST /orders/500/refund/
# Result: the refund executes with no role or permission check

Why this is vulnerable: login_required only confirms request.user.is_authenticated; nothing checks that this user holds the permission needed to refund an order.

DRF ViewSet Relying on Default Permission Classes

# VULNERABLE - IsAuthenticated confirms login, not object ownership
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated

class OrderViewSet(viewsets.ModelViewSet):
    queryset = Order.objects.all()
    serializer_class = OrderSerializer
    permission_classes = [IsAuthenticated]

# Attack: an authenticated user requests GET /orders/1/, /orders/2/, /orders/3/...
#         or skips the walk entirely and requests GET /orders/
# Result: every user's order is returned to every other authenticated user

Why this is vulnerable: IsAuthenticated.has_permission() only checks that the request is authenticated; it never implements has_object_permission(), so DRF's generic retrieve/update/destroy actions return or modify any order regardless of who owns it. The list action is worse: queryset = Order.objects.all() is what it serializes, so a single GET /orders/ returns every order in the table without any ID guessing at all.

Secure Patterns

Django Permission Decorator

# SECURE - permission_required checks a specific permission and fails closed
from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import get_object_or_404
from django.http import JsonResponse

@login_required
@permission_required("orders.change_order", raise_exception=True)
def refund_order(request, order_id):
    order = get_object_or_404(Order, pk=order_id)  # any order - the permission is the whole rule here
    order.refund()
    return JsonResponse({"status": "refunded"})

Why this works: permission_required("orders.change_order", raise_exception=True) checks the specific Django permission and raises PermissionDenied (a 403 response) when it's missing, instead of the decorator's default behavior of redirecting to a login page - which would incorrectly imply the problem was authentication, not authorization, for a user who is already logged in.

It answers one question - may this user refund orders at all - and that is the entire rule for a staff capability like refunding, where whoever holds the permission is meant to act on other people's orders. It is not an object check and does not scope what the view loads: get_object_or_404(Order, pk=order_id) returns any order, so every holder of orders.change_order can refund every order. The moment the action is meant to be limited to the caller's own records, this decorator is the wrong control on its own and the object-level patterns below are what the endpoint needs. Deciding which of the two an endpoint is comes before choosing between them.

DRF Scoped Queryset With an Object-Level Permission

# SECURE - get_queryset() authorizes the list, has_object_permission() the detail actions
from rest_framework.permissions import BasePermission, IsAuthenticated
from rest_framework import viewsets

class IsOrderOwner(BasePermission):
    def has_object_permission(self, request, view, obj):
        return request.user.is_staff or obj.owner_id == request.user.id

class OrderViewSet(viewsets.ModelViewSet):
    serializer_class = OrderSerializer
    permission_classes = [IsAuthenticated, IsOrderOwner]

    def get_queryset(self):
        if self.request.user.is_staff:
            return Order.objects.all()
        return Order.objects.filter(owner_id=self.request.user.id)

Why this works: The two halves cover different actions, and neither one covers both.

get_queryset() is the authorization control for list. DRF calls has_object_permission() from check_object_permissions() inside get_object(), and get_object() runs only for the detail actions - retrieve, update, partial_update, destroy. The list action serializes the queryset directly, so an IsOrderOwner sitting beside queryset = Order.objects.all() never runs for it and GET /orders/ still returns every order. Scoping the queryset is what puts only the caller's rows in the list.

has_object_permission() is the check on the detail actions, and it is what keeps them right if someone later widens get_queryset() - a reviewer who adds a select_related or relaxes the filter does not silently open retrieve as well.

Neither one covers a detail path that loads its own object. Because check_object_permissions() runs inside get_object() and nowhere else, an overridden retrieve() or a custom @action calling get_object_or_404(Order, pk=pk) directly runs neither half and returns any order to any caller. Use self.get_object() in those methods: it applies the scoped queryset and the object permission both, and refuses another user's ID with the same 404 as a missing one. Where the object genuinely has to be loaded another way, self.check_object_permissions(request, obj) closes the hole but answers 403, so scope that load by owner as well if the endpoint is not to become the oracle described below.

Scoping also decides how a detail request for someone else's order is refused: the row is filtered out before get_object() sees it, so DRF's lookup answers 404 - the same answer as an ID that does not exist. The version that looks more careful, an unscoped queryset with only IsOrderOwner, answers 403 for a real order and 404 for a missing one, and that pair is an existence oracle a caller can walk the ID space with.

Both halves have to agree on who staff are, which is the detail most often missed. get_queryset() widening for staff while has_object_permission() compares owner IDs alone produces a staff user who can list every order and open none of them - 403 on each one they click. Whatever the staff rule is, it belongs in both places, and the tests below are what catch it drifting apart.

Flask Manual Decorator With Ownership Check

# SECURE - a custom decorator checks both login and resource ownership
from functools import wraps
from flask import g
from flask_login import login_required, current_user

from .extensions import db   # the Flask-SQLAlchemy instance

def require_order_owner(f):
    @wraps(f)
    def wrapper(order_id, *args, **kwargs):
        stmt = db.select(Order).where(Order.id == order_id)
        if not current_user.has_role("admin"):
            stmt = stmt.where(Order.owner_id == current_user.id)
        g.order = db.first_or_404(stmt)   # missing and not-yours are the same answer
        return f(order_id, *args, **kwargs)
    return wrapper

@app.route("/orders/<int:order_id>/refund", methods=["POST"])
@login_required
@require_order_owner
def refund_order(order_id):
    g.order.refund()
    return {"status": "refunded"}

Why this works: require_order_owner decides ownership server-side before the view function runs, closing the gap that Flask's @login_required alone leaves open - Flask has no built-in permission or object-authorization layer, so this check has to be added explicitly rather than assumed to exist.

Ownership is a where clause rather than a comparison made afterwards, which is what keeps the two failure modes indistinguishable: an order that does not exist and an order belonging to somebody else both produce no row, and both answer 404. The version that reads as more careful - get_or_404(order_id) followed by abort(403) when the owner does not match - splits one decision across two response paths and turns the pair into an existence oracle, because 403 confirms the record exists and 404 confirms it does not. The admin branch widens the query instead of skipping the check, so an administrator still gets 404 for an ID that is not there.

Framework-Specific Guidance

django-guardian for Object-Level Permissions

For applications that need per-object permission assignment beyond simple ownership (shared documents, team resources with different access levels per user), django-guardian extends Django's permission framework to per-object grants (assign_perm("view_order", user, order)), checked with user.has_perm("view_order", order), rather than hand-rolling an ACL table.

Centralizing Flask Checks Without a Library

Flask ships no authorization layer, and the libraries that filled that gap have largely stopped moving - Flask-Principal, still the one most often suggested, has had no release since 2013. For an application with more than a couple of ad hoc decorators, put the rules in one module of decorators like require_order_owner above and import them, or attach a before_request to the blueprint so every route inside it is covered by default and a new route inherits the check rather than needing to remember it. Both are a few lines, both keep the rule readable next to the thing it protects, and neither adds a dependency that has to be vetted at upgrade time.

Testing

  • Normal: call the endpoint as a user who owns the resource or holds the required permission; confirm 200 and that the body is the expected resource. This is the assertion that catches a scoped queryset filtering on the wrong column, which refuses everyone while passing every test below.
  • Boundary: request a resource owned by another user, then request an ID that does not exist, and confirm the two responses are identical - same status and same body. Under the scoped-lookup patterns above both are 404. A 403 for one and a 404 for the other is an existence oracle whichever way round they are.
  • Malicious: call the endpoint directly with APIClient/Django's test client, bypassing any UI, as an authenticated user with no assigned permission; confirm 403, not 200 or a login redirect.
  • For DRF, assert the list action separately from the detail actions, because a different mechanism enforces each. Give a user one order out of three and confirm GET /orders/ returns exactly that one - only get_queryset() can produce that result, so this assertion is what catches an ownership permission class that leaves the list unscoped. Then exercise retrieve, update, and destroy against another user's ID, which is what has_object_permission() covers.
  • As a staff user, assert both halves: GET /orders/ returns every order and GET /orders/{someone-elses-id}/ returns 200. A staff list that works with a 403 behind each row means the queryset and the object permission disagree about who staff are.
  • Exercise every custom @action and every overridden retrieve()/update() with another user's ID, not just the generated routes. These are the methods that can load an object without get_object(), and a passing test on the generated retrieve says nothing about them.
  • Re-run any SAST/DAST scan that reported the finding to confirm it no longer triggers.

Common Pitfalls

  • Relying on has_object_permission() for the list action: A DRF ViewSet with an ownership permission class and an unscoped queryset = Model.objects.all() looks protected, and is - for the detail actions only. has_object_permission() runs from get_object(), which list never calls, so GET /orders/ serializes the whole table. The list action needs get_queryset() scoped to the caller.
  • Implementing has_permission() but not has_object_permission(): A custom DRF permission class gates whether the endpoint can be called at all, but generic detail views (retrieve, update, destroy) need has_object_permission() to check the specific object - without it, any authenticated user can act on any object.
  • Loading the object outside get_object(): An overridden retrieve() or a custom @action calling get_object_or_404(Order, pk=pk) gets neither the scoped queryset nor the object permission - DRF only runs check_object_permissions() from inside get_object(). The list looks correctly scoped and the endpoint beside it is open to any ID. Call self.get_object(), or self.check_object_permissions(request, obj) on whatever was loaded.
  • A staff or admin branch in only one of the two: Widening get_queryset() for staff without the matching allowance in has_object_permission() gives staff a full list and a 403 on every record in it. The reverse - an object permission that allows staff over a queryset that does not - hides the records from the list they are reachable from.
  • DEFAULT_PERMISSION_CLASSES = ["rest_framework.permissions.AllowAny"] left at the project default: Every ViewSet then has to set its own permission_classes, and a view that omits them inherits no authorization at all.
  • login_required used where a permission check was intended: On a Flask or Django view for an action that should be role- or ownership-restricted, login_required only checks that a session exists - authentication and authorization are separate steps.

Dependencies and Installation

Django's built-in permission framework and DRF's permission_classes need no extra package for role and object-level checks. For per-object permission grants beyond simple ownership, add django-guardian (pip install django-guardian) and include it in INSTALLED_APPS. For Flask, @login_required (via flask-login, pip install flask-login) only covers authentication, not authorization. Role and ownership checks need no extra package - use the hand-written decorator shown above, for the reasons in Centralizing Flask Checks Without a Library.

Additional Resources