Django Custom User Models and Authentication Complete Guide

How to replace Django's default user model with one built for your application — email-based login, custom fields, a proper manager — and how the authentication stack fits together around it.

Django ships a usable User model, but it hard-codes a required unique username and a 150-character limit, and it assumes username-based login. Most real applications want email as the identifier, which means a custom user model.

There is one critical constraint that shapes everything below: the user model is extremely difficult to change after you have run migrations. Getting this right at project start costs an hour; getting it wrong costs a data migration across every table with a foreign key to users.

Why the User Model Must Be Decided Before the First Migration

AUTH_USER_MODEL is read when migrations are generated, and every ForeignKey to the user is baked into those migration files. Swapping the model afterwards means Django wants to drop and recreate relations across the whole schema.

Note: Set AUTH_USER_MODEL and create your user app before running migrate for the first time. On an existing project, the practical options are a bespoke data migration or starting the database over — neither is quick.
Python
Always reference the user model indirectly.
# settings.py
AUTH_USER_MODEL = "accounts.User"

# In models.py — never import User directly
from django.conf import settings
from django.db import models


class Post(models.Model):
    # CORRECT: resolved from AUTH_USER_MODEL at migration time
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="posts",
    )

# In views, forms and tests — use the accessor
from django.contrib.auth import get_user_model

User = get_user_model()

The rule is simple and absolute: settings.AUTH_USER_MODEL in models, get_user_model() everywhere else. A direct from django.contrib.auth.models import User import will break the moment the model changes.

Choosing How Much to Replace

Django offers two starting points. AbstractUser keeps the existing field set and lets you add to it. AbstractBaseUser gives you only the password and last-login plumbing, leaving the identity fields entirely up to you.

Base classGives youChoose when
AbstractUserusername, email, first/last name, permissionsYou want extra fields and are content with username
AbstractBaseUserpassword, last_login onlyYou want email-only login or a different identity field
models.Model profileA OneToOne to the userYou only need extra data, not a different identity

The simplest case is adding fields while keeping Django's defaults.

Python
The minimal custom user — extend AbstractUser.
from django.contrib.auth.models import AbstractUser
from django.db import models


class User(AbstractUser):
    bio = models.TextField(blank=True)
    avatar = models.ImageField(upload_to="avatars/", blank=True, null=True)
    is_verified = models.BooleanField(default=False)

    def __str__(self):
        return self.get_username()

Even if you need nothing extra today, defining an empty class User(AbstractUser) at project start is worth it. It costs nothing and leaves the door open for the change you will eventually want.

A Custom User and Manager

Dropping username entirely requires AbstractBaseUser, a custom manager that knows how to create users, and the three class attributes Django's auth machinery reads.

Python
The manager — note create_superuser must set the staff and superuser flags.
from django.contrib.auth.base_user import BaseUserManager


class UserManager(BaseUserManager):
    use_in_migrations = True

    def _create_user(self, email, password, **extra_fields):
        if not email:
            raise ValueError("An email address is required.")
        email = self.normalize_email(email)
        user = self.model(email=email, **extra_fields)
        # set_password hashes; never assign to user.password directly
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_user(self, email, password=None, **extra_fields):
        extra_fields.setdefault("is_staff", False)
        extra_fields.setdefault("is_superuser", False)
        return self._create_user(email, password, **extra_fields)

    def create_superuser(self, email, password=None, **extra_fields):
        extra_fields.setdefault("is_staff", True)
        extra_fields.setdefault("is_superuser", 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)
Python
The user model — USERNAME_FIELD drives authentication.
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.auth.models import PermissionsMixin
from django.db import models
from django.utils import timezone


class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField(unique=True, db_index=True)
    full_name = models.CharField(max_length=150, blank=True)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    date_joined = models.DateTimeField(default=timezone.now)

    objects = UserManager()

    # The field used to log in
    USERNAME_FIELD = "email"
    # Fields prompted for by createsuperuser, excluding USERNAME_FIELD/password
    REQUIRED_FIELDS = ["full_name"]
    # The field used for password-reset emails
    EMAIL_FIELD = "email"

    def __str__(self):
        return self.email
Note: PermissionsMixin supplies is_superuser, groups and user_permissions. Omit it and the Django admin, along with every permission check, stops working. Also note REQUIRED_FIELDS must not contain USERNAME_FIELD.

unique=True on the email is not optional. Authentication looks the user up by USERNAME_FIELD, so a non-unique identifier makes login ambiguous.

