>

Building a Complete Django REST API with JWT Authentication and Access Control

Ali Malek 2026-01-24
17 min

Table of Contents

  1. Introduction
  2. Project Overview
  3. Prerequisites
  4. Setting Up the Environment
  5. Installing Dependencies
  6. Configuring Django Settings
  7. Understanding the Models
  8. Creating API Serializers
  9. Building API Views with Access Control
  10. Implementing JWT Authentication
  11. URL Configuration
  12. Testing the API
  13. Production Deployment
  14. Best Practices
  15. Troubleshooting
  16. Conclusion

Introduction

Building APIs is a crucial skill in modern web development. Whether you're creating a mobile app backend, integrating with frontend frameworks like React or Vue, or building microservices, a well-designed REST API is essential.

In this comprehensive tutorial, you'll learn how to build a production-ready Django REST API with JWT authentication and sophisticated access control. We'll create a blog API where:

  • Free posts are accessible to everyone
  • Paid posts require purchase for full content access
  • JWT authentication secures user-specific endpoints
  • Proper serialization handles data transformation
  • Access control manages content visibility

By the end of this tutorial, you'll have a complete API that can handle real-world scenarios and scale with your application needs.

Project Overview

We're building a blog API with the following features:

Core Functionality

  • Blog Posts: Free and paid content with access control
  • Categories: Hierarchical organization with parent/child relationships
  • Tags: Flexible content tagging system
  • Users: Custom email-based authentication
  • Orders: Purchase tracking for paid content

API Features

  • JWT Authentication: Secure token-based authentication
  • Access Control: Different permission levels for content
  • Pagination: Handle large datasets efficiently
  • Filtering & Search: Find content easily
  • CORS Support: Enable frontend integration

Security Features

  • Content Protection: Paid content only accessible to purchasers
  • Token Refresh: Secure session management
  • Permission Classes: Granular access control
  • Input Validation: Secure data handling

Prerequisites

Before starting, ensure you have:

  • Python 3.8+ installed on your system
  • Basic Django knowledge (models, views, URLs)
  • Understanding of REST principles (HTTP methods, status codes)
  • Familiarity with JSON and API concepts
  • Command line experience for running commands
  • Git for version control
  • Virtual environments in Python
  • Database basics (we'll use SQLite for development)
  • Postman or curl for API testing

Setting Up the Environment

1. Create Project Directory

mkdir django-blog-api
cd django-blog-api

2. Set Up Virtual Environment

# Create virtual environment
python -m venv .venv

# Activate virtual environment
# On Linux/Mac:
source .venv/bin/activate
# On Windows:
.venv\Scripts\activate

3. Create Django Project

# Install Django
pip install django

# Create project
django-admin startproject blogapi .

# Create necessary apps
python manage.py startapp accounts
python manage.py startapp blog
python manage.py startapp orders
python manage.py startapp api

4. Project Structure

Your project should look like this:

django-blog-api/
├── .venv/
├── blogapi/
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── accounts/
├── blog/
├── orders/
├── api/
├── manage.py
└── requirements.txt

Installing Dependencies

1. Install Required Packages

pip install djangorestframework
pip install django-cors-headers
pip install djangorestframework-simplejwt
pip install pillow  # For image handling

2. Create requirements.txt

pip freeze > requirements.txt

Your requirements.txt should include:

Django==5.0.1
djangorestframework==3.16.1
django-cors-headers==4.9.0
djangorestframework-simplejwt==5.5.1
pillow==10.2.0

Configuring Django Settings

1. Update settings.py

# blogapi/settings.py
from pathlib import Path
from datetime import timedelta
import os

BASE_DIR = Path(__file__).resolve().parent.parent

# Security settings
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'your-secret-key-here')
DEBUG = True
ALLOWED_HOSTS = ['localhost', '127.0.0.1', 'your-domain.com']

