}

Django REST Framework Tutorial 2026: JWT Auth, Permissions, Filtering, and Tests

Django REST Framework Tutorial 2026: JWT Auth, Permissions, Filtering, and Tests

Django REST Framework (DRF) remains the gold standard for building REST APIs with Python in 2026. In this tutorial you will build a fully functional Task Management API from scratch, covering every layer a real production service needs: serializers, viewsets, JWT authentication with SimpleJWT, object-level permissions, query filtering, pagination, and a pytest-django test suite. Every code block shown here is part of a single working project you can run locally.

By the end you will have:

  • A Django + DRF project with a tasks app (Users, Tasks, Comments)
  • JWT access/refresh token flow via djangorestframework-simplejwt
  • IsAuthenticated and a custom IsOwnerOrReadOnly permission class
  • Filtering with django-filter, search, and ordering
  • Page-number pagination
  • A pytest-django test suite covering auth, permissions, filtering, and CRUD

Prerequisites

  • Python 3.12+
  • Basic knowledge of Django (models, views, URLs)
  • Familiarity with HTTP concepts (verbs, status codes, headers)

1. Project Setup

Create a virtual environment and install dependencies

python -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate

pip install django==5.1 \
            djangorestframework==3.15 \
            djangorestframework-simplejwt==5.3 \
            django-filter==24.3 \
            pytest-django==4.8 \
            pytest==8.2 \
            factory-boy==3.3

Bootstrap the Django project

django-admin startproject taskapi .
python manage.py startapp tasks

Your directory layout will look like this:

taskapi/
    settings.py
    urls.py
tasks/
    models.py
    serializers.py
    views.py
    permissions.py
    urls.py
    tests/
        __init__.py
        conftest.py
        test_auth.py
        test_tasks.py
manage.py
pytest.ini

Configure settings

Open taskapi/settings.py and replace or extend the relevant sections:

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # third-party
    "rest_framework",
    "rest_framework_simplejwt",
    "django_filters",
    # local
    "tasks",
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework_simplejwt.authentication.JWTAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

from datetime import timedelta

SIMPLE_JWT = {
    "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
    "REFRESH_TOKEN_LIFETIME": timedelta(days=7),
    "ROTATE_REFRESH_TOKENS": True,
    "BLACKLIST_AFTER_ROTATION": True,
    "AUTH_HEADER_TYPES": ("Bearer",),
}

Setting DEFAULT_AUTHENTICATION_CLASSES to JWTAuthentication means every request must carry a valid Authorization: Bearer <token> header unless you explicitly allow unauthenticated access on a particular view.


2. Models

A Task belongs to an owner (a Django User). A Comment belongs to both a Task and an author.

# tasks/models.py
from django.conf import settings
from django.db import models


class Task(models.Model):
    class Priority(models.TextChoices):
        LOW = "low", "Low"
        MEDIUM = "medium", "Medium"
        HIGH = "high", "High"

    class Status(models.TextChoices):
        TODO = "todo", "To Do"
        IN_PROGRESS = "in_progress", "In Progress"
        DONE = "done", "Done"

    title = models.CharField(max_length=255)
    description = models.TextField(blank=True)
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="tasks",
    )
    priority = models.CharField(
        max_length=10, choices=Priority.choices, default=Priority.MEDIUM
    )
    status = models.CharField(
        max_length=15, choices=Status.choices, default=Status.TODO
    )
    due_date = models.DateField(null=True, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return self.title


class Comment(models.Model):
    task = models.ForeignKey(Task, on_delete=models.CASCADE, related_name="comments")
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="comments",
    )
    body = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ["created_at"]

    def __str__(self):
        return f"Comment by {self.author} on {self.task}"

Run the migrations:

python manage.py makemigrations tasks
python manage.py migrate

3. Serializers: ModelSerializer vs Serializer

DRF offers two base classes for serializers.

serializers.Serializer — explicit field declaration, full control, more boilerplate. Use it when you need to validate data that does not map directly to a model (e.g., a login payload, a complex composite write endpoint).

serializers.ModelSerializer — auto-generates fields from the model's meta, provides a default create() and update(), and greatly reduces code. Use it for standard CRUD on a model.

The golden rule from the DRF documentation is: start with ModelSerializer; reach for the plain Serializer when the model boundary becomes a limitation.

