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.userisAnonymousUserand 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_permissionandhas_object_permission?” — view-level (runs always) vs object-level (only whenget_object()is called, i.e. detail endpoints). - “Why doesn’t my object-level permission run on
list?” — list doesn’t callget_object(). Filterget_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-Authenticateheader; 403 = authenticated but disallowed. - “How do you set different permissions per action in a
ViewSet?” — overrideget_permissions()and branch onself.action. Return instances, not classes. - “What’s a common security anti-pattern with DRF permissions?” — relying only on
has_object_permissionand forgetting to scopeget_queryset()— list endpoints leak everything.