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