Django Authentication & Authorization

Django Authentication & Authorization

Here’s a mistake I see constantly.

A developer starts a Django project, builds features for weeks, then realizes they need to store additional user data — phone numbers, profile pictures, company affiliations. But they’re using Django’s default User model. Now they're stuck with a messy Profile model hack, or worse, facing a painful migration.

The rule is simple: Always create a custom user model before your first migration.

Further reading: Django User Model

But authentication is more than just the user model. It’s permissions, groups, middleware, and security patterns that protect your application. Django 5.1 introduced LoginRequiredMiddleware — a game-changer for securing views by default.

Today, I’ll show you how to build a production-ready authentication system from scratch.

Why Custom User Models Are Non-Negotiable

Django’s built-in User model has limitations:
Username is required (many apps use email-only auth)
Limited fields (no phone, no profile picture, no custom data)
Changing it later is painful (foreign keys everywhere)

The Django documentation says it clearly:

“If you’re starting a new project, it’s highly recommended to set up a custom user model, even if the default User model is sufficient for you.”

Setting Up a Custom User Model

Step 1: Create the Users App

cd apps
python ../manage.py startapp users

Step 2: Define the Custom User Model

# apps/users/models.py
import uuid
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
from django.db import models
from django.utils import timezone


class UserManager(BaseUserManager):
    """Custom manager for User model with email as the unique identifier."""
    
    def create_user(self, email, password=None, **extra_fields):
        """Create and save a regular user with the given email and password."""
        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):
        """Create and save a superuser with the given email and password."""
        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."""
    
    id = models.UUIDField(
        primary_key=True,
        default=uuid.uuid4,
        editable=False
    )
    email = models.EmailField(
        unique=True,
        max_length=255,
        verbose_name='email address'
    )
    
    # Profile fields
    first_name = models.CharField(max_length=150, blank=True)
    last_name = models.CharField(max_length=150, blank=True)
    phone_number = models.CharField(max_length=20, blank=True)
    avatar = models.ImageField(upload_to='avatars/', null=True, blank=True)
    bio = models.TextField(max_length=500, blank=True)
    
    # Status fields
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    is_verified = models.BooleanField(default=False)
    
    # Timestamps
    date_joined = models.DateTimeField(default=timezone.now)
    last_login = models.DateTimeField(null=True, blank=True)
    updated_at = models.DateTimeField(auto_now=True)
    
    # Settings
    email_notifications = models.BooleanField(default=True)
    
    objects = UserManager()
    
    USERNAME_FIELD = 'email'
    REQUIRED_FIELDS = []  # Email is already required by USERNAME_FIELD
    
    class Meta:
        db_table = 'users'
        verbose_name = 'user'
        verbose_name_plural = 'users'
        indexes = [
            models.Index(fields=['email'], name='user_email_idx'),
            models.Index(fields=['is_active'], name='user_active_idx'),
            models.Index(fields=['date_joined'], name='user_joined_idx'),
        ]
    
    def __str__(self):
        return self.email
    
    def get_full_name(self):
        """Return the first_name plus the last_name, with a space in between."""
        full_name = f'{self.first_name} {self.last_name}'.strip()
        return full_name or self.email
    
    def get_short_name(self):
        """Return the short name for the user."""
        return self.first_name or self.email.split('@')[0]

Step 3: Configure Settings

# config/settings/base.py

# Add to INSTALLED_APPS
LOCAL_APPS = [
    'apps.core',
    'apps.users',  # Must be before django.contrib.admin
]

# Custom user model - MUST be set before first migration
AUTH_USER_MODEL = 'users.User'

Step 4: Create Admin Configuration

# apps/users/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.utils.translation import gettext_lazy as _
from .models import User


@admin.register(User)
class UserAdmin(BaseUserAdmin):
    """Admin configuration for custom User model."""
    
    list_display = ('email', 'first_name', 'last_name', 'is_staff', 'is_active', 'date_joined')
    list_filter = ('is_staff', 'is_superuser', 'is_active', 'is_verified')
    search_fields = ('email', 'first_name', 'last_name')
    ordering = ('-date_joined',)
    
    fieldsets = (
        (None, {'fields': ('email', 'password')}),
        (_('Personal info'), {'fields': ('first_name', 'last_name', 'phone_number', 'avatar', 'bio')}),
        (_('Permissions'), {
            'fields': ('is_active', 'is_staff', 'is_superuser', 'is_verified', 'groups', 'user_permissions'),
        }),
        (_('Settings'), {'fields': ('email_notifications',)}),
        (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
    )
    
    add_fieldsets = (
        (None, {
            'classes': ('wide',),
            'fields': ('email', 'password1', 'password2', 'first_name', 'last_name'),
        }),
    )

Step 5: Run Migrations

python manage.py makemigrations users
python manage.py migrate
python manage.py createsuperuser

Django 5.1’s LoginRequiredMiddleware

Before Django 5.1, you had to decorate every view that required authentication:

# config/settings/base.py
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.auth.middleware.LoginRequiredMiddleware',  # Add this
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

# Where to redirect unauthenticated users
LOGIN_URL = '/auth/login/'
LOGIN_REDIRECT_URL = '/dashboard/'
LOGOUT_REDIRECT_URL = '/'

Django 5.1 introduced LoginRequiredMiddleware — authentication by default:

# config/settings/base.py
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.auth.middleware.LoginRequiredMiddleware',  # Add this
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

# Where to redirect unauthenticated users
LOGIN_URL = '/auth/login/'
LOGIN_REDIRECT_URL = '/dashboard/'
LOGOUT_REDIRECT_URL = '/'

Now all views require authentication by default. To make a view public, use the login_not_required decorator:

from django.contrib.auth.decorators import login_not_required

@login_not_required
def home(request):
    """Public homepage - anyone can access."""
    return render(request, 'home.html')

@login_not_required
def about(request):
    """Public about page."""
    return render(request, 'about.html')

# No decorator needed - requires auth by default
def dashboard(request):
    """Private dashboard - only authenticated users."""
    return render(request, 'dashboard.html')

Class-Based Views

For class-based views, use the decorator on the dispatch method or in URLconf:

from django.contrib.auth.decorators import login_not_required
from django.utils.decorators import method_decorator
from django.views.generic import TemplateView


@method_decorator(login_not_required, name='dispatch')
class HomeView(TemplateView):
    template_name = 'home.html'


# Or in urls.py
from django.contrib.auth.decorators import login_not_required

urlpatterns = [
    path('', login_not_required(HomeView.as_view()), name='home'),
]

Permission System Deep Dive

Django’s permission system has three levels: user permissions, group permissions, and object-level permissions.

Model-Level Permissions

Django automatically creates four permissions for each model:

  • add_<model> — Can add new records
  • change_<model> — Can edit existing records
  • delete_<model> — Can delete records
  • view_<model> — Can view records (Django 2.1+)
# Check permissions
user.has_perm('products.add_product')
user.has_perm('products.change_product')
user.has_perm('products.delete_product')
user.has_perm('products.view_product')

Custom Permissions

Define custom permissions in your model:

# apps/orders/models.py
class Order(models.Model):
    customer = models.ForeignKey('users.User', on_delete=models.CASCADE)
    status = models.CharField(max_length=20)
    total = models.DecimalField(max_digits=10, decimal_places=2)
    
    class Meta:
        permissions = [
            ('can_cancel_order', 'Can cancel orders'),
            ('can_refund_order', 'Can refund orders'),
            ('can_export_orders', 'Can export orders to CSV'),
            ('can_view_all_orders', 'Can view all orders (not just own)'),
        ]
# Check custom permissions
user.has_perm('orders.can_cancel_order')
user.has_perm('orders.can_refund_order')

Using Permissions in Views

from django.contrib.auth.decorators import permission_required

# Single permission
@permission_required('orders.can_export_orders')
def export_orders(request):
    # Only users with can_export_orders permission
    ...

# Multiple permissions (all required)
@permission_required(['orders.can_view_all_orders', 'orders.can_export_orders'])
def export_all_orders(request):
    ...

# With custom redirect
@permission_required('orders.can_refund_order', login_url='/no-access/')
def refund_order(request, order_id):
    ...

Permission Mixins for Class-Based Views

from django.contrib.auth.mixins import PermissionRequiredMixin
from django.views.generic import ListView


class OrderListView(PermissionRequiredMixin, ListView):
    model = Order
    permission_required = 'orders.can_view_all_orders'
    # Or multiple: permission_required = ['orders.view_order', 'orders.can_view_all_orders']


class OrderExportView(PermissionRequiredMixin, View):
    permission_required = 'orders.can_export_orders'
    raise_exception = True  # Return 403 instead of redirecting to login
    
    def get(self, request):
        ...

Groups: Organizing Permissions

Groups let you assign permissions to roles, not individual users.

Creating Groups Programmatically

class Command(BaseCommand):
    help = 'Create default user groups with permissions'
    
    def handle(self, *args, **options):
        # Customer Support group
        support_group, created = Group.objects.get_or_create(name='Customer Support')
        support_permissions = [
            'view_order',
            'can_cancel_order',
        ]
        self._assign_permissions(support_group, Order, support_permissions)
        
        # Finance group
        finance_group, created = Group.objects.get_or_create(name='Finance')
        finance_permissions = [
            'view_order',
            'can_refund_order',
            'can_export_orders',
            'can_view_all_orders',
        ]
        self._assign_permissions(finance_group, Order, finance_permissions)
        
        # Manager group - gets everything
        manager_group, created = Group.objects.get_or_create(name='Manager')
        manager_permissions = [
            'add_order',
            'change_order',
            'delete_order',
            'view_order',
            'can_cancel_order',
            'can_refund_order',
            'can_export_orders',
            'can_view_all_orders',
        ]
        self._assign_permissions(manager_group, Order, manager_permissions)
        
        self.stdout.write(self.style.SUCCESS('Groups created successfully'))
    
    def _assign_permissions(self, group, model, permission_codenames):
        content_type = ContentType.objects.get_for_model(model)
        for codename in permission_codenames:
            try:
                permission = Permission.objects.get(
                    codename=codename,
                    content_type=content_type
                )
                group.permissions.add(permission)
            except Permission.DoesNotExist:
                self.stdout.write(
                    self.style.WARNING(f'Permission {codename} not found')
                )
python manage.py setup_groups

Assigning Users to Groups

from django.contrib.auth.models import Group

# Add user to group
support_group = Group.objects.get(name='Customer Support')
user.groups.add(support_group)

# Check group membership
user.groups.filter(name='Finance').exists()

# Get all user permissions (including from groups)
user.get_all_permissions()

Object-Level Permissions with django-guardian

Model-level permissions answer “Can this user edit ANY order?” Object-level permissions answer “Can this user edit THIS specific order?”

Install django-guardian :

pip install django-guardian
# config/settings/base.py
INSTALLED_APPS += ['guardian']

AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',
    'guardian.backends.ObjectPermissionBackend',
]
python manage.py migrate

Assigning Object Permissions

from guardian.shortcuts import assign_perm, remove_perm, get_perms

# Assign permission on specific object
assign_perm('change_order', user, order)
assign_perm('can_cancel_order', user, order)

# Assign to group
assign_perm('view_order', finance_group, order)

# Remove permission
remove_perm('change_order', user, order)

# Check object permission
user.has_perm('change_order', order)  # Object-level
user.has_perm('orders.change_order')  # Model-level (different!)

# Get all permissions for object
get_perms(user, order)  # ['change_order', 'can_cancel_order']

Automatic Permission Assignment

Assign permissions when objects are created:

# apps/orders/models.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from guardian.shortcuts import assign_perm


class Order(models.Model):
    customer = models.ForeignKey('users.User', on_delete=models.CASCADE)
    # ...


@receiver(post_save, sender=Order)
def assign_order_permissions(sender, instance, created, **kwargs):
    if created:
        # Customer can view and cancel their own order
        assign_perm('view_order', instance.customer, instance)
        assign_perm('can_cancel_order', instance.customer, instance)

Checking Object Permissions in Views

from guardian.decorators import permission_required_or_403

@permission_required_or_403('orders.change_order', (Order, 'pk', 'order_id'))
def edit_order(request, order_id):
    order = get_object_or_404(Order, pk=order_id)
    # User has change_order permission on this specific order
    ...
from guardian.mixins import PermissionRequiredMixin

class OrderUpdateView(PermissionRequiredMixin, UpdateView):
    model = Order
    permission_required = 'orders.change_order'
    return_403 = True
    
    def get_permission_object(self):
        return self.get_object()

Authentication Services Pattern

Keep authentication logic out of views with a service layer:

# apps/users/services.py
from django.contrib.auth import get_user_model
from django.db import transaction
from django.core.mail import send_mail
from django.utils.crypto import get_random_string
from apps.core.exceptions import BusinessLogicError

User = get_user_model()


class AuthService:
    @staticmethod
    @transaction.atomic
    def register_user(email: str, password: str, **kwargs) -> User:
        """Register a new user and send verification email."""
        
        if User.objects.filter(email__iexact=email).exists():
            raise BusinessLogicError('An account with this email already exists.')
        
        user = User.objects.create_user(
            email=email.lower(),
            password=password,
            **kwargs
        )
        
        # Generate verification token
        user.verification_token = get_random_string(64)
        user.save(update_fields=['verification_token'])
        
        # Send verification email (async in production)
        AuthService._send_verification_email(user)
        
        return user
    
    @staticmethod
    def verify_email(token: str) -> User:
        """Verify user's email with token."""
        try:
            user = User.objects.get(verification_token=token)
        except User.DoesNotExist:
            raise BusinessLogicError('Invalid verification token.')
        
        user.is_verified = True
        user.verification_token = ''
        user.save(update_fields=['is_verified', 'verification_token'])
        
        return user
    
    @staticmethod
    def request_password_reset(email: str) -> None:
        """Send password reset email if user exists."""
        try:
            user = User.objects.get(email__iexact=email, is_active=True)
            user.password_reset_token = get_random_string(64)
            user.save(update_fields=['password_reset_token'])
            AuthService._send_password_reset_email(user)
        except User.DoesNotExist:
            # Don't reveal whether email exists
            pass
    
    @staticmethod
    def reset_password(token: str, new_password: str) -> User:
        """Reset password with token."""
        try:
            user = User.objects.get(password_reset_token=token)
        except User.DoesNotExist:
            raise BusinessLogicError('Invalid or expired reset token.')
        
        user.set_password(new_password)
        user.password_reset_token = ''
        user.save()
        
        return user
    
    @staticmethod
    def _send_verification_email(user: User) -> None:
        """Send email verification link."""
        # In production, use Celery for async email
        send_mail(
            subject='Verify your email',
            message=f'Click to verify: https://example.com/verify/{user.verification_token}',
            from_email='noreply@example.com',
            recipient_list=[user.email],
        )
    
    @staticmethod
    def _send_password_reset_email(user: User) -> None:
        """Send password reset link."""
        send_mail(
            subject='Reset your password',
            message=f'Click to reset: https://example.com/reset/{user.password_reset_token}',
            from_email='noreply@example.com',
            recipient_list=[user.email],
        )

