DRF Permissions

Updated 6 interview angles 3 min read source
On this page11
  1. Built-in permission classes
  2. Default project-wide permission
  3. Per-view permissions
  4. Per-action permissions
  5. Writing a custom permission
  6. SAFE_METHODS
  7. Object-level vs view-level: when each runs
  8. Returning 401 vs
  9. Throttling vs permissions
  10. Common patterns
  11. Interview angle

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

python
# 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

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

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

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

Per-action permissions

For ViewSets:

python
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:

python
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

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

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():

python
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 DRF Throttling.

Common patterns

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

python
permission_classes = [IsAuthenticatedOrReadOnly]

Owner-only edits

python
permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]

Tenant scoping at the queryset level (defense in depth)

python
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 6

  • “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.