How a Login Request Is Resolved

authenticate() walks the configured backends in order, returning the first non-null user. A custom backend is how you support alternatives such as logging in with either an email or a username.

Python
A backend accepting either email or username, written to avoid timing leaks.
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.db.models import Q

User = get_user_model()


class EmailOrUsernameBackend(ModelBackend):
    def authenticate(self, request, username=None, password=None, **kwargs):
        if username is None:
            username = kwargs.get(User.USERNAME_FIELD)
        if username is None or password is None:
            return None
        try:
            user = User.objects.get(
                Q(email__iexact=username) | Q(username__iexact=username)
            )
        except User.DoesNotExist:
            # Run the hasher anyway so a missing user and a wrong password
            # take similar time, avoiding user enumeration by timing.
            User().set_password(password)
            return None
        except User.MultipleObjectsReturned:
            return None

        if user.check_password(password) and self.user_can_authenticate(user):
            return user
        return None
Python
Settings for backends, hashing and password validation.
AUTHENTICATION_BACKENDS = [
    "accounts.backends.EmailOrUsernameBackend",
    "django.contrib.auth.backends.ModelBackend",
]

# Argon2 is the recommended hasher; the first entry is used for new passwords
# and the rest allow existing hashes to be verified and upgraded on login.
PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.Argon2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
]

AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
     "OPTIONS": {"min_length": 12}},
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
Note: Argon2 requires pip install django[argon2]. Django upgrades a user's stored hash to the first hasher in the list automatically on their next successful login, so changing the order migrates hashes gradually without a reset.

One behaviour worth knowing: user_can_authenticate rejects users with is_active = False. Deactivating an account is therefore the correct way to revoke access — it blocks login while preserving the rows that reference the user.

Wiring the Custom User into Django's Tooling

A custom user needs its own admin class, because the default one references fields that may no longer exist. Login and password-reset views come from django.contrib.auth.urls and work unchanged.

Python
Admin registration with the password-aware forms.
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm

from .models import User


@admin.register(User)
class UserAdmin(BaseUserAdmin):
    form = UserChangeForm
    add_form = UserCreationForm
    ordering = ["email"]
    list_display = ["email", "full_name", "is_staff", "is_active"]
    list_filter = ["is_staff", "is_active", "is_superuser"]
    search_fields = ["email", "full_name"]

    fieldsets = (
        (None, {"fields": ("email", "password")}),
        ("Profile", {"fields": ("full_name",)}),
        ("Permissions", {"fields": (
            "is_active", "is_staff", "is_superuser", "groups", "user_permissions",
        )}),
        ("Dates", {"fields": ("last_login", "date_joined")}),
    )
    add_fieldsets = (
        (None, {
            "classes": ("wide",),
            "fields": ("email", "password1", "password2"),
        }),
    )
Python
Built-in auth URLs plus a registration view.
from django.urls import path, include
from django.views.generic import CreateView
from django.contrib.auth.forms import UserCreationForm
from django.urls import reverse_lazy

urlpatterns = [
    # login, logout, password_change, password_reset and friends
    path("accounts/", include("django.contrib.auth.urls")),
    path(
        "accounts/register/",
        CreateView.as_view(
            form_class=UserCreationForm,
            template_name="registration/register.html",
            success_url=reverse_lazy("login"),
        ),
        name="register",
    ),
]

# settings.py
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "login"
• Decide the user model before the first migration — changing it later is painful.
• Reference users via settings.AUTH_USER_MODEL in models and get_user_model() elsewhere.
• AbstractUser to add fields; AbstractBaseUser for email-only login.
• Include PermissionsMixin or the admin and all permission checks break.
• USERNAME_FIELD must be unique, and must not appear in REQUIRED_FIELDS.
• Use set_password and check_password; never touch user.password directly.
• Deactivate with is_active = False rather than deleting the row.

Summary

A custom user model is the one piece of Django architecture that is genuinely expensive to defer. Define it in the first commit, even as an empty subclass of AbstractUser, and the option to change identity fields stays open for the life of the project.

Use AbstractUser to extend Django's defaults, or AbstractBaseUser with PermissionsMixin and a custom manager for email-based login. Keep USERNAME_FIELD unique, let the manager own user creation, and leave hashing to set_password.

Everything else — login views, password resets, permissions, the admin — then works on top of the model you defined, because every part of Django's auth stack reads it through AUTH_USER_MODEL rather than assuming its own.