Using the Service in Views

# apps/users/views.py
from django.contrib.auth.decorators import login_not_required
from django.shortcuts import render, redirect
from django.contrib import messages
from .services import AuthService
from .forms import RegistrationForm, PasswordResetRequestForm, PasswordResetForm


@login_not_required
def register(request):
    if request.method == 'POST':
        form = RegistrationForm(request.POST)
        if form.is_valid():
            try:
                AuthService.register_user(
                    email=form.cleaned_data['email'],
                    password=form.cleaned_data['password'],
                    first_name=form.cleaned_data.get('first_name', ''),
                    last_name=form.cleaned_data.get('last_name', ''),
                )
                messages.success(request, 'Account created! Check your email to verify.')
                return redirect('login')
            except BusinessLogicError as e:
                messages.error(request, str(e))
    else:
        form = RegistrationForm()
    
    return render(request, 'users/register.html', {'form': form})


@login_not_required
def verify_email(request, token):
    try:
        AuthService.verify_email(token)
        messages.success(request, 'Email verified! You can now log in.')
    except BusinessLogicError as e:
        messages.error(request, str(e))
    
    return redirect('login')

Security Best Practices Checklist

Password Security

# config/settings/base.py

# Strong password validators
AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
        'OPTIONS': {'min_length': 10},  # Increase from default 8
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
    },
]

