Building a Secure JWT Authentication System with Django

I recently implemented a custom authentication system for my Django project, Shopease-API, using JSON Web Tokens (JWT). The system includes: Email verification at signup to ensure that email addresses are unique and valid. Secure login with access and refresh tokens for seamless user sessions. Token-based authentication using rest_framework_simplejwt for robust security. In this post, I’ll walk you through the step-by-step process of building this system, from setting up the custom user model to creating the authentication endpoints. Whether you’re a developer diving into the Django REST Framework, this journey offers valuable insights and practical tips! Why Choose JWT for Shopease-API? Shopease-API is an e-commerce platform I’m building using Django, designed to handle user authentication, product listings, carts, and orders. I opted for JWT for its stateless nature, scalability, and compatibility with modern APIs. My specific needs included: Email-based authentication: Users sign in with their email and password, eliminating the need for usernames. Email verification: This enforces unique email addresses and validates user input. Secure token management: Access tokens are used for short-term authentication, while refresh tokens allow for session renewal. Logout with token blacklisting: This feature prevents the reuse of tokens. Auto-login after signup: To ensure a frictionless user experience. I chose rest_framework_simplejwt for its reliable JWT implementation, which includes token blacklisting. The authentication system is integrated within a modular users' Django app, keeping the codebase clean and maintainable. The Authentication Flow Here’s how the Shopease-API authentication system works: Signup (/api/auth/register/): Users register with an email and password. The system validates the email for uniqueness, hashes the password, and returns access and refresh tokens for auto-login. Login (/api/auth/login/): Users authenticate with email and password, receiving new access and refresh tokens. Logout (/api/auth/logout/): Users send their refresh token to blacklist it, requiring an access token in the header for authentication. Token Refresh (/api/auth/token/refresh/): Users refresh their access token using the refresh token Step-by-Step Implementation Let’s break down the code, organized in the user's app. Step 1: Custom User Model (users/models.py) I created a CustomUser model using Django’s AbstractBaseUser to support email-based authentication. from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager from django.db import models from django.utils import timezone class CustomUserManager(BaseUserManager): def create_user(self, email, password=None, **extra_fields): if not email: raise ValueError("Email must be provided") 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) return self.create_user(email, password, **extra_fields) class CustomUser(AbstractBaseUser, PermissionsMixin): email = models.EmailField(unique=True) is_active = models.BooleanField(default=True) is_staff = models.BooleanField(default=False) date_joined = models.DateTimeField(default=timezone.now) objects = CustomUserManager() USERNAME_FIELD = 'email' REQUIRED_FIELDS = [] def __str__(self): return self.email Key Features: email is the unique identifier (USERNAME_FIELD). unique=True ensures email uniqueness at the database level. set_password hashes passwords securely. Step 2: Serializers (users/serializers.py) Serializers handle data validation, including email verification. from rest_framework import serializers from .models import CustomUser “ from rest_framework import serializers from .models import CustomUser from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from django.core.validators import EmailValidator from django.core.exceptions import ValidationError class RegisterSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True, min_length=8) class Meta: model = CustomUser fields = ['email', 'password'] def validate_email(self, value): # Validate email format validator = EmailValidator() try: validator(value) except ValidationError: raise serializers.ValidationError("Invalid email format") # Check email uniqueness if CustomUser.objects.filter(email=value).exists(): raise serializers.ValidationError("Email already exists") return value def create(self, validated_dat

Apr 30, 2025 - 08:06
 0
Building a Secure JWT Authentication System with Django

I recently implemented a custom authentication system for my Django project, Shopease-API, using JSON Web Tokens (JWT).

The system includes:

  • Email verification at signup to ensure that email addresses are unique and valid.
  • Secure login with access and refresh tokens for seamless user sessions.
  • Token-based authentication using rest_framework_simplejwt for robust security.

In this post, I’ll walk you through the step-by-step process of building this system, from setting up the custom user model to creating the authentication endpoints. Whether you’re a developer diving into the Django REST Framework, this journey offers valuable insights and practical tips!

Why Choose JWT for Shopease-API?

Shopease-API is an e-commerce platform I’m building using Django, designed to handle user authentication, product listings, carts, and orders. I opted for JWT for its stateless nature, scalability, and compatibility with modern APIs. My specific needs included:

  • Email-based authentication: Users sign in with their email and password, eliminating the need for usernames.
  • Email verification: This enforces unique email addresses and validates user input.
  • Secure token management: Access tokens are used for short-term authentication, while refresh tokens allow for session renewal.
  • Logout with token blacklisting: This feature prevents the reuse of tokens.
  • Auto-login after signup: To ensure a frictionless user experience.

I chose rest_framework_simplejwt for its reliable JWT implementation, which includes token blacklisting. The authentication system is integrated within a modular users' Django app, keeping the codebase clean and maintainable.

The Authentication Flow

Here’s how the Shopease-API authentication system works:

  • Signup (/api/auth/register/): Users register with an email and password. The system validates the email for uniqueness, hashes the password, and returns access and refresh tokens for auto-login.
  • Login (/api/auth/login/): Users authenticate with email and password, receiving new access and refresh tokens.
  • Logout (/api/auth/logout/): Users send their refresh token to blacklist it, requiring an access token in the header for authentication.
  • Token Refresh (/api/auth/token/refresh/): Users refresh their access token using the refresh token

authentication flow

Step-by-Step Implementation

Let’s break down the code, organized in the user's app.

Step 1: Custom User Model (users/models.py)

I created a CustomUser model using Django’s AbstractBaseUser to support email-based authentication.

from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
from django.db import models
from django.utils import timezone

