DEV Community

Cover image for Go beyond Django's built-in auth — learn JWT, custom email login, role-based permissions, and brute-force protection.
sribalu
sribalu

Posted on

Go beyond Django's built-in auth — learn JWT, custom email login, role-based permissions, and brute-force protection.

Django Authentication Deep Dive: JWT, Sessions, and Custom Backends

Go beyond Django's built-in auth — learn JWT, custom email login, role-based permissions, and brute-force protection. Intermediate | Read Time: 12 min | Author: [SRI BALU]


Introduction

Authentication is the backbone of almost every web application. Django ships with a solid built-in auth system — but in real-world projects, you'll quickly outgrow it. Whether you're building a REST API, a multi-tenant SaaS, or a social login platform, understanding Django's authentication internals gives you the power to customize it exactly how you need.

In this deep-dive, we'll cover:

  • How Django's authentication system works under the hood
  • Session-based vs JWT-based authentication
  • Implementing JWT authentication with djangorestframework-simplejwt
  • Writing a custom authentication backend (e.g., login with email instead of username)
  • Securing your endpoints with permissions

1. How Django Authentication Works Under the Hood

Before writing any code, it helps to understand what happens when a user logs in.

Django's auth system revolves around three core components:

Component Role
User model Stores credentials (username, password hash)
AuthenticationBackend Validates credentials
Session / Token Maintains state after login

When you call authenticate(request, username=..., password=...), Django loops through all backends listed in AUTHENTICATION_BACKENDS and returns the first User object that matches — or None.

# django/contrib/auth/__init__.py (simplified)
def authenticate(request=None, **credentials):
    for backend_path in settings.AUTHENTICATION_BACKENDS:
        backend = load_backend(backend_path)
        try:
            user = backend.authenticate(request, **credentials)
        except PermissionDenied:
            return None
        if user is None:
            continue
        user.backend = backend_path
        return user
    return None
Enter fullscreen mode Exit fullscreen mode

The default backend is django.contrib.auth.backends.ModelBackend, which checks the database for a matching username + password hash.


2. Session-Based Authentication (The Django Default)

Django's default auth uses server-side sessions. Here's the flow:

User submits login form
    → Django validates credentials
    → Creates a session in the DB (django_session table)
    → Sends a session cookie (sessionid) to the browser
    → On every request, browser sends the cookie
    → Django looks up the session → finds the user
Enter fullscreen mode Exit fullscreen mode

Setting it up (already built-in):

# settings.py
INSTALLED_APPS = [
    'django.contrib.auth',
    'django.contrib.sessions',
    ...
]

MIDDLEWARE = [
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    ...
]
Enter fullscreen mode Exit fullscreen mode

Login/Logout views:

# views.py
from django.contrib.auth import authenticate, login, logout
from django.shortcuts import redirect, render

def login_view(request):
    if request.method == 'POST':
        username = request.POST['username']
        password = request.POST['password']
        user = authenticate(request, username=username, password=password)
        if user is not None:
            login(request, user)
            return redirect('dashboard')
        else:
            return render(request, 'login.html', {'error': 'Invalid credentials'})
    return render(request, 'login.html')

def logout_view(request):
    logout(request)
    return redirect('login')
Enter fullscreen mode Exit fullscreen mode

When to use sessions:

  • Traditional Django web apps (not APIs)
  • Server-rendered templates
  • When you control both frontend and backend

3. JWT Authentication for REST APIs

For Django REST Framework (DRF) APIs — especially those consumed by React, Flutter, or mobile apps — JWT (JSON Web Tokens) is the preferred approach.

Why JWT over sessions for APIs?

Sessions JWT
State Server-side Stateless
Scalability Requires shared DB Works across servers
Mobile support Cookie issues Easy via headers
Logout Simple Requires token blacklist

Installation:

pip install djangorestframework djangorestframework-simplejwt
Enter fullscreen mode Exit fullscreen mode
# settings.py
INSTALLED_APPS = [
    ...
    'rest_framework',
]

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

from datetime import timedelta

SIMPLE_JWT = {
    'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30),
    'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
    'ROTATE_REFRESH_TOKENS': True,
    'BLACKLIST_AFTER_ROTATION': True,
    'ALGORITHM': 'HS256',
    'AUTH_HEADER_TYPES': ('Bearer',),
}
Enter fullscreen mode Exit fullscreen mode

URL configuration:

# urls.py
from rest_framework_simplejwt.views import (
    TokenObtainPairView,
    TokenRefreshView,
    TokenBlacklistView,
)

