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
tasksapp (Users, Tasks, Comments) - JWT access/refresh token flow via
djangorestframework-simplejwt IsAuthenticatedand a customIsOwnerOrReadOnlypermission class- Filtering with
django-filter, search, and ordering - Page-number pagination
- A
pytest-djangotest 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, receiveaccess+refreshtokensTokenRefreshView— POSTrefreshtoken, receive a newaccesstokenTokenVerifyView— 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 pattern | View method | Name |
|---|---|---|
GET /api/tasks/ | list | task-list |
POST /api/tasks/ | create | task-list |
GET /api/tasks/{id}/ | retrieve | task-detail |
PUT /api/tasks/{id}/ | update | task-detail |
PATCH /api/tasks/{id}/ | partial_update | task-detail |
DELETE /api/tasks/{id}/ | destroy | task-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
| Concern | Solution used |
|---|---|
| Authentication | SimpleJWT (JWTAuthentication) |
| Token refresh | TokenRefreshView with ROTATE_REFRESH_TOKENS |
| Default permissions | IsAuthenticated globally |
| Object-level permissions | IsOwnerOrReadOnly on viewsets |
| Data isolation | get_queryset filters by request.user |
| Filtering | DjangoFilterBackend + SearchFilter + OrderingFilter |
| Pagination | PageNumberPagination (customizable) |
| Read/write serializers | TaskSerializer (read) + TaskWriteSerializer (write) |
| Tests | pytest-django + APIClient + JWT fixture |
Security notes before deploying
- Set
DEBUG = Falseand configureALLOWED_HOSTS. - Store
SECRET_KEYand database credentials in environment variables, never in source control. - Use HTTPS in production — JWT access tokens sent over plain HTTP can be intercepted.
- Keep
ACCESS_TOKEN_LIFETIMEshort (15–60 minutes) and rely on refresh tokens for session continuity. - Consider adding
rest_framework_simplejwt.token_blacklisttoINSTALLED_APPSand running its migrations so that revoked refresh tokens are tracked in the database.