Skip to content
C Codeloom

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.

Intermediate 10 min read

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, view per 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
Authorization decision flow

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_id in 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_staff for everything. is_staff only 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-guardian for 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.