CommentSerializer

# tasks/serializers.py
from django.contrib.auth import get_user_model
from rest_framework import serializers

from .models import Comment, Task

User = get_user_model()


class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "username", "email"]
        read_only_fields = ["id"]


class CommentSerializer(serializers.ModelSerializer):
    author = UserSerializer(read_only=True)

    class Meta:
        model = Comment
        fields = ["id", "author", "body", "created_at"]
        read_only_fields = ["id", "author", "created_at"]

TaskSerializer

class TaskSerializer(serializers.ModelSerializer):
    owner = UserSerializer(read_only=True)
    comments = CommentSerializer(many=True, read_only=True)
    comment_count = serializers.IntegerField(source="comments.count", read_only=True)

    class Meta:
        model = Task
        fields = [
            "id",
            "title",
            "description",
            "owner",
            "priority",
            "status",
            "due_date",
            "comment_count",
            "comments",
            "created_at",
            "updated_at",
        ]
        read_only_fields = ["id", "owner", "created_at", "updated_at"]

owner is read_only=True because we set it automatically in the view's perform_create — we never want the client to supply an arbitrary owner ID.

TaskWriteSerializer (separation of read and write)

A common production pattern is to use separate serializers for reads (verbose, nested) and writes (flat, validated input only). This avoids the complexity of making nested writes work and keeps validation logic clean.

class TaskWriteSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = ["title", "description", "priority", "status", "due_date"]

    def validate_due_date(self, value):
        from datetime import date
        if value and value < date.today():
            raise serializers.ValidationError("due_date cannot be in the past.")
        return value

4. JWT Authentication with SimpleJWT

djangorestframework-simplejwt is the community-recommended JWT library for DRF. It provides:

  • TokenObtainPairView — POST username + password, receive access + refresh tokens
  • TokenRefreshView — POST refresh token, receive a new access token
  • TokenVerifyView — POST a token to verify it is still valid

Wire up the token URLs

# taskapi/urls.py
from django.contrib import admin
from django.urls import include, path
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenVerifyView,
)

urlpatterns = [
    path("admin/", admin.site.urls),
    # JWT endpoints
    path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
    path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
    path("api/token/verify/", TokenVerifyView.as_view(), name="token_verify"),
    # App endpoints
    path("api/", include("tasks.urls")),
]

Token flow in practice

# 1. Obtain tokens
POST /api/token/
{ "username": "alice", "password": "secret" }

# Response
{ "access": "eyJ...", "refresh": "eyJ..." }

# 2. Use the access token
GET /api/tasks/
Authorization: Bearer eyJ...

# 3. Refresh when the access token expires
POST /api/token/refresh/
{ "refresh": "eyJ..." }

# Response
{ "access": "eyJ...(new)", "refresh": "eyJ...(rotated)" }

With ROTATE_REFRESH_TOKENS = True (set in our SIMPLE_JWT config), every refresh call invalidates the old refresh token and issues a new one, reducing the window of misuse if a refresh token is ever leaked.


5. Permissions

IsAuthenticated (built-in)

We already set DEFAULT_PERMISSION_CLASSES to IsAuthenticated, so every view is protected by default. Unauthenticated requests receive HTTP 401 Unauthorized.

Custom IsOwnerOrReadOnly permission

Object-level permissions run after the view has retrieved the object from the database. The has_object_permission method is called by DRF whenever a view calls self.check_object_permissions(request, obj) — which all generic views do automatically.

# tasks/permissions.py
from rest_framework.permissions import BasePermission, SAFE_METHODS


class IsOwnerOrReadOnly(BasePermission):
    """
    Allow read access to any authenticated user.
    Allow write access only to the resource owner.
    """

    def has_object_permission(self, request, view, obj):
        if request.method in SAFE_METHODS:
            return True
        # obj.owner for Task, obj.author for Comment
        owner = getattr(obj, "owner", None) or getattr(obj, "author", None)
        return owner == request.user

SAFE_METHODS is the tuple ("GET", "HEAD", "OPTIONS"). Any non-safe HTTP verb (POST, PUT, PATCH, DELETE) will be refused unless the requesting user owns the object.


6. ViewSets and Routers

ViewSets collapse the standard CRUD view logic into a single class. Combined with DRF's DefaultRouter, they generate a complete set of URL patterns with no manual wiring.