# Application definition
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    # Third party apps
    'rest_framework',
    'corsheaders',
    'rest_framework_simplejwt',

    # Local apps
    'accounts',
    'blog',
    'orders',
    'api',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'corsheaders.middleware.CorsMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'blogapi.urls'

# Templates
TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

# Database
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
}

# Custom User Model
AUTH_USER_MODEL = 'accounts.User'

# Internationalization
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True

# Static files
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'

# Django REST Framework Configuration
REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework_simplejwt.authentication.JWTAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ],
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.IsAuthenticated',
    ],
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
    'DEFAULT_FILTER_BACKENDS': [
        'rest_framework.filters.SearchFilter',
        'rest_framework.filters.OrderingFilter',
    ],
    'DEFAULT_RENDERER_CLASSES': [
        'rest_framework.renderers.JSONRenderer',
    ],
}

# JWT Settings
SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
}

# CORS Settings
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",  # React frontend
    "http://127.0.0.1:3000",
    "http://localhost:8080",  # Vue frontend
]

CORS_ALLOW_ALL_ORIGINS = DEBUG  # Only for development

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

Understanding the Models

Let's create the data models that will power our API.

1. Custom User Model (accounts/models.py)

# accounts/models.py
from django.db import models
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin


class CustomUserManager(BaseUserManager):
    """Custom user manager where email is the unique identifier."""

    def create_user(self, email, password=None, **extra_fields):
        if not email:
            raise ValueError('The Email field must be set')
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email, password=None, **extra_fields):
        extra_fields.setdefault('is_staff', True)
        extra_fields.setdefault('is_superuser', True)
        extra_fields.setdefault('is_active', True)

        if extra_fields.get('is_staff') is not True:
            raise ValueError('Superuser must have is_staff=True.')
        if extra_fields.get('is_superuser') is not True:
            raise ValueError('Superuser must have is_superuser=True.')

        return self.create_user(email, password, **extra_fields)


class User(AbstractBaseUser, PermissionsMixin):
    """Custom user model with email as the unique identifier."""

    email = models.EmailField(unique=True)
    full_name = models.CharField(max_length=255, blank=True)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    date_joined = models.DateTimeField(auto_now_add=True)

    objects = CustomUserManager()

    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    class Meta:
        verbose_name = 'user'
        verbose_name_plural = 'users'

    def __str__(self):
        return self.email

    def get_full_name(self):
        return self.full_name or self.email

2. Blog Models (blog/models.py)

# blog/models.py
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.text import slugify
from django.conf import settings


class Category(models.Model):
    """Category model with parent/child relationships."""

    name = models.CharField(max_length=100)
    slug = models.SlugField(max_length=100, unique=True, blank=True)
    parent = models.ForeignKey(
        'self', 
        on_delete=models.CASCADE, 
        null=True, 
        blank=True, 
        related_name='subcategories'
    )
    order = models.PositiveIntegerField(default=0)
    is_active = models.BooleanField(default=True)

    class Meta:
        verbose_name_plural = 'Categories'
        ordering = ['order', 'name']

    def __str__(self):
        if self.parent:
            return f"{self.parent.name} > {self.name}"
        return self.name

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)


class Tag(models.Model):
    """Tag model for categorizing blog posts."""

    name = models.CharField(max_length=50, unique=True)
    slug = models.SlugField(max_length=50, unique=True, blank=True)
    color = models.CharField(max_length=20, default='indigo')

    class Meta:
        ordering = ['name']

    def __str__(self):
        return self.name

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)