class CustomUserManager(BaseUserManager):
    def create_user(self, email, password=None, **extra_fields):
        if not email:
            raise ValueError("Email must be provided")
        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)
        return self.create_user(email, password, **extra_fields)

class CustomUser(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    date_joined = models.DateTimeField(default=timezone.now)

    objects = CustomUserManager()
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []

    def __str__(self):
        return self.email

Key Features:

  • email is the unique identifier (USERNAME_FIELD).
  • unique=True ensures email uniqueness at the database level.
  • set_password hashes passwords securely.

Step 2: Serializers (users/serializers.py)

Serializers handle data validation, including email verification.

from rest_framework import serializers
from .models import CustomUser
 “

from rest_framework import serializers
from .models import CustomUser
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from django.core.validators import EmailValidator
from django.core.exceptions import ValidationError

class RegisterSerializer(serializers.ModelSerializer):
    password = serializers.CharField(write_only=True, min_length=8)

    class Meta:
        model = CustomUser
        fields = ['email', 'password']

    def validate_email(self, value):
        # Validate email format
        validator = EmailValidator()
        try:
            validator(value)
        except ValidationError:
            raise serializers.ValidationError("Invalid email format")
        # Check email uniqueness
        if CustomUser.objects.filter(email=value).exists():
            raise serializers.ValidationError("Email already exists")
        return value

    def create(self, validated_data):
        return CustomUser.objects.create_user(**validated_data)

class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
    @classmethod
    def get_token(cls, user):
        token = super().get_token(user)
        return token

Key Features:

  • RegisterSerializer validates email format and uniqueness using EmailValidator and a custom check.
  • CustomTokenObtainPairSerializer supports email-based login (leveraging USERNAME_FIELD).

Step 3: Views (users/views.py)

Views define the API endpoints.

from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status, generics
from rest_framework.permissions import IsAuthenticated
from rest_framework_simplejwt.views import TokenObtainPairView
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.exceptions import TokenError
from .models import CustomUser
from .serializers import RegisterSerializer, CustomTokenObtainPairSerializer

class RegisterView(generics.CreateAPIView):
    queryset = CustomUser.objects.all()
    serializer_class = RegisterSerializer

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        user = serializer.save()
        refresh = RefreshToken.for_user(user)
        return Response({
            "user": {"email": user.email},
            "refresh": str(refresh),
            "access": str(refresh.access_token),
        }, status=status.HTTP_201_CREATED)

class CustomTokenObtainPairView(TokenObtainPairView):
    serializer_class = CustomTokenObtainPairSerializer

class LogoutView(APIView):
    permission_classes = [IsAuthenticated]

    def post(self, request):
        try:
            refresh_token = request.data.get("refresh")
            if not refresh_token:
                return Response({"error": "Refresh token is required"}, status=status.HTTP_400_BAD_REQUEST)
            token = RefreshToken(refresh_token)
            token.blacklist()
            return Response({"message": "Successfully logged out"}, status=status.HTTP_205_RESET_CONTENT)
        except TokenError as e:
            return Response({"error": f"Invalid refresh token: {str(e)}"}, status=status.HTTP_400_BAD_REQUEST)

Key Features:

  • RegisterView: Creates users and returns tokens for auto-login.
  • CustomTokenObtainPairView: Handles secure login with tokens.
  • LogoutView: Blacklists refresh tokens, requiring an access token for authentication.

Step 4: URLs (shopease/urls.py)

Include app URLs in the project's urls.py.

from django.contrib import admin
from django.urls import path, include

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

Step 5: URLs (users/urls.py)

Define the API endpoints.

from django.urls import path
from .views import RegisterView, CustomTokenObtainPairView, LogoutView
from rest_framework_simplejwt.views import TokenRefreshView

urlpatterns = [
    path('register/', RegisterView.as_view(), name='register'),
    path('login/', CustomTokenObtainPairView.as_view(), name='login'),
    path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('logout/', LogoutView.as_view(), name='logout'),
]

Step 6: Settings (shopease/settings.py)

Configure JWT and the custom user model.

from datetime import timedelta

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',
    'rest_framework_simplejwt',
    'rest_framework_simplejwt.token_blacklist',
    'users',
]

REST_FRAMEWORK = {
    'DEFAULT_AUTHENTICATION_CLASSES': (
        'rest_framework_simplejwt.authentication.JWTAuthentication',
    ),
}

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=1),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
    'AUTH_HEADER_TYPES': ('Bearer',),
    'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
}

AUTH_USER_MODEL = 'users.CustomUser'

Key Features in Action

  • Email Verification: The RegisterSerializer uses EmailValidator and checks for existing emails, ensuring only valid, unique emails are accepted.

  • Auto-Login: The RegisterView returns tokens immediately after signup, streamlining the user experience.

registeruser

  • Secure Login: The CustomTokenObtainPairView authenticates users and issues access and refresh tokens, with access tokens expiring in 60 minutes for security.

  • Token-Based Auth: SimpleJWT’s JWTAuthentication validates access tokens for protected endpoints like /logout/.

loginUser

  • Logout with Blacklisting: The token_blacklist app ensures refresh tokens are invalidated on logout.

logout

Lessons Learned

  • Token Distinction: Access tokens authenticate requests, while refresh tokens handle session renewal or blacklisting. Mixing them up causes errors like 401.
  • Debugging: Logging request headers and payloads is essential for troubleshooting auth issues.
  • Validation: Combining model-level (unique=True) and serializer-level email checks ensures robust verification.
  • Testing: Early testing with Postman catches bugs before they impact the system.

That's a wrap! I’m a Django developer with a passion for creating scalable APIs. Let’s connect! GitHub