# tasks/views.py
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, viewsets
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response

from .models import Comment, Task
from .permissions import IsOwnerOrReadOnly
from .serializers import (
    CommentSerializer,
    TaskSerializer,
    TaskWriteSerializer,
)


class TaskViewSet(viewsets.ModelViewSet):
    """
    list:   GET  /api/tasks/
    create: POST /api/tasks/
    retrieve: GET  /api/tasks/{id}/
    update: PUT  /api/tasks/{id}/
    partial_update: PATCH /api/tasks/{id}/
    destroy: DELETE /api/tasks/{id}/
    """

    permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]
    filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
    filterset_fields = ["status", "priority"]
    search_fields = ["title", "description"]
    ordering_fields = ["created_at", "due_date", "priority"]
    ordering = ["-created_at"]

    def get_queryset(self):
        # Each user sees only their own tasks
        return Task.objects.filter(owner=self.request.user).select_related("owner")

    def get_serializer_class(self):
        if self.action in ("create", "update", "partial_update"):
            return TaskWriteSerializer
        return TaskSerializer

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

    @action(detail=True, methods=["get"], url_path="comments")
    def comments(self, request, pk=None):
        task = self.get_object()
        qs = task.comments.select_related("author").all()
        serializer = CommentSerializer(qs, many=True)
        return Response(serializer.data)


class CommentViewSet(viewsets.ModelViewSet):
    serializer_class = CommentSerializer
    permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]

    def get_queryset(self):
        return Comment.objects.filter(
            task__owner=self.request.user
        ).select_related("author", "task")

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)

Wire up the router

# tasks/urls.py
from rest_framework.routers import DefaultRouter
from .views import CommentViewSet, TaskViewSet

router = DefaultRouter()
router.register(r"tasks", TaskViewSet, basename="task")
router.register(r"comments", CommentViewSet, basename="comment")

urlpatterns = router.urls

DefaultRouter generates the following URL patterns automatically:

URL patternView methodName
GET /api/tasks/listtask-list
POST /api/tasks/createtask-list
GET /api/tasks/{id}/retrievetask-detail
PUT /api/tasks/{id}/updatetask-detail
PATCH /api/tasks/{id}/partial_updatetask-detail
DELETE /api/tasks/{id}/destroytask-detail
GET /api/tasks/{id}/comments/comments (custom action)task-comments

7. Filtering, Search, and Ordering

We set up three filter backends globally in settings. On the TaskViewSet we configure them per-view:

filterset_fields = ["status", "priority"]   # exact match filters
search_fields   = ["title", "description"]  # ?search=...
ordering_fields = ["created_at", "due_date", "priority"]

Example queries:

# Filter by status
GET /api/tasks/?status=in_progress

# Filter by priority
GET /api/tasks/?priority=high

# Full-text search across title and description
GET /api/tasks/?search=deploy

# Combine filters
GET /api/tasks/?status=todo&priority=high&ordering=due_date

# Reverse ordering (prefix with -)
GET /api/tasks/?ordering=-created_at

Advanced filtering with FilterSet

For range queries (e.g., tasks due before a date) you need a custom FilterSet:

# tasks/filters.py
import django_filters
from .models import Task


class TaskFilter(django_filters.FilterSet):
    due_before = django_filters.DateFilter(field_name="due_date", lookup_expr="lte")
    due_after = django_filters.DateFilter(field_name="due_date", lookup_expr="gte")
    title_contains = django_filters.CharFilter(field_name="title", lookup_expr="icontains")

    class Meta:
        model = Task
        fields = ["status", "priority", "due_before", "due_after", "title_contains"]

Then set filterset_class = TaskFilter on the viewset instead of filterset_fields.


8. Pagination

PageNumberPagination is already active via DEFAULT_PAGINATION_CLASS. The API client controls pages via query parameters:

GET /api/tasks/?page=2
GET /api/tasks/?page=2&page_size=10   # if PAGE_SIZE override is enabled

A paginated response looks like:

{
  "count": 47,
  "next": "http://localhost:8000/api/tasks/?page=3",
  "previous": "http://localhost:8000/api/tasks/?page=1",
  "results": [...]
}

To allow the client to override page_size, subclass PageNumberPagination:

