DRF Permissions

6 interview angles 3 min read source

DRF Permissions

Permissions answer “is this request allowed to do this?” after authentication has answered “who is this?”. They run after authentication_classes and before the view method.

Built-in permission classes

Class Allows
AllowAny everyone (default if you set permission_classes = [] for clarity)
IsAuthenticated logged-in users only
IsAdminUser user.is_staff = True
IsAuthenticatedOrReadOnly safe methods (GET/HEAD/OPTIONS) for anyone, write methods only for authenticated
DjangoModelPermissions uses Django’s app.add_book / app.change_book etc. — requires queryset on the view
DjangoModelPermissionsOrAnonReadOnly as above, plus read for anon
DjangoObjectPermissions per-object Django permissions (needs django-guardian or similar)

Default project-wide permission

# settings.py
REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

Always set this explicitly. The DRF default is AllowAny — easy to ship a public-by-default API.

Per-view permissions

class BookViewSet(viewsets.ModelViewSet):
    permission_classes = [IsAuthenticated, IsBookOwner]

All listed classes must pass (logical AND). For OR / NOT logic use bitwise operators on instances:

permission_classes = [IsAdminUser | IsOwner]   # admin OR owner
permission_classes = [IsAuthenticated & ~IsBanned]

Per-action permissions

For ViewSets:

def get_permissions(self):
    if self.action in ("list", "retrieve"):
        return [permissions.AllowAny()]
    if self.action == "destroy":
        return [permissions.IsAdminUser()]
    return [permissions.IsAuthenticated()]

Note ()get_permissions returns instances, not classes.

Writing a custom permission

Two methods to optionally override:

from rest_framework.permissions import BasePermission, SAFE_METHODS

class IsOwnerOrReadOnly(BasePermission):
    def has_permission(self, request, view):
        # called BEFORE the view method runs, before get_object()
        return request.user.is_authenticated or request.method in SAFE_METHODS

    def has_object_permission(self, request, view, obj):
        # called by get_object() on detail endpoints
        if request.method in SAFE_METHODS:
            return True
        return obj.owner_id == request.user.id

Important: has_object_permission is only called from get_object(). If your view doesn’t call get_object() (e.g. a custom action that filters its own queryset), you must call self.check_object_permissions(request, obj) yourself.

SAFE_METHODS

from rest_framework.permissions import SAFE_METHODS  # ("GET", "HEAD", "OPTIONS")

Use it instead of hardcoding the tuple — it’s the canonical “read-only HTTP method” check.

Object-level vs view-level: when each runs

Endpoint has_permission has_object_permission
GET /books/ (list) yes no — list iterates all objects, doesn’t call get_object()
POST /books/ (create) yes no — there’s no object yet
GET /books/{pk}/ (retrieve) yes yes
PUT/PATCH/DELETE /books/{pk}/ yes yes
@action(detail=True) /books/{pk}/publish/ yes yes (self.get_object() triggers it)
@action(detail=False) /books/recent/ yes no

Common bug: people put their authorization logic in has_object_permission and assume it filters list endpoints too. It doesn’t. For list filtering, scope get_queryset():

def get_queryset(self):
    qs = super().get_queryset()
    if self.request.user.is_staff:
        return qs
    return qs.filter(owner=self.request.user)

Returning 401 vs 403

  • 401 Unauthorized = not authenticated, but the endpoint requires auth. DRF returns this when request.user is AnonymousUser and a permission requires auth.
  • 403 Forbidden = authenticated but not allowed.

DRF picks between them based on whether any authentication_classes returned a WWW-Authenticate header. If yes, anonymous = 401; otherwise = 403.

Throttling vs permissions

Both can deny a request, but:

  • Permissions: identity-based (“admins only”).
  • Throttling: rate-based (“max 100/hour”). See 08_throttling.md.

Common patterns

Read-only public, write authenticated (the most common API)

permission_classes = [IsAuthenticatedOrReadOnly]

Owner-only edits

permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]

Tenant scoping at the queryset level (defense in depth)

def get_queryset(self):
    return super().get_queryset().filter(tenant=self.request.user.tenant)

Permissions check intent; querysets ensure the data the user could try to access is already filtered. Both together prevent enumeration leaks.

Interview angle

  • “Difference between has_permission and has_object_permission?” — view-level (runs always) vs object-level (only when get_object() is called, i.e. detail endpoints).
  • “Why doesn’t my object-level permission run on list?” — list doesn’t call get_object(). Filter get_queryset() instead.
  • “How do you combine permissions with OR semantics?” — bitwise | on instances: IsAdmin | IsOwner. (DRF supports &, |, ~.)
  • “Difference between 401 and 403 in DRF?” — 401 = not authenticated and at least one auth class advertised a WWW-Authenticate header; 403 = authenticated but disallowed.
  • “How do you set different permissions per action in a ViewSet?” — override get_permissions() and branch on self.action. Return instances, not classes.
  • “What’s a common security anti-pattern with DRF permissions?” — relying only on has_object_permission and forgetting to scope get_queryset() — list endpoints leak everything.