class Post(models.Model):
    """Blog post model with access control."""

    POST_TYPE_CHOICES = [
        ('tutorial', 'Tutorial'),
        ('article', 'Article'),
    ]

    SCHEMA_TYPE_CHOICES = [
        ('Article', 'Article'),
        ('BlogPosting', 'Blog Post'),
        ('TechArticle', 'Technical Article'),
        ('Tutorial', 'Tutorial'),
        ('HowTo', 'How-To Guide'),
    ]

    # Basic fields
    title = models.CharField(max_length=255)
    slug = models.SlugField(max_length=255, unique=True, blank=True)
    excerpt = models.TextField(max_length=500, help_text='Short summary for cards and SEO')
    content = models.TextField(help_text='Write in Markdown format')

    # SEO fields
    seo_title = models.CharField(max_length=60, blank=True, null=True)
    meta_description = models.TextField(max_length=160, blank=True, null=True)
    meta_keywords = models.CharField(max_length=255, blank=True, null=True)
    focus_keyword = models.CharField(max_length=100, blank=True, null=True)
    canonical_url = models.URLField(blank=True, null=True)
    og_image_alt = models.CharField(max_length=125, blank=True, null=True)
    schema_type = models.CharField(
        max_length=50, 
        choices=SCHEMA_TYPE_CHOICES, 
        default='BlogPosting'
    )
    reading_time_override = models.PositiveIntegerField(blank=True, null=True)

    # Media
    cover_image = models.ImageField(upload_to='blog/', blank=True, null=True)

    # Publishing
    is_published = models.BooleanField(default=False)
    published_at = models.DateTimeField(blank=True, null=True)
    is_featured = models.BooleanField(default=False)

    # Access control
    is_free = models.BooleanField(default=True)
    price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)

    # Type and organization
    post_type = models.CharField(max_length=20, choices=POST_TYPE_CHOICES, default='tutorial')
    category = models.ForeignKey(
        Category, 
        on_delete=models.SET_NULL, 
        null=True, 
        blank=True, 
        related_name='posts'
    )

    # Relations
    tags = models.ManyToManyField(Tag, blank=True, related_name='posts')
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL, 
        on_delete=models.SET_NULL, 
        null=True, 
        blank=True,
        related_name='blog_posts'
    )

    # Timestamps
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ['-published_at', '-created_at']

    def __str__(self):
        return self.title

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.title)
        if self.is_published and not self.published_at:
            self.published_at = timezone.now()
        super().save(*args, **kwargs)

    @property
    def reading_time(self):
        """Estimate reading time in minutes."""
        if self.reading_time_override:
            return self.reading_time_override
        word_count = len(self.content.split())
        minutes = max(1, round(word_count / 200))
        return minutes

    @property
    def get_seo_title(self):
        """Return SEO title or fallback to main title."""
        return self.seo_title or self.title

    @property
    def get_meta_description(self):
        """Return meta description or fallback to excerpt."""
        return self.meta_description or self.excerpt

3. Order Model (orders/models.py)

# orders/models.py
from decimal import Decimal
from django.conf import settings
from django.db import models
from django.utils import timezone


class Order(models.Model):
    """Order model for tracking purchases."""

    STATUS_PENDING = 'pending'
    STATUS_PAID = 'paid'
    STATUS_FAILED = 'failed'
    STATUS_REFUNDED = 'refunded'
    STATUS_CANCELLED = 'cancelled'

    STATUS_CHOICES = [
        (STATUS_PENDING, 'Pending'),
        (STATUS_PAID, 'Paid'),
        (STATUS_FAILED, 'Failed'),
        (STATUS_REFUNDED, 'Refunded'),
        (STATUS_CANCELLED, 'Cancelled'),
    ]

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name='orders'
    )
    post = models.ForeignKey(
        'blog.Post',
        on_delete=models.CASCADE,
        null=True,
        blank=True,
        related_name='orders'
    )

    order_number = models.CharField(max_length=20, unique=True, blank=True)
    status = models.CharField(max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING)

    # Financial details
    total_amount = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))

    # Timestamps
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    paid_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        return f"Order {self.order_number} - {self.user.email}"

    def save(self, *args, **kwargs):
        if not self.order_number:
            self.order_number = f"ORD-{timezone.now().strftime('%Y%m%d%H%M%S')}"
        if self.status == self.STATUS_PAID and not self.paid_at:
            self.paid_at = timezone.now()
        super().save(*args, **kwargs)