# taskapi/pagination.py
from rest_framework.pagination import PageNumberPagination


class StandardResultsPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"
    max_page_size = 100

Then set "DEFAULT_PAGINATION_CLASS": "taskapi.pagination.StandardResultsPagination" in REST_FRAMEWORK.


9. Testing with pytest-django and APIClient

pytest-django integrates pytest with Django's test infrastructure. Combined with DRF's APIClient, it makes writing API tests straightforward and readable.

pytest.ini

[pytest]
DJANGO_SETTINGS_MODULE = taskapi.settings
python_files = tests/test_*.py
python_classes = Test*
python_functions = test_*

conftest.py with fixtures

# tasks/tests/conftest.py
import pytest
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken

User = get_user_model()


@pytest.fixture
def api_client():
    return APIClient()


@pytest.fixture
def create_user(db):
    def make_user(**kwargs):
        kwargs.setdefault("password", "testpass123")
        password = kwargs.pop("password")
        user = User.objects.create_user(**kwargs)
        user.set_password(password)
        user.save()
        return user
    return make_user


@pytest.fixture
def alice(create_user):
    return create_user(username="alice", email="[email protected]")


@pytest.fixture
def bob(create_user):
    return create_user(username="bob", email="[email protected]")


@pytest.fixture
def auth_client(api_client):
    """Return an APIClient pre-authenticated for a given user via JWT."""
    def _auth(user):
        refresh = RefreshToken.for_user(user)
        api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {refresh.access_token}")
        return api_client
    return _auth

Testing JWT authentication

# tasks/tests/test_auth.py
import pytest
from django.urls import reverse


@pytest.mark.django_db
class TestJWTAuth:
    def test_obtain_token_success(self, api_client, alice):
        url = reverse("token_obtain_pair")
        response = api_client.post(url, {"username": "alice", "password": "testpass123"})
        assert response.status_code == 200
        assert "access" in response.data
        assert "refresh" in response.data

    def test_obtain_token_wrong_password(self, api_client, alice):
        url = reverse("token_obtain_pair")
        response = api_client.post(url, {"username": "alice", "password": "wrong"})
        assert response.status_code == 401

    def test_refresh_token(self, api_client, alice):
        obtain_url = reverse("token_obtain_pair")
        refresh_url = reverse("token_refresh")

        obtain_resp = api_client.post(obtain_url, {"username": "alice", "password": "testpass123"})
        refresh_token = obtain_resp.data["refresh"]

        refresh_resp = api_client.post(refresh_url, {"refresh": refresh_token})
        assert refresh_resp.status_code == 200
        assert "access" in refresh_resp.data

    def test_unauthenticated_request_rejected(self, api_client):
        url = reverse("task-list")
        response = api_client.get(url)
        assert response.status_code == 401

Testing CRUD and ownership permissions

# tasks/tests/test_tasks.py
import pytest
from django.urls import reverse
from tasks.models import Task


def make_task(owner, **kwargs):
    kwargs.setdefault("title", "Test Task")
    kwargs.setdefault("priority", "medium")
    kwargs.setdefault("status", "todo")
    return Task.objects.create(owner=owner, **kwargs)


@pytest.mark.django_db
class TestTaskCRUD:
    def test_create_task(self, auth_client, alice):
        client = auth_client(alice)
        url = reverse("task-list")
        payload = {"title": "Ship it", "priority": "high", "status": "todo"}
        response = client.post(url, payload, format="json")
        assert response.status_code == 201
        assert response.data["title"] == "Ship it"
        assert Task.objects.filter(owner=alice, title="Ship it").exists()

    def test_list_tasks_only_own(self, auth_client, alice, bob, db):
        make_task(alice, title="Alice's task")
        make_task(bob, title="Bob's task")

        client = auth_client(alice)
        response = client.get(reverse("task-list"))
        assert response.status_code == 200
        titles = [t["title"] for t in response.data["results"]]
        assert "Alice's task" in titles
        assert "Bob's task" not in titles

    def test_retrieve_own_task(self, auth_client, alice, db):
        task = make_task(alice)
        client = auth_client(alice)
        url = reverse("task-detail", kwargs={"pk": task.pk})
        response = client.get(url)
        assert response.status_code == 200
        assert response.data["id"] == task.pk

    def test_update_own_task(self, auth_client, alice, db):
        task = make_task(alice, title="Old title")
        client = auth_client(alice)
        url = reverse("task-detail", kwargs={"pk": task.pk})
        response = client.patch(url, {"title": "New title"}, format="json")
        assert response.status_code == 200
        task.refresh_from_db()
        assert task.title == "New title"

    def test_delete_own_task(self, auth_client, alice, db):
        task = make_task(alice)
        client = auth_client(alice)
        url = reverse("task-detail", kwargs={"pk": task.pk})
        response = client.delete(url)
        assert response.status_code == 204
        assert not Task.objects.filter(pk=task.pk).exists()

    def test_cannot_update_others_task(self, auth_client, alice, bob, db):
        task = make_task(bob, title="Bob's task")
        # Alice tries to update Bob's task — but since get_queryset filters by owner,
        # Alice will get a 404 (not found), which is also a valid security response.
        client = auth_client(alice)
        url = reverse("task-detail", kwargs={"pk": task.pk})
        response = client.patch(url, {"title": "Hacked"}, format="json")
        assert response.status_code == 404