# Session security
SESSION_COOKIE_AGE = 60 * 60 * 24 * 7  # 1 week
SESSION_COOKIE_SECURE = True  # HTTPS only
SESSION_COOKIE_HTTPONLY = True  # No JavaScript access
SESSION_COOKIE_SAMESITE = 'Lax'  # CSRF protection

# CSRF
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True

Rate Limiting Login Attempts

pip install django-axes
# config/settings/base.py
INSTALLED_APPS += ['axes']

MIDDLEWARE += ['axes.middleware.AxesMiddleware']

AUTHENTICATION_BACKENDS = [
    'axes.backends.AxesStandaloneBackend',
    'django.contrib.auth.backends.ModelBackend',
]

# Axes configuration
AXES_FAILURE_LIMIT = 5  # Lock after 5 failed attempts
AXES_COOLOFF_TIME = 1  # Lock for 1 hour
AXES_LOCKOUT_TEMPLATE = 'users/account_locked.html'
AXES_RESET_ON_SUCCESS = True

Key Takeaways

  1. Always create a custom user model — Do it before your first migration. No exceptions.
  2. Use LoginRequiredMiddleware (Django 5.1+) — Secure by default, explicitly allow public views.
  3. Use groups for role-based access — Don’t assign permissions to individual users.
  4. Consider object-level permissions — When “Can edit ANY order” isn’t granular enough, use django-guardian.
  5. Keep auth logic in services — Views should be thin. Business logic belongs in services.
  6. Implement rate limiting — Protect against brute force attacks with django-axes.
  7. Follow the security checklist — Secure cookies, strong password validators, HTTPS everywhere.

SUBSCRIBE FOR NEW ARTICLES

@
comments powered by Disqus