Courses / Django Full Stack Web Development
Lesson 12 of 13
Django Permissions and Authorization
Move beyond is_authenticated. Learn how to model groups, object-level permissions, and DRF permission classes cleanly.
What you'll learn
- ✓Auth vs authz
- ✓Built-in permissions
- ✓Custom permission classes
- ✓Object-level checks
- ✓When to reach for roles
Prerequisites
- •Familiar with Django auth and DRF basics
What and Why
Authentication answers “who are you.” Authorization answers “what may you do.” Conflating the two is the most common security mistake in Django apps. A logged-in user is not automatically allowed to do anything. They are allowed to do the specific things your policy says they can.
Django ships with a flexible permission system: users, groups, model-level permissions, and hooks for object-level checks. DRF layers on top with permission classes that run on every request. Used together, you can express most real-world policies without third-party libraries.
Mental Model
Think of authorization as three concentric rings.
- Model-level: can this user touch this kind of object at all. Default permissions are
add,change,delete,viewper model. - Object-level: can this user touch this specific instance. The framework asks; you provide the answer.
- Field-level: can this user see or write this specific field. Usually handled in serializers.
A request must pass all relevant rings, and the rings should fail closed. When in doubt, deny.
Hands-on Example
Imagine a blog where authors can edit their own posts, editors can edit any post, and everyone authenticated can read.
Create groups in a data migration.
from django.db import migrations
def create_groups(apps, schema_editor):
Group = apps.get_model("auth", "Group")
Permission = apps.get_model("auth", "Permission")
editors, _ = Group.objects.get_or_create(name="Editors")
perms = Permission.objects.filter(
codename__in=["change_article", "delete_article"]
)
editors.permissions.add(*perms)
class Migration(migrations.Migration):
dependencies = [("auth", "0012_alter_user_first_name_max_length")]
operations = [migrations.RunPython(create_groups)]
A DRF permission class that checks both global and object-level rules.
from rest_framework import permissions
class ArticlePermission(permissions.BasePermission):
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return request.user.is_authenticated
return request.user.has_perm("blog.change_article") or \
request.user.has_perm("blog.add_article")
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
if request.user.groups.filter(name="Editors").exists():
return True
return obj.author_id == request.user.id
Wire it into the view.
class ArticleViewSet(viewsets.ModelViewSet):
queryset = Article.objects.all()
serializer_class = ArticleSerializer
permission_classes = [ArticlePermission]
Request -> Authenticate
|
v
has_permission(view) -> deny? -> 403
|
v
Fetch object -> has_object_permission(obj) -> deny? -> 404 or 403
|
v
Serializer field policy -> Response For field-level rules, override the serializer’s to_representation or use SerializerMethodField.
class ArticleSerializer(serializers.ModelSerializer):
def to_representation(self, instance):
data = super().to_representation(instance)
request = self.context.get("request")
if not request or not request.user.is_staff:
data.pop("internal_notes", None)
return data
Common Pitfalls
- Trusting client-supplied IDs. A user can send any
author_idin a POST body. Always set ownership server-side:serializer.save(author=request.user). - Mixing model permissions with hardcoded role checks. Pick one source of truth. If groups are your roles, look up groups consistently.
- Forgetting the queryset filter. Permissions stop forbidden writes, but a
GET /articles/can still leak data if the queryset returns rows the user shouldn’t see. Filter at the queryset level. - Using
is_stafffor everything.is_staffonly means “can access the admin.” It is not a generic admin role. - Returning 403 when 404 is safer. For private resources, returning 404 hides their existence from probing.
Practical Tips
- Define groups in migrations so every environment has the same baseline.
- Test permissions with parametric tests. One test per role per endpoint catches regressions cheaply.
- For complex domains, consider
django-guardianfor true row-level permissions backed by a join table. - Keep policy code in dedicated classes, not scattered across views. Easier to audit.
- Log authorization failures. Repeated 403s from one user is a useful signal.
Wrap-up
Authorization is policy, and policy belongs in one place. Use Django’s built-in permissions and groups for the broad strokes, DRF permission classes for the per-request decisions, and queryset filtering to make sure unauthorized rows never leave the database. With those three layers, you can express realistic policies clearly and audit them when the security review eventually shows up.
Progress is saved locally to your browser.