urlpatterns = [
    path('api/auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
    path('api/auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
    path('api/auth/logout/', TokenBlacklistView.as_view(), name='token_blacklist'),
]
Enter fullscreen mode Exit fullscreen mode

Protecting API views:

# views.py
from rest_framework.permissions import IsAuthenticated
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response

@api_view(['GET'])
@permission_classes([IsAuthenticated])
def profile_view(request):
    user = request.user
    return Response({
        'id': user.id,
        'username': user.username,
        'email': user.email,
    })
Enter fullscreen mode Exit fullscreen mode

How to call the API:

# Step 1: Get tokens
curl -X POST http://localhost:8000/api/auth/login/ \
  -H "Content-Type: application/json" \
  -d '{"username": "john", "password": "secret123"}'

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

# Step 2: Call protected endpoint
curl -X GET http://localhost:8000/api/profile/ \
  -H "Authorization: Bearer eyJ0eXAiOiJKV..."
Enter fullscreen mode Exit fullscreen mode

4. Custom Authentication Backend: Login with Email

By default, Django uses username to authenticate. In modern apps, users expect to log in with their email address. Here's how to write a custom backend:

# backends.py
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth import get_user_model

User = get_user_model()

class EmailBackend(ModelBackend):
    """
    Authenticate using email instead of username.
    Falls back to username if email not found.
    """
    def authenticate(self, request, username=None, password=None, **kwargs):
        try:
            # Try to find user by email
            user = User.objects.get(email=username)
        except User.DoesNotExist:
            # Fall back to username lookup
            try:
                user = User.objects.get(username=username)
            except User.DoesNotExist:
                return None

        if user.check_password(password) and self.user_can_authenticate(user):
            return user
        return None
Enter fullscreen mode Exit fullscreen mode

Register the backend:

# settings.py
AUTHENTICATION_BACKENDS = [
    'myapp.backends.EmailBackend',
    'django.contrib.auth.backends.ModelBackend',  # keep as fallback
]
Enter fullscreen mode Exit fullscreen mode

Now authenticate(request, username="john@example.com", password="secret") will work automatically — no other code changes needed!


5. Custom JWT Payload (Add Extra Claims)

By default, JWT tokens contain only user_id. You can add extra data like role, email, or is_staff:

# serializers.py
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer

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

        # Add custom claims
        token['email'] = user.email
        token['username'] = user.username
        token['is_staff'] = user.is_staff
        token['role'] = getattr(user, 'role', 'user')  # if you have a role field

        return token

# views.py
from rest_framework_simplejwt.views import TokenObtainPairView

class CustomTokenObtainPairView(TokenObtainPairView):
    serializer_class = CustomTokenObtainPairSerializer
Enter fullscreen mode Exit fullscreen mode
# urls.py
path('api/auth/login/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'),
Enter fullscreen mode Exit fullscreen mode

6. Role-Based Permissions

Once authentication is set up, you'll want authorization — controlling what users can do.

Built-in permission classes:

from rest_framework.permissions import (
    IsAuthenticated,       # Must be logged in
    IsAdminUser,           # Must be staff/superuser
    IsAuthenticatedOrReadOnly,  # Read for all, write for authenticated
)
Enter fullscreen mode Exit fullscreen mode

Custom permission class:

# permissions.py
from rest_framework.permissions import BasePermission

class IsOwnerOrAdmin(BasePermission):
    """
    Allow access only to object owners or admin users.
    """
    def has_object_permission(self, request, view, obj):
        if request.user.is_staff:
            return True
        return obj.owner == request.user


# views.py
from rest_framework import generics
from .permissions import IsOwnerOrAdmin
from .models import Post
from .serializers import PostSerializer

class PostDetailView(generics.RetrieveUpdateDestroyAPIView):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    permission_classes = [IsAuthenticated, IsOwnerOrAdmin]
Enter fullscreen mode Exit fullscreen mode

7. Security Best Practices

✅ Always do this:

# settings.py

# Use HTTPS in production
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True

# Prevent XSS from stealing session cookies
SESSION_COOKIE_HTTPONLY = True

# Limit session age
SESSION_COOKIE_AGE = 1209600  # 2 weeks in seconds

# Password validation
AUTH_PASSWORD_VALIDATORS = [
    {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
     'OPTIONS': {'min_length': 8}},
    {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
    {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
Enter fullscreen mode Exit fullscreen mode

✅ Rate-limit login attempts:

pip install django-axes
Enter fullscreen mode Exit fullscreen mode
# settings.py
INSTALLED_APPS = ['axes', ...]
AUTHENTICATION_BACKENDS = [
    'axes.backends.AxesStandaloneBackend',
    'myapp.backends.EmailBackend',
]
AXES_FAILURE_LIMIT = 5       # Lock after 5 failed attempts
AXES_COOLOFF_TIME = 1        # Unlock after 1 hour
Enter fullscreen mode Exit fullscreen mode

Summary

Here's what we covered:

Topic Use Case
Session auth Traditional Django web apps
JWT with simplejwt REST APIs, mobile apps
Custom EmailBackend Login with email
Custom JWT claims Pass role/email in token
Custom permissions Role-based access control
django-axes Brute-force protection

Django's auth system is designed to be extended — not replaced. By understanding how backends, sessions, and tokens work together, you can build secure, scalable authentication for any use case.


What's Next?

  • Part 2: OAuth2 & Social Login with django-allauth (Google, GitHub)
  • Part 3: Two-Factor Authentication (2FA) with TOTP
  • Part 4: Multi-tenant authentication with row-level security

Found this helpful? Share it on Twitter and tag me! Have questions? Drop them in the comments below.


Tags: django python authentication jwt django-rest-framework web-development backend

Top comments (0)