Creating API Serializers

Serializers convert Django model instances to JSON and vice versa.

1. Create api/serializers.py

# api/serializers.py
from rest_framework import serializers
from blog.models import Post, Category, Tag
from orders.models import Order
from django.contrib.auth import get_user_model

User = get_user_model()


class CategorySerializer(serializers.ModelSerializer):
    """Serializer for Category model"""
    subcategories = serializers.SerializerMethodField()
    posts_count = serializers.SerializerMethodField()

    class Meta:
        model = Category
        fields = ['id', 'name', 'slug', 'parent', 'subcategories', 'posts_count', 'is_active']

    def get_subcategories(self, obj):
        if obj.parent is None and obj.subcategories.exists():
            return CategorySerializer(obj.subcategories.filter(is_active=True), many=True).data
        return []

    def get_posts_count(self, obj):
        return obj.posts.filter(is_published=True).count()


class TagSerializer(serializers.ModelSerializer):
    """Serializer for Tag model"""
    posts_count = serializers.SerializerMethodField()

    class Meta:
        model = Tag
        fields = ['id', 'name', 'slug', 'color', 'posts_count']

    def get_posts_count(self, obj):
        return obj.posts.filter(is_published=True).count()


class AuthorSerializer(serializers.ModelSerializer):
    """Serializer for User model (as author)"""

    class Meta:
        model = User
        fields = ['id', 'email', 'full_name']


class PostListSerializer(serializers.ModelSerializer):
    """Serializer for Post list view"""
    category = CategorySerializer(read_only=True)
    tags = TagSerializer(many=True, read_only=True)
    author = AuthorSerializer(read_only=True)
    reading_time = serializers.ReadOnlyField()
    is_accessible = serializers.SerializerMethodField()

    class Meta:
        model = Post
        fields = [
            'id', 'title', 'slug', 'excerpt', 'cover_image',
            'published_at', 'reading_time', 'is_featured', 'is_free', 'price',
            'post_type', 'category', 'tags', 'author', 'is_accessible'
        ]

    def get_is_accessible(self, obj):
        """Check if user can access this post"""
        request = self.context.get('request')
        if not request:
            return obj.is_free

        # If post is free, everyone can access
        if obj.is_free:
            return True

        # If user is not authenticated, only free posts
        if not request.user.is_authenticated:
            return False

        # Check if user has paid for this post
        return Order.objects.filter(
            user=request.user,
            post=obj,
            status='paid'
        ).exists()


class PostDetailSerializer(serializers.ModelSerializer):
    """Serializer for Post detail view"""
    category = CategorySerializer(read_only=True)
    tags = TagSerializer(many=True, read_only=True)
    author = AuthorSerializer(read_only=True)
    reading_time = serializers.ReadOnlyField()
    get_seo_title = serializers.ReadOnlyField()
    get_meta_description = serializers.ReadOnlyField()
    content = serializers.SerializerMethodField()
    is_accessible = serializers.SerializerMethodField()

    class Meta:
        model = Post
        fields = [
            'id', 'title', 'slug', 'excerpt', 'content', 'cover_image',
            'published_at', 'updated_at', 'reading_time', 'is_featured',
            'is_free', 'price', 'post_type', 'category', 'tags', 'author',
            'get_seo_title', 'get_meta_description', 'is_accessible'
        ]

    def get_content(self, obj):
        """Return content only if user has access"""
        request = self.context.get('request')

        # If post is free, return content
        if obj.is_free:
            return obj.content

        # If user is not authenticated, return None
        if not request or not request.user.is_authenticated:
            return None

        # Check if user has paid for this post
        has_access = Order.objects.filter(
            user=request.user,
            post=obj,
            status='paid'
        ).exists()

        if has_access:
            return obj.content
        else:
            # Return a teaser
            return obj.content[:500] + "... [Content locked - Purchase required]"

    def get_is_accessible(self, obj):
        """Check if user can access this post"""
        request = self.context.get('request')
        if not request:
            return obj.is_free

        if obj.is_free:
            return True

        if not request.user.is_authenticated:
            return False

        return Order.objects.filter(
            user=request.user,
            post=obj,
            status='paid'
        ).exists()

