Django REST Framework: User-Level and Object-Level Permissions

Blog / Django · May 23, 2022 · Updated June 10, 2026 · 8 min read
Django REST Framework: User-Level and Object-Level Permissions

In Django REST Framework, two separate permission checks gate every request. View-level (user-level) permissions decide who may reach an endpoint at all — they run once, before the view executes, by calling each permission class's has_permission(). Object-level permissions decide which individual records that user may act on — DRF calls has_object_permission() per object, but only when the view fetches a single instance through get_object(). The two layers stack: a request must pass every view-level check first, and then (for detail actions) every object-level check on the specific record.

The most common security bug here is assuming object-level permissions filter list endpoints. They do not. This guide shows both layers, how to combine them, and how to correctly scope lists with queryset filtering — using Django 5.x and DRF 3.15+.

Authentication vs permission vs throttling

These three run in order on every DRF request, and conflating them causes subtle bugs:

  • Authentication answers "who is making this request?" It populates request.user and request.auth (e.g. via SessionAuthentication, TokenAuthentication, or JWT). It does not decide access — an unauthenticated request still reaches the permission layer as AnonymousUser.
  • Permissions answer "is this user allowed to do this?" This is where view-level and object-level checks live. A failed check returns 403 Forbidden (or 401 if no authentication was supplied).
  • Throttling answers "how often may they do it?" It rate-limits requests and returns 429 Too Many Requests.

Permissions assume authentication already ran, so they read request.user freely. Keep access logic in permissions — not in authentication classes and not scattered inside view bodies.

View-level (user-level) permissions

A view-level permission runs once per request via has_permission(self, request, view). DRF ships several built-ins:

  • AllowAny — no restriction (the implicit default if you set nothing).
  • IsAuthenticated — request must come from a logged-in user.
  • IsAdminUserrequest.user.is_staff must be True.
  • IsAuthenticatedOrReadOnly — anyone can use safe methods (GET, HEAD, OPTIONS); writes require authentication.
  • DjangoModelPermissions — maps HTTP methods to Django's built-in model permissions (add/change/delete); requires a queryset on the view.
  • DjangoModelPermissionsOrAnonReadOnly — same, but allows anonymous read.

Set them per view with permission_classes:

# views.py
from rest_framework import generics, permissions
from .models import Article
from .serializers import ArticleSerializer


class ArticleListCreateView(generics.ListCreateAPIView):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    # Anyone can read; only authenticated users can create.
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]

To apply a policy site-wide, set DEFAULT_PERMISSION_CLASSES once. Anything not overridden on a view inherits it — a safe default is "deny by default, allow explicitly":

# settings.py
REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ],
    # Locked down by default; relax per-view where public access is intended.
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

When the built-ins are not enough, subclass BasePermission and implement has_permission(). Return True to allow, False to deny. This example restricts an endpoint to users with an "author" role on the user model:

# permissions.py
from rest_framework.permissions import BasePermission


class IsAuthorRole(BasePermission):
    """Allow access only to users whose role is 'author'."""

    message = "Only authors may perform this action."

    def has_permission(self, request, view):
        return bool(
            request.user
            and request.user.is_authenticated
            and request.user.role == "author"
        )

Object-level permissions

Object-level permissions answer "may this user touch this record?" via has_object_permission(self, request, view, obj). The critical detail: DRF only calls it when the view calls get_object(), which internally runs self.check_object_permissions(request, obj). That happens for detail actions (retrieve, update, partial_update, destroy) in the generic views and ModelViewSet.

It does not happen for list actions. ListAPIView never calls get_object(), so has_object_permission() is never invoked for the rows in a list. If you rely on it to hide other users' records from a list, every row leaks. Lists must be scoped by filtering the queryset (covered below).

A classic object-level permission — owners can edit; everyone else gets read-only:

# permissions.py
from rest_framework.permissions import BasePermission, SAFE_METHODS


class IsOwnerOrReadOnly(BasePermission):
    """Read for anyone; write only for the object's owner."""

    def has_object_permission(self, request, view, obj):
        # SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS')
        if request.method in SAFE_METHODS:
            return True
        return obj.owner_id == request.user.id
# views.py
from rest_framework import generics, permissions
from .models import Article
from .serializers import ArticleSerializer
from .permissions import IsOwnerOrReadOnly


class ArticleDetailView(generics.RetrieveUpdateDestroyAPIView):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    # First the view-level check runs, then the object-level check
    # (because RetrieveUpdateDestroyAPIView calls get_object()).
    permission_classes = [
        permissions.IsAuthenticatedOrReadOnly,
        IsOwnerOrReadOnly,
    ]

If you write a custom detail view that fetches the object manually instead of calling get_object(), you must call self.check_object_permissions(request, obj) yourself — otherwise the object-level check silently never runs:

# views.py
from django.shortcuts import get_object_or_404
from rest_framework.views import APIView
from rest_framework.response import Response
from .models import Article
from .permissions import IsOwnerOrReadOnly


class ArticleArchiveView(APIView):
    permission_classes = [IsOwnerOrReadOnly]

    def post(self, request, pk):
        article = get_object_or_404(Article, pk=pk)
        # REQUIRED: APIView does not run object permissions for you.
        self.check_object_permissions(request, article)
        article.archive()
        return Response({"status": "archived"})

Combining permissions with AND, OR, NOT

DRF lets you compose permission classes with bitwise operators, so you do not need a bespoke class for every combination:

  • & (AND) — both must pass. Listing classes in permission_classes is already an implicit AND.
  • | (OR) — either may pass.
  • ~ (NOT) — inverts a permission.

You can also nest them with parentheses:

# views.py
from rest_framework import viewsets, permissions
from .models import Article
from .serializers import ArticleSerializer
from .permissions import IsOwnerOrReadOnly, IsAuthorRole


class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer

    # Staff get full access; otherwise you must be an author
    # AND (own the object OR be making a read-only request).
    permission_classes = [
        permissions.IsAdminUser | (IsAuthorRole & IsOwnerOrReadOnly)
    ]

The operators apply to both layers: when classes are OR'd, the combined object-level result is OR'd too. One caveat — a permission class that defines only has_permission() (and not has_object_permission()) defaults to allowing at the object level, so it won't restrict detail actions on its own.

Filtering querysets by user

Because list endpoints bypass object-level permissions, scoping a list is a queryset concern, not a permission concern. Override get_queryset() so users only ever see rows they're entitled to. This is the correct, leak-proof way to build "my items" endpoints:

# views.py
from rest_framework import generics, permissions
from .models import Article
from .serializers import ArticleSerializer


class MyArticlesView(generics.ListAPIView):
    serializer_class = ArticleSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        # Each user sees only their own articles — enforced in SQL,
        # so it applies to the list, not just single-object fetches.
        return Article.objects.filter(owner=self.request.user)

For reusable, declarative scoping across many views, write a BaseFilterBackend and add it to filter_backends (or DEFAULT_FILTER_BACKENDS). It runs on every list and detail queryset:

# filters.py
from rest_framework import filters


class OwnedByUserFilterBackend(filters.BaseFilterBackend):
    """Restrict every queryset to objects owned by the current user."""

    def filter_queryset(self, request, queryset, view):
        if request.user.is_staff:
            return queryset
        return queryset.filter(owner=request.user)

django-guardian and DjangoObjectPermissions

The custom-class approach above computes permissions from object attributes (obj.owner_id == request.user.id). When you need real per-object access control lists stored in the database — e.g. "share this document with these three specific users" — computing at request time isn't enough. Use django-guardian, which adds object-level permission rows backed by Django's auth framework:

# Assign a per-object permission to a specific user
from guardian.shortcuts import assign_perm, get_objects_for_user

assign_perm("change_article", some_user, article)        # grant
get_objects_for_user(some_user, "app.change_article")    # query grants

Pair guardian with DRF's DjangoObjectPermissions permission class, which checks both model-level Django permissions and per-object permissions (via the configured backend). Add guardian's ObjectPermissionsBackend to AUTHENTICATION_BACKENDS and set DjangoObjectPermissions on the view. Reach for this only when you genuinely need stored ACLs — for simple ownership rules, a custom has_object_permission plus queryset filtering is lighter and faster.

Which permission mechanism to use

Mechanism What it protects Runs on lists? Best for
View-level (has_permission) The endpoint as a whole Yes (once per request) Role gates, "must be logged in", admin-only routes
Object-level (has_object_permission) A single fetched record No — detail actions only Ownership/edit rules on retrieve/update/delete
Queryset filtering (get_queryset / filter backend) Which rows a user can see Yes (in SQL) Scoping lists; preventing data leaks in collections
django-guardian + DjangoObjectPermissions Per-object ACLs in the DB With a filter backend Sharing, granular grants, "give user X access to record Y"

A secure API usually uses several at once: a view-level gate, queryset filtering for lists, and an object-level check for detail writes.

Frequently Asked Questions

Does DRF check object-level permissions on list endpoints?

No. DRF only runs has_object_permission() when a view calls get_object(), which list views never do. If you rely on object-level permissions to hide other users' records from a list, every row will be exposed. Scope lists by overriding get_queryset() or with a filter backend instead.

What is the difference between has_permission and has_object_permission?

has_permission(request, view) is a view-level check that runs once before the view executes and decides whether the user may reach the endpoint at all. has_object_permission(request, view, obj) is an object-level check that runs per record, only for detail actions, and decides whether the user may act on that specific instance. A request must pass the view-level check first; the object-level check is reached only afterward.

How do I combine multiple permission classes?

Listing classes in permission_classes ANDs them — all must pass. For other logic, use the bitwise operators DRF supports on permission classes: & (AND), | (OR), and ~ (NOT), with parentheses for grouping, e.g. [IsAdminUser | (IsAuthorRole & IsOwnerOrReadOnly)].

Why is my has_object_permission method never called?

Most likely your view doesn't call get_object() — it's a list view, or a custom APIView/action that fetches the object manually. DRF runs object permissions inside get_object() via check_object_permissions(). In custom views, fetch the object and then call self.check_object_permissions(request, obj) explicitly.

Should I filter the queryset or rely on object permissions for lists?

Always filter the queryset for lists. Object-level permissions are not applied to list results, so filtering in get_queryset() (or a filter backend) is the only reliable way to keep one user's rows out of another user's list response. Use object-level permissions for single-record write actions.

When should I use django-guardian instead of a custom permission class?

Use a custom BasePermission when access can be computed from the request and object attributes (ownership, roles, status). Use django-guardian when you need per-object permissions stored in the database — sharing a record with specific users, granular grants, or admin-managed ACLs — and pair it with DRF's DjangoObjectPermissions.

Does a permission class without has_object_permission block detail actions?

No. If a permission class defines only has_permission(), its object-level check defaults to True, so it allows all objects. To restrict individual records you must implement has_object_permission() (or add a separate class that does).


At MicroPyramid we've spent 12+ years and 50+ delivered projects building secure Django and DRF APIs where access control is layered correctly — view gates, queryset scoping, and object checks working together rather than one mechanism doing the job of three. Explore our Django development services and Python development services, or read our companion guide on customizing DRF serializers to shape what those permission-gated endpoints return.

Share this article