@pytest.mark.django_db
class TestTaskFiltering:
    def test_filter_by_status(self, auth_client, alice, db):
        make_task(alice, title="Todo task", status="todo")
        make_task(alice, title="Done task", status="done")

        client = auth_client(alice)
        response = client.get(reverse("task-list"), {"status": "todo"})
        assert response.status_code == 200
        statuses = [t["status"] for t in response.data["results"]]
        assert all(s == "todo" for s in statuses)

    def test_search_by_title(self, auth_client, alice, db):
        make_task(alice, title="Deploy to production")
        make_task(alice, title="Write unit tests")

        client = auth_client(alice)
        response = client.get(reverse("task-list"), {"search": "Deploy"})
        assert response.status_code == 200
        assert len(response.data["results"]) == 1
        assert response.data["results"][0]["title"] == "Deploy to production"

    def test_ordering_by_priority(self, auth_client, alice, db):
        make_task(alice, title="Low priority task", priority="low")
        make_task(alice, title="High priority task", priority="high")

        client = auth_client(alice)
        response = client.get(reverse("task-list"), {"ordering": "priority"})
        assert response.status_code == 200
        assert len(response.data["results"]) == 2

Run the full suite:

pytest tasks/tests/ -v

10. Running the Development Server

python manage.py createsuperuser
python manage.py runserver

Create your first task via curl:

# 1. Get a token
TOKEN=$(curl -s -X POST http://localhost:8000/api/token/ \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"yourpassword"}' | python -c "import sys,json; print(json.load(sys.stdin)['access'])")

# 2. Create a task
curl -s -X POST http://localhost:8000/api/tasks/ \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Launch product","priority":"high","status":"todo"}'

# 3. List tasks
curl -s http://localhost:8000/api/tasks/ \
  -H "Authorization: Bearer $TOKEN" | python -m json.tool

Key Takeaways and Production Checklist

ConcernSolution used
AuthenticationSimpleJWT (JWTAuthentication)
Token refreshTokenRefreshView with ROTATE_REFRESH_TOKENS
Default permissionsIsAuthenticated globally
Object-level permissionsIsOwnerOrReadOnly on viewsets
Data isolationget_queryset filters by request.user
FilteringDjangoFilterBackend + SearchFilter + OrderingFilter
PaginationPageNumberPagination (customizable)
Read/write serializersTaskSerializer (read) + TaskWriteSerializer (write)
Testspytest-django + APIClient + JWT fixture

Security notes before deploying

  1. Set DEBUG = False and configure ALLOWED_HOSTS.
  2. Store SECRET_KEY and database credentials in environment variables, never in source control.
  3. Use HTTPS in production — JWT access tokens sent over plain HTTP can be intercepted.
  4. Keep ACCESS_TOKEN_LIFETIME short (15–60 minutes) and rely on refresh tokens for session continuity.
  5. Consider adding rest_framework_simplejwt.token_blacklist to INSTALLED_APPS and running its migrations so that revoked refresh tokens are tracked in the database.

Further Reading

Leonardo Lazzaro

Software engineer and technical writer. 10+ years experience in DevOps, Python, and Linux systems.

More articles by Leonardo Lazzaro