Building API Views with Access Control

1. Create api/views.py

# api/views.py
from rest_framework import generics, permissions
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from rest_framework.views import APIView
from django.shortcuts import get_object_or_404
from django.db.models import Q
from blog.models import Post, Category, Tag
from orders.models import Order
from .serializers import (
    PostListSerializer, PostDetailSerializer,
    CategorySerializer, TagSerializer
)


class FreePostListView(generics.ListAPIView):
    """List all free blog posts - accessible to everyone"""
    serializer_class = PostListSerializer
    permission_classes = [permissions.AllowAny]
    filterset_fields = ['category', 'post_type', 'is_featured']
    search_fields = ['title', 'excerpt', 'content']
    ordering_fields = ['published_at', 'title', 'reading_time']
    ordering = ['-published_at']

    def get_queryset(self):
        return Post.objects.filter(
            is_published=True,
            is_free=True
        ).select_related('author', 'category').prefetch_related('tags')


class FreePostDetailView(generics.RetrieveAPIView):
    """Retrieve a free blog post - accessible to everyone"""
    serializer_class = PostDetailSerializer
    permission_classes = [permissions.AllowAny]
    lookup_field = 'slug'

    def get_queryset(self):
        return Post.objects.filter(
            is_published=True,
            is_free=True
        ).select_related('author', 'category').prefetch_related('tags')


class PaidPostListView(generics.ListAPIView):
    """List all paid blog posts - content access controlled"""
    serializer_class = PostListSerializer
    permission_classes = [permissions.AllowAny]
    filterset_fields = ['category', 'post_type', 'price']
    search_fields = ['title', 'excerpt']
    ordering_fields = ['published_at', 'title', 'price', 'reading_time']
    ordering = ['-published_at']

    def get_queryset(self):
        return Post.objects.filter(
            is_published=True,
            is_free=False
        ).select_related('author', 'category').prefetch_related('tags')


class PaidPostDetailView(generics.RetrieveAPIView):
    """Retrieve a paid blog post - content access controlled"""
    serializer_class = PostDetailSerializer
    permission_classes = [permissions.AllowAny]
    lookup_field = 'slug'

    def get_queryset(self):
        return Post.objects.filter(
            is_published=True,
            is_free=False
        ).select_related('author', 'category').prefetch_related('tags')


class AllPostListView(generics.ListAPIView):
    """List all blog posts (free and paid) - content access controlled"""
    serializer_class = PostListSerializer
    permission_classes = [permissions.AllowAny]
    filterset_fields = ['category', 'post_type', 'is_free', 'is_featured']
    search_fields = ['title', 'excerpt']
    ordering_fields = ['published_at', 'title', 'price', 'reading_time']
    ordering = ['-published_at']

    def get_queryset(self):
        return Post.objects.filter(
            is_published=True
        ).select_related('author', 'category').prefetch_related('tags')


class CategoryListView(generics.ListAPIView):
    """List all active categories"""
    serializer_class = CategorySerializer
    permission_classes = [permissions.AllowAny]

    def get_queryset(self):
        return Category.objects.filter(is_active=True).prefetch_related('subcategories')


class TagListView(generics.ListAPIView):
    """List all tags"""
    serializer_class = TagSerializer
    permission_classes = [permissions.AllowAny]

    def get_queryset(self):
        return Tag.objects.all()


@api_view(['GET'])
@permission_classes([permissions.AllowAny])
def post_search(request):
    """Search posts by title, excerpt, or content"""
    query = request.GET.get('q', '')
    post_type = request.GET.get('type', '')

    if not query:
        return Response({'results': []})

    posts = Post.objects.filter(is_published=True)

    if post_type == 'free':
        posts = posts.filter(is_free=True)
    elif post_type == 'paid':
        posts = posts.filter(is_free=False)

    posts = posts.filter(
        Q(title__icontains=query) |
        Q(excerpt__icontains=query) |
        Q(content__icontains=query)
    ).select_related('author', 'category').prefetch_related('tags')[:20]

    serializer = PostListSerializer(posts, many=True, context={'request': request})
    return Response({'results': serializer.data})


@api_view(['GET'])
@permission_classes([permissions.AllowAny])
def featured_posts(request):
    """Get featured posts"""
    posts = Post.objects.filter(
        is_published=True,
        is_featured=True
    ).select_related('author', 'category').prefetch_related('tags')[:10]

    serializer = PostListSerializer(posts, many=True, context={'request': request})
    return Response({'results': serializer.data})

Implementing JWT Authentication

1. Create api/auth_views.py

# api/auth_views.py
from rest_framework import status, serializers
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
from django.contrib.auth import authenticate
from django.contrib.auth import get_user_model

User = get_user_model()


class UserRegistrationSerializer(serializers.ModelSerializer):
    """Serializer for user registration"""
    password = serializers.CharField(write_only=True, min_length=8)
    password_confirm = serializers.CharField(write_only=True)

    class Meta:
        model = User
        fields = ['email', 'password', 'password_confirm', 'full_name']

    def validate(self, attrs):
        if attrs['password'] != attrs['password_confirm']:
            raise serializers.ValidationError("Passwords don't match")
        return attrs

    def create(self, validated_data):
        validated_data.pop('password_confirm')
        user = User.objects.create_user(**validated_data)
        return user


class UserSerializer(serializers.ModelSerializer):
    """Serializer for user data"""

    class Meta:
        model = User
        fields = ['id', 'email', 'full_name', 'date_joined']


@api_view(['POST'])
@permission_classes([AllowAny])
def register(request):
    """Register a new user"""
    serializer = UserRegistrationSerializer(data=request.data)
    if serializer.is_valid():
        user = serializer.save()

        # Generate tokens
        refresh = RefreshToken.for_user(user)

        return Response({
            'user': UserSerializer(user).data,
            'tokens': {
                'refresh': str(refresh),
                'access': str(refresh.access_token),
            }
        }, status=status.HTTP_201_CREATED)

    return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)


@api_view(['POST'])
@permission_classes([AllowAny])
def login(request):
    """Login user and return JWT tokens"""
    email = request.data.get('email')
    password = request.data.get('password')

    if not email or not password:
        return Response({
            'error': 'Email and password are required'
        }, status=status.HTTP_400_BAD_REQUEST)

    user = authenticate(request, username=email, password=password)

    if user:
        refresh = RefreshToken.for_user(user)

        return Response({
            'user': UserSerializer(user).data,
            'tokens': {
                'refresh': str(refresh),
                'access': str(refresh.access_token),
            }
        })

    return Response({
        'error': 'Invalid credentials'
    }, status=status.HTTP_401_UNAUTHORIZED)


@api_view(['POST'])
@permission_classes([AllowAny])
def refresh_token(request):
    """Refresh JWT token"""
    refresh_token = request.data.get('refresh')

    if not refresh_token:
        return Response({
            'error': 'Refresh token is required'
        }, status=status.HTTP_400_BAD_REQUEST)

    try:
        refresh = RefreshToken(refresh_token)
        return Response({
            'access': str(refresh.access_token)
        })
    except Exception:
        return Response({
            'error': 'Invalid refresh token'
        }, status=status.HTTP_401_UNAUTHORIZED)

URL Configuration

1. Create api/urls.py

# api/urls.py
from django.urls import path
from . import views, auth_views

app_name = 'api'

urlpatterns = [
    # Authentication endpoints
    path('auth/register/', auth_views.register, name='register'),
    path('auth/login/', auth_views.login, name='login'),
    path('auth/refresh/', auth_views.refresh_token, name='refresh_token'),

    # Free blog posts
    path('posts/free/', views.FreePostListView.as_view(), name='free_posts'),
    path('posts/free/<slug:slug>/', views.FreePostDetailView.as_view(), name='free_post_detail'),

    # Paid blog posts
    path('posts/paid/', views.PaidPostListView.as_view(), name='paid_posts'),
    path('posts/paid/<slug:slug>/', views.PaidPostDetailView.as_view(), name='paid_post_detail'),

    # All blog posts
    path('posts/', views.AllPostListView.as_view(), name='all_posts'),

    # Categories and tags
    path('categories/', views.CategoryListView.as_view(), name='categories'),
    path('tags/', views.TagListView.as_view(), name='tags'),

    # Search and special endpoints
    path('posts/search/', views.post_search, name='post_search'),
    path('posts/featured/', views.featured_posts, name='featured_posts'),
]

2. Update main urls.py

# blogapi/urls.py
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/v1/', include('api.urls')),
]

# Serve media files in development
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Testing the API

1. Run Migrations

# Create and apply migrations
python manage.py makemigrations accounts
python manage.py makemigrations blog
python manage.py makemigrations orders
python manage.py migrate

2. Create Superuser

python manage.py createsuperuser

3. Start Development Server

python manage.py runserver

4. Test Endpoints

Test Free Posts

curl -X GET "http://localhost:8000/api/v1/posts/free/" \
  -H "Accept: application/json"

Test User Registration

curl -X POST "http://localhost:8000/api/v1/auth/register/" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "test@example.com",
    "password": "securepassword123",
    "password_confirm": "securepassword123",
    "full_name": "Test User"
  }'

Test User Login

curl -X POST "http://localhost:8000/api/v1/auth/login/" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "test@example.com",
    "password": "securepassword123"
  }'

5. Create Test Data

Create some blog posts through Django admin or create a management command:

# blog/management/commands/create_sample_data.py
from django.core.management.base import BaseCommand
from blog.models import Category, Tag, Post
from accounts.models import User

class Command(BaseCommand):
    def handle(self, *args, **options):
        # Create categories
        web_dev = Category.objects.create(name="Web Development", slug="web-development")
        django_cat = Category.objects.create(
            name="Django", 
            slug="django", 
            parent=web_dev
        )

        # Create tags
        django_tag = Tag.objects.create(name="Django", color="green")
        python_tag = Tag.objects.create(name="Python", color="blue")

        # Get or create author
        author, _ = User.objects.get_or_create(
            email="author@example.com",
            defaults={'full_name': 'Blog Author'}
        )

        # Create free post
        free_post = Post.objects.create(
            title="Getting Started with Django",
            slug="getting-started-django",
            excerpt="Learn the basics of Django web framework",
            content="# Getting Started with Django\n\nDjango is awesome...",
            is_published=True,
            is_free=True,
            category=django_cat,
            author=author
        )
        free_post.tags.add(django_tag, python_tag)

        # Create paid post
        paid_post = Post.objects.create(
            title="Advanced Django Patterns",
            slug="advanced-django-patterns",
            excerpt="Master advanced Django development",
            content="# Advanced Django Patterns\n\nThis covers advanced topics...",
            is_published=True,
            is_free=False,
            price=29.99,
            category=django_cat,
            author=author
        )
        paid_post.tags.add(django_tag)

        self.stdout.write("Sample data created successfully!")

Run the command:

python manage.py create_sample_data

Production Deployment

1. Environment Variables

Create a .env file:

DJANGO_SECRET_KEY=your-super-secret-key-here
DJANGO_DEBUG=False
DJANGO_ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
DATABASE_URL=postgres://user:pass@localhost/dbname

2. Update Settings for Production

# blogapi/settings.py
import os
from dotenv import load_dotenv

load_dotenv()

# Security settings
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY')
DEBUG = os.getenv('DJANGO_DEBUG', 'False').lower() in ('true', '1')
ALLOWED_HOSTS = os.getenv('DJANGO_ALLOWED_HOSTS', '').split(',')

# Database
if os.getenv('DATABASE_URL'):
    import dj_database_url
    DATABASES = {
        'default': dj_database_url.parse(os.getenv('DATABASE_URL'))
    }

# Static files for production
STATIC_ROOT = BASE_DIR / 'staticfiles'

# CORS settings for production
CORS_ALLOWED_ORIGINS = [
    "https://yourdomain.com",
    "https://www.yourdomain.com",
]
CORS_ALLOW_ALL_ORIGINS = DEBUG

3. Requirements for Production

# requirements.txt
Django==5.0.1
djangorestframework==3.16.1
django-cors-headers==4.9.0
djangorestframework-simplejwt==5.5.1
pillow==10.2.0
python-dotenv==1.0.0
dj-database-url==2.1.0
psycopg2-binary==2.9.9
gunicorn==21.2.0

4. Deploy to Production

# Collect static files
python manage.py collectstatic --noinput

# Run with Gunicorn
gunicorn blogapi.wsgi:application --bind 0.0.0.0:8000

Best Practices

1. Security

  • Always use HTTPS in production
  • Keep SECRET_KEY secure and random
  • Use environment variables for sensitive data
  • Implement rate limiting
  • Validate all user inputs
  • Use strong password policies

2. Performance

  • Use select_related() and prefetch_related() for database optimization
  • Implement caching for frequently accessed data
  • Use database indexes on filtered fields
  • Optimize image sizes and formats
  • Monitor API response times

3. Code Organization

  • Keep serializers focused and simple
  • Use custom permissions for complex access control
  • Write comprehensive tests
  • Document your API endpoints
  • Use consistent naming conventions

4. API Design

  • Follow RESTful conventions
  • Use appropriate HTTP status codes
  • Implement proper pagination
  • Provide meaningful error messages
  • Version your APIs

Troubleshooting

Common Issues

1. CORS Errors

# Add to settings.py
CORS_ALLOWED_ORIGINS = [
    "http://localhost:3000",
    "https://yourdomain.com",
]

2. JWT Token Issues

# Check token in request headers
Authorization: Bearer your_jwt_token_here

3. Database Errors

# Reset migrations if needed
python manage.py migrate --fake-initial

4. Import Errors

# Check app registration in INSTALLED_APPS
INSTALLED_APPS = [
    # ... other apps
    'api',
]

Debugging Tips

  1. Use Django Debug Toolbar for development
  2. Check server logs for detailed error information
  3. Test with curl or Postman before frontend integration
  4. Use Django shell to test model queries
  5. Enable detailed logging for production issues

Conclusion

Congratulations! You've built a comprehensive Django REST API with:

JWT Authentication - Secure token-based authentication
Access Control - Different permission levels for content
Custom User Model - Email-based authentication
Content Management - Blog posts with categories and tags
Purchase System - Paid content with access control
Production Ready - CORS, security, and deployment configuration

What You've Learned

  1. Django REST Framework fundamentals
  2. JWT authentication implementation
  3. Custom serializers with access control
  4. Permission classes and security
  5. Database relationships and optimization
  6. API design best practices
  7. Production deployment considerations

Next Steps

  • Add email verification for user registration
  • Implement password reset functionality
  • Add payment processing for paid content
  • Create API documentation with tools like Swagger
  • Add comprehensive testing with unit and integration tests
  • Implement caching for better performance
  • Add API versioning for future updates

Resources

This tutorial provides a solid foundation for building production-ready APIs. You can extend this codebase for various applications like e-commerce platforms, content management systems, or mobile app backends.

Happy coding!


About the Author: This tutorial was created by the Amstack development team. For more Django tutorials and web development resources, visit our blog.

Last Updated: January 2026
Django Version: 5.0+
Python Version: 3.8+

More Tutorials