Custom User Model & Backends

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, all checked on 2026-10-03; where the 5.2 LTS (5.2.17) behaves differently the text says so. The third-party part uses django-allauth 65.19.7 (2026-10-01), which declares support for Django 5.2, 6.0 and 6.1. Every example was run on that stack in a scratch project on SQLite with the Django test client (39 tests in total, listed below) and the runserver development server; none of them needs PostgreSQL or Redis (the login-throttling example uses the in-process LocMemCache, and the text says what changes with a shared cache). Django’s user model is the one table nearly every project touches, and it is the one table that is painful to change later. This page explains why to start every project with a custom user model, how to write one in the two supported styles (a subclass of AbstractUser that logs in by email, and a lean AbstractBaseUser with PermissionsMixin), how to reference it without hardcoding it, how to give it forms and an admin, how Django’s authentication backends work and how to write your own (including async ones), how REMOTE_USER single sign-on plugs in, and what django-allauth adds for social login and multi-factor authentication. The login, logout, sign-up and password flows themselves are covered on Authentication; permissions and groups on Permissions & Authorization; hashers and validators on Passwords.

In a hurry: what goes where

Need Rule of thumb Section

Start a new project

Create an accounts app with class User(AbstractUser): pass, set AUTH_USER_MODEL = "accounts.User", and only then run makemigrations and migrate.

Why start with a custom user model

Log in with an email address

Set username = None, email unique, USERNAME_FIELD = "email", REQUIRED_FIELDS = [] and write a manager with create_user and create_superuser.

Option 1: AbstractUser with email as the username

Keep the user table minimal

AbstractBaseUser plus PermissionsMixin: you provide the fields, is_active, is_staff, a manager and REQUIRED_FIELDS.

Option 2: AbstractBaseUser with PermissionsMixin

Point a foreign key at the user

settings.AUTH_USER_MODEL (a string) in models and signals, get_user_model() everywhere else.

Referencing the user model: AUTH_USER_MODEL and get_user_model()

Add profile data to an existing project

A one-to-one profile model (or a proxy model for behaviour only) instead of swapping the user.

Extending without swapping: profile and proxy models

Forms for a custom user

Subclass BaseUserCreationForm and UserChangeForm with your own Meta.model and explicit fields.

Forms for a custom user

Admin for a custom user

Subclass UserAdmin, replace fieldsets and add_fieldsets, use AdminUserCreationForm.

UserAdmin for a custom user

Authenticate against another source

A backend class with authenticate() and get_user() (plus async twins), listed in AUTHENTICATION_BACKENDS.

Authentication backends

Let the web server authenticate

RemoteUserMiddleware after AuthenticationMiddleware, RemoteUserBackend, and a proxy that strips the header.

REMOTE_USER: letting the web server authenticate

Social login, MFA, email verification

django-allauth, configured for the custom model.

Social login and MFA with django-allauth

The Bookshelf pieces used on this page

Every example belongs to the Bookshelf project built across this section. The accounts app holds the user model and everything that authenticates; the catalog app (reviews and reader profiles) refers to the user the way a reusable app must. The test files shown below are the ones that ran; test methods are abbreviated only by leaving out the import lines that every test module repeats.

Why start with a custom user model

Django’s built-in django.contrib.auth.models.User has a username, first_name, last_name, email, password, permission and date fields. It is good enough for plenty of sites, but it cannot be changed without a migration that touches every table with a foreign key to it, and Django says so in plain terms: changing AUTH_USER_MODEL after tables exist is possible but complex, because it affects foreign keys and many-to-many tables, and it cannot be done automatically. The remedy costs one empty class at the start of a project, so the documentation’s advice is to create a custom user model even if the default would do.

The options on the table

Option What it is Use it when

Default User

django.contrib.auth.models.User.

Throw-away prototypes and tutorials only.

class User(AbstractUser): pass + AUTH_USER_MODEL

The default fields and behaviour in your own table, ready to grow.

Almost every new project, including those that log in with email.

AbstractBaseUser + PermissionsMixin

Only password, last_login and the permission machinery; you supply every other field.

You want a smaller table, a non-username identifier, or no first/last name.

Profile model (OneToOneField to the user)

A separate table for non-authentication data.

You cannot swap the user (existing project) or the data is not about authentication.

Proxy model

Same table, different Python behaviour.

Only behaviour changes (ordering, methods, a manager). It breaks once a custom user is installed (see Extending without swapping: profile and proxy models).

The documentation also asks one design question before you start: should app-specific information live on the user, or in a model related to it? Keeping everything on the user avoids joins, while keeping the user model small and focused on authentication lets each app define its own data. A common split is the one used here: the user holds identity and authentication fields, and catalog.Profile holds reader-specific data.

Set it before the first migration

The setting must exist before the first migrate because the admin, auth and other apps create foreign keys to whichever model AUTH_USER_MODEL names, and migrations record that dependency. Starting a database with the default user and switching later produces a history Django refuses to apply:

Switching AUTH_USER_MODEL after the first migrate (run in the scratch project)
$ python manage.py migrate --settings=config.settings_default   # built-in auth.User: fine
$ python manage.py migrate                                      # AUTH_USER_MODEL = "accounts.User"
django.db.migrations.exceptions.InconsistentMigrationHistory: Migration admin.0001_initial is applied before its dependency accounts.0001_initial on database 'default'.

Two related rules from the same page. The user model must be created in the first migration of its app (usually 0001_initial), otherwise the dynamic dependency for swappable models breaks; and if a CircularDependencyError appears, the way out is to move the models your user depends on into a second migration. Reusable apps, finally, must not define a custom user model: two such apps could never be installed together, so a reusable app stores per-user data in a foreign key to settings.AUTH_USER_MODEL, as Referencing the user model: AUTH_USER_MODEL and get_user_model() shows. The migration commands themselves are covered on Migrations.

Option 1: AbstractUser with email as the username

AbstractUser is the full implementation of the default User as an abstract model, so subclassing it keeps first_name, last_name, is_staff, is_active, date_joined, the permission fields and every method. Logging in by email takes four changes: remove the inherited username field, make email unique, point USERNAME_FIELD at it, and write a manager, because the stock UserManager calls create_user(username, …​).

accounts/models.py
from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db.models.functions import Lower
from django.utils.translation import gettext_lazy as _


class UserManager(BaseUserManager):
    use_in_migrations = True

    def _create_user(self, email, password, **extra_fields):
        if not email:
            raise ValueError("The email address must be set")
        user = self.model(email=self.normalize_email(email), **extra_fields)
        user.set_password(password)  # None makes the password unusable
        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["is_staff"] is not True:
            raise ValueError("Superuser must have is_staff=True.")
        if extra_fields["is_superuser"] is not True:
            raise ValueError("Superuser must have is_superuser=True.")
        return self._create_user(email, password, **extra_fields)

    # Case-insensitive lookups for login, sync and async.
    def get_by_natural_key(self, username):
        return self.get(**{f"{self.model.USERNAME_FIELD}__iexact": username})

    async def aget_by_natural_key(self, username):
        return await self.aget(**{f"{self.model.USERNAME_FIELD}__iexact": username})


class User(AbstractUser):
    username = None  # remove the inherited field
    email = models.EmailField(_("email address"), unique=True)
    display_name = models.CharField(max_length=80, blank=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []  # AbstractUser requires "email", which is now the username

    objects = UserManager()

    class Meta(AbstractUser.Meta):
        constraints = [
            models.UniqueConstraint(Lower("email"), name="accounts_user_email_ci_unique"),
        ]


class ApiToken(models.Model):
    user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, related_name="api_tokens")
    digest = models.CharField(max_length=64, unique=True)
    created = models.DateTimeField(auto_now_add=True)

Points worth knowing, each one a place where copied snippets go wrong:

  • REQUIRED_FIELDS must not contain the USERNAME_FIELD. AbstractUser sets REQUIRED_FIELDS = ["email"]; once email is the username, leaving that value in place fails the system check with auth.E002. REQUIRED_FIELDS is only what createsuperuser prompts for in addition to the identifier and the password (it has no effect in the admin or in forms).

  • normalize_email() lowercases only the domain. Ann@Example.COM is stored as Ann@example.com, because the local part is technically case-sensitive. People do not type it consistently, so the model adds a case-insensitive unique constraint (UniqueConstraint(Lower("email"))) and the manager looks users up with __iexact in get_by_natural_key(); ModelBackend calls that method to find the user at login. Functional constraints are described on Constraints, Indexes & Composite Keys.

  • Override the async twin as well. BaseUserManager.aget_by_natural_key() (added in 5.2) is what ModelBackend uses from async code. Overriding only the sync method gives case-insensitive login in sync views and case-sensitive login in async ones.

  • AbstractBaseUser.clean() applies NFKC normalisation to the username field, so visually identical identifiers with different code points compare equal; if you override clean(), call super().

  • use_in_migrations = True serialises the manager into migrations, which is needed only when a migration (or a data migration) calls manager methods; it is harmless otherwise.

  • set_password(None) stores an unusable password. It is the right value for accounts that sign in elsewhere (REMOTE_USER, social login); has_usable_password() then returns False.

  • The stock UserManager has async creation helpers (acreate_user(), acreate_superuser()); a subclass of BaseUserManager does not, so wrap your own create_user with sync_to_async or add async methods if you need them.

The swap itself is one line in the settings, and it must be there before the first migration is generated:

config/settings.py (excerpt)
INSTALLED_APPS = [
    # ... django.contrib.* ...
    "accounts",
    "catalog",
]

AUTH_USER_MODEL = "accounts.User"

Source: AUTH_USER_MODEL

Generating the migrations and checking the project are the first things to run, and a good test that the order was right:

Creating the first migration and checking the project
$ python manage.py makemigrations accounts catalog
Migrations for 'accounts':
  accounts/migrations/0001_initial.py
    + Create model User
    + Create model ApiToken
    + Create constraint accounts_user_email_ci_unique on model user
Migrations for 'catalog':
  catalog/migrations/0001_initial.py
    + Create model Profile
    + Create model Review
$ python manage.py check
System check identified no issues (0 silenced).

When REQUIRED_FIELDS still contains email, the check stops with the error that explains the fix:

The system check catches a wrong REQUIRED_FIELDS
ERRORS:
accounts.User: (auth.E002) The field named as the 'USERNAME_FIELD' for a custom user model must not be included in 'REQUIRED_FIELDS'.
	HINT: The 'USERNAME_FIELD' is currently set to 'email', you should remove 'email' from the 'REQUIRED_FIELDS'.

The other user-model checks are auth.E001 (REQUIRED_FIELDS is not a list or tuple), auth.E003 (USERNAME_FIELD is not unique) and auth.W004 (the identifier is not unique, which is allowed only with a backend that supports it).

Testing the model and the manager

The tests pin down every behaviour listed above: creation, the superuser guard rails, the missing username field, case-insensitive uniqueness, unusable passwords, the swapped model, and the createsuperuser command (which reads the password from the DJANGO_SUPERUSER_PASSWORD environment variable when it runs without prompts).

accounts/tests.py (ManagerTests)
import secrets
from io import StringIO
from unittest import mock

from django.contrib.auth import get_user_model
from django.db import IntegrityError
from django.test import TestCase

User = get_user_model()
PASSWORD = secrets.token_urlsafe(16)  # a throwaway value per test run


class ManagerTests(TestCase):
    def test_create_user_and_superuser(self):
        user = User.objects.create_user("Ann@Example.COM", PASSWORD)
        self.assertEqual(user.email, "Ann@example.com")  # only the domain is lowercased
        self.assertTrue(user.check_password(PASSWORD))
        self.assertFalse(user.is_staff)
        admin_user = User.objects.create_superuser("root@example.com", PASSWORD)
        self.assertTrue(admin_user.is_staff and admin_user.is_superuser)
        with self.assertRaises(ValueError):
            User.objects.create_superuser("x@example.com", PASSWORD, is_staff=False)
        with self.assertRaises(ValueError):
            User.objects.create_user("", PASSWORD)

    def test_no_username_field(self):
        self.assertIsNone(getattr(User, "username", None))
        self.assertEqual(User.USERNAME_FIELD, "email")
        self.assertEqual(User.get_email_field_name(), "email")

    def test_case_insensitive_uniqueness(self):
        User.objects.create_user("ann@example.com", PASSWORD)
        with self.assertRaises(IntegrityError):
            User.objects.create_user("ANN@example.com", PASSWORD)

    def test_unusable_password(self):
        user = User.objects.create_user("nopw@example.com")
        self.assertFalse(user.has_usable_password())

    def test_swapped_model(self):
        self.assertIs(User, __import__("accounts.models", fromlist=["User"]).User)
        self.assertEqual(User._meta.label, "accounts.User")

    def test_createsuperuser_command(self):
        from django.core.management import call_command

        with mock.patch.dict("os.environ", {"DJANGO_SUPERUSER_PASSWORD": PASSWORD}):
            call_command("createsuperuser", interactive=False, email="cli@example.com", stdout=StringIO())
        self.assertTrue(User.objects.get(email="cli@example.com").is_superuser)
Running the tests
$ python manage.py test accounts catalog
Found 32 test(s).
................................
Ran 32 tests in 0.1s

OK

Source: Testing tools

Option 2: AbstractBaseUser with PermissionsMixin

AbstractBaseUser is the minimal base: the hashed password, last_login and the methods around them. Everything else is yours. The documentation calls it the easiest way to build a compliant model and lists what you must provide.

You provide Meaning

USERNAME_FIELD

Name of the unique identifier field. Must be unique=True unless a custom backend supports duplicates.

REQUIRED_FIELDS

Extra fields createsuperuser prompts for; must include every non-blank field without a default, and not the identifier or password.

EMAIL_FIELD

Optional; the name of the email field, returned by get_email_field_name() (default email) and used by the password reset form.

is_active

Provided as True on AbstractBaseUser; define a real BooleanField if inactive accounts must be storable.

get_full_name() and get_short_name()

Optional; the admin shows them in object history and in its header greeting.

A manager with create_user() and create_superuser()

Required by createsuperuser; the prototypes take the identifier plus every required field.

In exchange you get get_username(), clean(), get_email_field_name(), normalize_username(), is_authenticated (always True), is_anonymous (always False), set_password(), check_password() and acheck_password(), set_unusable_password(), has_usable_password(), and get_session_auth_hash() (an HMAC of the password field that invalidates sessions when the password changes) with get_session_auth_fallback_hash() (the same HMAC computed with each SECRET_KEY_FALLBACKS value). AbstractBaseUser and BaseUserManager can be imported from django.contrib.auth.base_user without django.contrib.auth in INSTALLED_APPS.

Permissions are a separate mixin. PermissionsMixin adds is_superuser, groups and user_permissions and the methods get_user_permissions(), get_group_permissions(), get_all_permissions(), has_perm(), has_perms() and has_module_perms(). Without it you must not call the permission methods of ModelBackend, which assumes those fields exist and raises database errors when they do not. The admin additionally needs is_staff, is_active, has_perm() and has_module_perms() on the user (the admin checks is_staff and is_active in AdminSite.has_permission()).

accounts_abu/models.py (the lean alternative; used with config/settings_abu.py)
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.contrib.auth.models import PermissionsMixin
from django.db import models
from django.utils import timezone


class UserManager(BaseUserManager):
    use_in_migrations = True

    def create_user(self, email, display_name, password=None):
        if not email:
            raise ValueError("Users must have an email address")
        user = self.model(email=self.normalize_email(email), display_name=display_name)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, email, display_name, password=None):
        user = self.create_user(email, display_name, password=password)
        user.is_staff = True
        user.is_superuser = True
        user.save(using=self._db)
        return user


class User(AbstractBaseUser, PermissionsMixin):
    email = models.EmailField("email address", max_length=255, unique=True)
    display_name = models.CharField(max_length=80)
    is_active = models.BooleanField(default=True)
    is_staff = models.BooleanField(default=False)
    date_joined = models.DateTimeField(default=timezone.now)

    objects = UserManager()

    EMAIL_FIELD = "email"
    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = ["display_name"]

    def get_full_name(self):
        return self.display_name

    def get_short_name(self):
        return self.display_name.split(" ")[0]

Here REQUIRED_FIELDS = ["display_name"], so createsuperuser prompts for the email, the display name and the password, and the manager’s create_superuser(email, display_name, password=None) receives them as arguments. The tests run the command non-interactively, add a permission and a group through the mixin, and drive the admin (see UserAdmin for a custom user).

accounts_abu/tests.py (excerpt)
import secrets
from io import StringIO
from unittest import mock

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.core.management import call_command
from django.test import TestCase

User = get_user_model()
PASSWORD = secrets.token_urlsafe(16)  # a throwaway value per test run


class AbstractBaseUserTests(TestCase):
    def test_create_and_permissions_mixin(self):
        user = User.objects.create_user("a@example.com", "Ann Lee", PASSWORD)
        self.assertEqual(user.get_short_name(), "Ann")
        perm = Permission.objects.get(codename="view_group")
        user.user_permissions.add(perm)
        group = Group.objects.create(name="editors")
        user.groups.add(group)
        user = User.objects.get(pk=user.pk)  # permissions are cached on the instance
        self.assertTrue(user.has_perm("auth.view_group"))
        self.assertFalse(user.has_perm("auth.delete_group"))

    def test_createsuperuser_prompts_for_required_fields(self):
        with mock.patch.dict("os.environ", {"DJANGO_SUPERUSER_PASSWORD": PASSWORD}):
            call_command(
                "createsuperuser", interactive=False, email="root@example.com", display_name="Root", stdout=StringIO()
            )
        root = User.objects.get(email="root@example.com")
        self.assertTrue(root.is_superuser and root.is_staff)
Running the lean variant against its own settings module
$ python manage.py test accounts_abu --settings=config.settings_abu
Found 4 test(s).
....
Ran 4 tests in 0.1s

OK

Choosing between the two

AbstractUser AbstractBaseUser + PermissionsMixin

Fields you get

username, first_name, last_name, email, is_staff, is_active, date_joined, plus permission fields

password, last_login, plus is_superuser, groups, user_permissions from the mixin

Manager

Stock UserManager works when username exists; custom manager when you drop it

Always custom

Admin

Existing UserAdmin with small edits

UserAdmin subclass with every field reference replaced

Built-in forms

Compatible after a Meta.model swap

AuthenticationForm, password forms work; creation and change forms must be rewritten

Cost of a new requirement

Add a field and migrate

Add a field and migrate; add is_staff-style flags yourself

Pick it when

You want to ship; email login is a two-line change plus a manager

You are certain you do not want the default fields

Referencing the user model: AUTH_USER_MODEL and get_user_model()

Once the model is swappable, from django.contrib.auth.models import User is wrong: it works only in projects that kept the default. There are two correct ways to refer to the active user model, and each has its place.

Where Use Why

ForeignKey, OneToOneField, ManyToManyField in models

settings.AUTH_USER_MODEL (a string such as "accounts.User")

Resolved lazily, so import order does not matter and the model can be defined before the user app is loaded.

Signal receivers (sender=)

settings.AUTH_USER_MODEL

connect(…​, sender="accounts.User") accepts the lazy reference.

Views, forms, admin, tests, management commands

get_user_model()

Returns the active class at call time, so you can call its manager and class methods.

Module level in code run while models import

Prefer settings.AUTH_USER_MODEL; get_user_model() is allowed during model import but is easy to misuse

Calling it before the user app’s models have been imported fails, so keep it out of module-level code that can run earlier.

catalog/models.py
from django.conf import settings
from django.db import models


class Review(models.Model):
    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="reviews")
    body = models.TextField()


class Profile(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="profile")
    bio = models.TextField(blank=True)
catalog/signals.py and the app config that connects it
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver

from .models import Profile


@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_profile(sender, instance, created, **kwargs):
    if created:
        Profile.objects.create(user=instance)


from django.apps import AppConfig


class CatalogConfig(AppConfig):
    name = "catalog"

    def ready(self):
        from . import signals  # noqa: F401

ready() is the place for the import, so the receiver is registered once, after all apps have loaded. The profile row is created for every new user however the user was created (sign-up form, admin, createsuperuser, a REMOTE_USER login).

If a test swaps the user model with @override_settings(AUTH_USER_MODEL=…​) and your code caches the result of get_user_model() in a module-level variable, listen to the setting_changed signal, call apps.clear_cache() and refresh the cache, as the documentation shows. Most projects never need this because the swap is a deployment-time decision, not a test-time one. Reviews and profiles are exercised in the tests of Authentication backends and Forms for a custom user.

Extending without swapping: profile and proxy models

When the project already has data, or when the additional information is not about authentication, extend the built-in user instead of replacing it. A proxy model shares the table, so it is right for pure behaviour (default ordering, methods, a manager). A profile model (a OneToOneField to the user) stores additional fields in its own table, as catalog.Profile does above; reading it costs a join or an extra query, and the row is not created automatically, which is what the post_save receiver is for. To show the profile fields on the user’s admin page, add an inline to a UserAdmin subclass and re-register it:

Profile inline on the user admin (pattern from the documentation, adapted to catalog.Profile)
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.models import User

from catalog.models import Profile


class ProfileInline(admin.StackedInline):
    model = Profile
    can_delete = False
    verbose_name_plural = "profile"


class UserAdmin(BaseUserAdmin):
    inlines = [ProfileInline]


admin.site.unregister(User)
admin.site.register(User, UserAdmin)

(This listing targets the built-in User and therefore applies only to a project that kept it. It is shown for the pattern and was not part of the custom-user scratch project, where the custom UserAdmin below plays this role; the profile model and its signal, which are the substance, ran with the tests.)

One limit to remember: installing a custom user model breaks proxy models that extend User. Proxy models need a concrete base class, and by defining a custom user model Django loses the ability to identify the base reliably. Either re-point the proxy at the model in use or merge the behaviour into the user class.

Forms for a custom user

Django’s built-in forms make assumptions about the user model, and the documentation sorts them into three groups.

Form Compatibility with a custom user

AuthenticationForm, SetPasswordForm, PasswordChangeForm, AdminPasswordChangeForm

Work with any AbstractBaseUser subclass; AuthenticationForm uses the field named by USERNAME_FIELD.

PasswordResetForm

Works if the model has an email field named by get_email_field_name() and a boolean is_active.

UserCreationForm, BaseUserCreationForm, UserChangeForm, AdminUserCreationForm

Tied to the built-in User; extend them with your own Meta.model and fields.

BaseUserCreationForm is the documented base class when you customise user creation: it has the identifier field, password1 and password2, checks that they match, runs the password validators against the new user, and hashes the password. UserCreationForm adds a username-specific case-insensitive uniqueness check that does not apply to a model without a username, which is why the forms below extend the base class and the model carries its own case-insensitive constraint.

accounts/forms.py
from django.contrib.auth.forms import BaseUserCreationForm, UserChangeForm

from .models import User


class UserCreationForm(BaseUserCreationForm):
    class Meta(BaseUserCreationForm.Meta):
        model = User
        fields = ("email", "display_name")


class UserAdminChangeForm(UserChangeForm):
    class Meta(UserChangeForm.Meta):
        model = User
        fields = ("email", "display_name", "password", "is_active")

Always list fields explicitly. UserChangeForm.Meta uses fields = "all", which for a user model would expose is_superuser, groups and user_permissions to whoever uses the form; the override above names what the form edits.

A sign-up view built on the creation form needs no more than a CreateView and a template that includes the CSRF token (a state-changing form must never be a GET):

accounts/views.py (SignUpView) and accounts/templates/accounts/signup.html
from django.urls import reverse_lazy
from django.views.generic import CreateView

from .forms import UserCreationForm


class SignUpView(CreateView):
    form_class = UserCreationForm
    template_name = "accounts/signup.html"
    success_url = reverse_lazy("login")
<form method="post">
  {% csrf_token %}
  {{ form.as_div }}
  <button type="submit">Sign up</button>
</form>
accounts/tests.py (SignUpTests and PasswordResetTests)
import secrets

from django.core import mail
from django.test import Client, TestCase
from django.urls import reverse

from accounts.models import User

PASSWORD = secrets.token_urlsafe(16)  # a throwaway value per test run


class SignUpTests(TestCase):
    def test_signup_form(self):
        data = {"email": "new@example.com", "display_name": "New", "password1": PASSWORD, "password2": PASSWORD}
        response = self.client.post(reverse("signup"), data)
        self.assertRedirects(response, reverse("login"))
        user = User.objects.get(email="new@example.com")
        self.assertTrue(user.check_password(PASSWORD))
        self.assertTrue(user.profile)  # created by the post_save receiver in catalog

    def test_signup_requires_csrf(self):
        strict = Client(enforce_csrf_checks=True)
        data = {"email": "z@example.com", "password1": PASSWORD, "password2": PASSWORD}
        self.assertEqual(strict.post(reverse("signup"), data).status_code, 403)

    def test_password_validation(self):
        data = {"email": "w@example.com", "password1": "short", "password2": "short"}
        response = self.client.post(reverse("signup"), data)
        self.assertEqual(response.status_code, 200)
        self.assertFalse(User.objects.filter(email="w@example.com").exists())


class PasswordResetTests(TestCase):
    def test_reset_email_uses_email_field(self):
        User.objects.create_user("reset@example.com", PASSWORD)
        response = self.client.post(reverse("password_reset"), {"email": "reset@example.com"})
        self.assertEqual(response.status_code, 302)
        self.assertEqual(len(mail.outbox), 1)
        self.assertEqual(mail.outbox[0].to, ["reset@example.com"])

The tests assert that a valid post creates the user and, through the signal, the profile; that the CSRF check rejects a post without a token; that a short password is refused; and that the password reset form finds the user by the model’s email field. The login, logout, sign-up and reset views and URLs belong to Authentication; the validators and hashers behind the password check to Passwords.

UserAdmin for a custom user

A custom user model works with the admin as soon as it defines is_staff, is_active, has_perm() and has_module_perms() and is registered. The registration is where the choice made in Choosing between the two shows. For an AbstractUser subclass you can reuse django.contrib.auth.admin.UserAdmin, but every option that names a removed field must be replaced: fieldsets (edit page), add_fieldsets (create page, not a standard ModelAdmin option; UserAdmin overrides get_fieldsets() to use it), list_display, search_fields and ordering. For an AbstractBaseUser subclass you replace all of them, and you may need to drop filter_horizontal entries for fields the model lacks.

accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import AdminUserCreationForm
from django.utils.translation import gettext_lazy as _

from .forms import UserAdminChangeForm
from .models import User


class AdminCreationForm(AdminUserCreationForm):
    class Meta(AdminUserCreationForm.Meta):
        model = User
        fields = ("email", "display_name")


@admin.register(User)
class UserAdmin(BaseUserAdmin):
    form = UserAdminChangeForm
    add_form = AdminCreationForm
    ordering = ("email",)
    list_display = ("email", "display_name", "is_staff", "is_active")
    search_fields = ("email", "display_name")
    fieldsets = (
        (None, {"fields": ("email", "password")}),
        (_("Personal info"), {"fields": ("display_name",)}),
        (
            _("Permissions"),
            {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")},
        ),
        (_("Important dates"), {"fields": ("last_login", "date_joined")}),
    )
    add_fieldsets = (
        (
            None,
            {
                "classes": ("wide",),
                "fields": ("email", "display_name", "usable_password", "password1", "password2"),
            },
        ),
    )

AdminUserCreationForm (added in 5.1) is the form the stock UserAdmin uses to add a user. It inherits from UserCreationForm and adds a usable_password choice: when it is enabled the form validates and sets the password; when it is disabled no password is validated and set_unusable_password() is called, which disables password sign-in for that account (for example for a user who will only sign in through single sign-on). The field must therefore be listed in add_fieldsets. Using the older creation form in a 5.1+ admin drops that option. Registration through the decorator @admin.register(User) is equivalent to admin.site.register(User, UserAdmin); the registration and the options themselves are described on Admin Site. REQUIRED_FIELDS plays no role in the admin: the fields the add page asks for are the ones in add_fieldsets.

The AbstractBaseUser variant of the admin follows the same structure; its forms carry explicit fields and its fieldsets name only fields the model has:

accounts_abu/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.forms import AdminUserCreationForm, UserChangeForm

from .models import User


class StaffCreationForm(AdminUserCreationForm):
    class Meta(AdminUserCreationForm.Meta):
        model = User
        fields = ("email", "display_name")


class StaffChangeForm(UserChangeForm):
    class Meta(UserChangeForm.Meta):
        model = User
        fields = ("email", "display_name", "password", "is_active", "is_staff", "is_superuser")


@admin.register(User)
class StaffUserAdmin(BaseUserAdmin):
    form = StaffChangeForm
    add_form = StaffCreationForm
    list_display = ("email", "display_name", "is_staff")
    list_filter = ("is_staff", "is_superuser", "is_active")
    search_fields = ("email", "display_name")
    ordering = ("email",)
    filter_horizontal = ("groups", "user_permissions")
    fieldsets = (
        (None, {"fields": ("email", "password")}),
        ("Personal info", {"fields": ("display_name",)}),
        ("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
        ("Important dates", {"fields": ("last_login", "date_joined")}),
    )
    add_fieldsets = (
        (None, {"classes": ("wide",), "fields": ("email", "display_name", "usable_password", "password1", "password2")}),
    )
accounts/tests.py (AdminTests)
import secrets

from django.contrib import admin
from django.test import TestCase
from django.urls import reverse

from accounts.models import User

PASSWORD = secrets.token_urlsafe(16)  # a throwaway value per test run


class AdminTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.staff = User.objects.create_superuser("root@example.com", PASSWORD)

    def setUp(self):
        self.client.force_login(self.staff)

    def test_registered_with_user_admin(self):
        from django.contrib.auth.admin import UserAdmin

        self.assertIsInstance(admin.site._registry[User], UserAdmin)

    def test_add_user_through_admin(self):
        url = reverse("admin:accounts_user_add")
        self.assertEqual(self.client.get(url).status_code, 200)
        data = {
            "email": "made@example.com",
            "display_name": "Made",
            "usable_password": "true",
            "password1": PASSWORD,
            "password2": PASSWORD,
        }
        response = self.client.post(url, data)
        self.assertEqual(response.status_code, 302, getattr(response, "context", None) and response.context["adminform"].form.errors)
        self.assertTrue(User.objects.get(email="made@example.com").check_password(PASSWORD))

    def test_add_user_without_password(self):
        url = reverse("admin:accounts_user_add")
        data = {"email": "nopw@example.com", "display_name": "No", "usable_password": "false"}
        self.assertEqual(self.client.post(url, data).status_code, 302)
        self.assertFalse(User.objects.get(email="nopw@example.com").has_usable_password())

    def test_change_list_and_form(self):
        target = User.objects.create_user("t@example.com", PASSWORD)
        self.assertContains(self.client.get(reverse("admin:accounts_user_changelist")), "t@example.com")
        self.assertEqual(self.client.get(reverse("admin:accounts_user_change", args=[target.pk])).status_code, 200)
        self.assertEqual(self.client.get(reverse("admin:auth_user_password_change", args=[target.pk])).status_code, 200)

    def test_search_by_email(self):
        User.objects.create_user("findme@example.com", PASSWORD)
        response = self.client.get(reverse("admin:accounts_user_changelist"), {"q": "findme"})
        self.assertContains(response, "findme@example.com")

The tests add a user with a password through the admin form, add one without a password (usable_password set to false), open the change list, the change form and the admin password-change page, and search by email. One more consequence of the admin’s design deserves a sentence: the admin login view goes through authenticate() and your backends, but it is a separate view from your login page, so anything you bolt onto your own login (rate limiting, MFA) does not protect /admin/login/ unless you route it through the same path (see Social login and MFA with django-allauth).

Authentication backends

An authentication backend is the object that turns credentials into a user. Django keeps a list of them in AUTHENTICATION_BACKENDS (default: ["django.contrib.auth.backends.ModelBackend"]) and authenticate() tries them in order.

Where backends sit in the request cycle

Backends are used at two moments: when credentials are submitted (a login view, authenticate(), the admin login) and on every later request, when AuthenticationMiddleware resolves request.user from the session.

sequenceDiagram participant V as Login view participant A as authenticate() participant B1 as Backend 1 participant B2 as Backend 2 participant S as Session participant M as AuthenticationMiddleware V->>A: authenticate(request, **credentials) A->>B1: authenticate(request, **credentials) B1-->>A: None (not mine or wrong) A->>B2: authenticate(request, **credentials) B2-->>A: user (user.backend set) V->>S: login(request, user) stores id, backend path, hash Note over M,S: later requests M->>S: read user id and backend path M->>B2: get_user(user_id) M->>M: verify session auth hash M-->>M: request.user

The rules that follow from this picture:

  • The first backend that returns a user wins. A backend that returns None passes the credentials on; one that raises PermissionDenied stops the whole attempt, and authenticate() returns None.

  • A backend only sees credentials its signature accepts. Django binds the keyword arguments to each backend’s authenticate() signature and skips backends it does not fit. A token backend with authenticate(self, request, token=None) is never offered a username and password. The reverse is not true: ModelBackend.authenticate() takes **kwargs, so it is offered a token and simply returns None because there is no username or password. A subclass of it that does work before calling super() must check that the credentials are its own (see Example: refusing too many attempts).

  • The backend that authenticated the user is remembered in the session (the dotted path under _auth_user_backend) and reused for the whole session through its get_user(). If you change AUTHENTICATION_BACKENDS, existing sessions keep the old backend until they expire; Session.objects.all().delete() forces everyone to sign in again. If the stored backend is no longer in the setting, the user is anonymous.

  • authenticate() sets user.backend. That attribute is what login() stores. When several backends are configured and the user object did not come from authenticate() (for example, a user fetched by id), login(request, user) raises a ValueError asking for the backend argument.

  • Order is policy. Put the cheap and the strict first; remember that if the same credentials are valid in two backends the first one wins.

  • Verification happens after get_user(). The middleware compares the session’s hash with user.get_session_auth_hash() (and tries get_session_auth_fallback_hash() for each SECRET_KEY_FALLBACKS value), so a password change signs the account out everywhere; update_session_auth_hash() keeps the current session alive after a voluntary change.

The backends that ship with Django

Class Behaviour

BaseBackend

Rejects every user and grants no permissions. Subclass it and override what you need.

ModelBackend

The default. Authenticates by USERNAME_FIELD and password (check_password()), rejects inactive users through user_can_authenticate(), and implements model permissions from user_permissions and groups. Object-level methods return an empty set or False when obj is given. It has no brute-force protection.

AllowAllUsersModelBackend

Same, but user_can_authenticate() always returns True, so inactive users may authenticate. You will also want a custom AuthenticationForm subclass that overrides confirm_login_allowed(), because the stock form rejects them.

RemoteUserBackend, AllowAllUsersRemoteUserBackend

Trust a username delivered by the web server (see REMOTE_USER: letting the web server authenticate).

A backend is a class with two required methods, get_user(user_id) and authenticate(request, **credentials), and optional permission methods. get_user() takes the primary key of the user object and returns the user or None; authenticate() returns the user when the credentials are valid and None otherwise. request can be None when the caller did not pass one. For the default user model the documentation also notes that a backend that creates Django users on demand is the best way to integrate an external directory, because the admin and the permission system are tightly coupled to the user object (is_staff and is_active must be true to reach the admin).

Example: a token backend

Token authentication for an API fits the backend model naturally: the credential is a token, not a password, and there is no session unless you create one. The backend below stores only a SHA-256 digest of each token (a leaked table does not leak usable tokens) and ships native async methods.

accounts/backends.py (ApiTokenBackend)
import hashlib

from django.contrib.auth import get_user_model
from django.contrib.auth.backends import BaseBackend, ModelBackend
from django.core.cache import cache
from django.core.exceptions import PermissionDenied

from .models import ApiToken


def hash_token(token):
    return hashlib.sha256(token.encode()).hexdigest()


class ApiTokenBackend(BaseBackend):
    """Authenticate with authenticate(request, token=...); no password involved."""

    def user_can_authenticate(self, user):
        return getattr(user, "is_active", True)

    def authenticate(self, request, token=None):
        if token is None:
            return None
        try:
            record = ApiToken.objects.select_related("user").get(digest=hash_token(token))
        except ApiToken.DoesNotExist:
            return None
        return record.user if self.user_can_authenticate(record.user) else None

    def get_user(self, user_id):
        user = get_user_model()._default_manager.filter(pk=user_id).first()
        return user if user and self.user_can_authenticate(user) else None

    # Native async versions: without them Django wraps the sync ones in sync_to_async.
    async def aauthenticate(self, request, token=None):
        if token is None:
            return None
        try:
            record = await ApiToken.objects.select_related("user").aget(digest=hash_token(token))
        except ApiToken.DoesNotExist:
            return None
        return record.user if self.user_can_authenticate(record.user) else None

    async def aget_user(self, user_id):
        user = await get_user_model()._default_manager.filter(pk=user_id).afirst()
        return user if user and self.user_can_authenticate(user) else None


class ThrottledModelBackend(ModelBackend):
    """ModelBackend that gives up after too many failures for one identifier."""

    max_failures = 5
    window = 15 * 60  # seconds

    def _key(self, username, password, kwargs):
        identifier = username or kwargs.get(get_user_model().USERNAME_FIELD)
        if not identifier or password is None:
            return None  # not a username/password attempt (a token, say): not ours to count
        return "login-failures:" + hashlib.sha256(identifier.lower().encode()).hexdigest()

    def authenticate(self, request, username=None, password=None, **kwargs):
        key = self._key(username, password, kwargs)
        if key is None:
            return None
        if cache.get(key, 0) >= self.max_failures:
            raise PermissionDenied("Too many failed attempts")
        user = super().authenticate(request, username, password, **kwargs)
        if user is None:
            cache.add(key, 0, self.window)
            cache.incr(key)
        else:
            cache.delete(key)
        return user

    async def aauthenticate(self, request, username=None, password=None, **kwargs):
        key = self._key(username, password, kwargs)
        if key is None:
            return None
        if await cache.aget(key, 0) >= self.max_failures:
            raise PermissionDenied("Too many failed attempts")
        user = await super().aauthenticate(request, username, password, **kwargs)
        if user is None:
            await cache.aadd(key, 0, self.window)
            await cache.aincr(key)
        else:
            await cache.adelete(key)
        return user

(The same file also holds ThrottledModelBackend, discussed next.) ApiToken is the small model in accounts/models.py above: a foreign key to the user and a unique digest. The view that uses the backend reads a bearer token and calls authenticate(); user.backend shows which backend answered:

accounts/views.py (whoami) and config/urls.py
from django.contrib.auth import authenticate
from django.http import JsonResponse


def whoami(request):
    header = request.headers.get("Authorization", "")
    scheme, _, token = header.partition(" ")
    user = authenticate(request, token=token) if scheme.lower() == "bearer" and token else None
    if user is None:
        return JsonResponse({"detail": "invalid token"}, status=401)
    return JsonResponse({"email": user.email, "backend": user.backend})
Calling it with curl (token created in the shell; placeholder values)
$ curl -si -H "Authorization: Bearer not-a-real-token" http://127.0.0.1:8740/api/whoami/
HTTP/1.1 200 OK
{"email": "dana@example.com", "backend": "accounts.backends.ApiTokenBackend"}

$ curl -si -H "Authorization: Bearer wrong" http://127.0.0.1:8740/api/whoami/
HTTP/1.1 401 Unauthorized
{"detail": "invalid token"}

For anything beyond a small internal API, the REST frameworks ship their own authentication classes (token, session, JWT); see the Django REST framework and Django Ninja pages of this section when they are available. The point of the example is the mechanism, which those libraries reuse. Do not put real tokens in source control: the digest is what the table stores and the raw token is shown to its owner once.

accounts/tests.py (ApiTokenTests)
import secrets

from django.test import TestCase
from django.urls import reverse

from accounts.backends import hash_token
from accounts.models import ApiToken, User

PASSWORD = secrets.token_urlsafe(16)  # a throwaway value per test run


class ApiTokenTests(TestCase):
    def setUp(self):
        self.user = User.objects.create_user("tok@example.com", PASSWORD)
        self.raw = "not-a-real-token"
        ApiToken.objects.create(user=self.user, digest=hash_token(self.raw))

    def test_whoami(self):
        ok = self.client.get(reverse("whoami"), headers={"Authorization": f"Bearer {self.raw}"})
        self.assertEqual(ok.status_code, 200)
        self.assertEqual(ok.json(), {"email": "tok@example.com", "backend": "accounts.backends.ApiTokenBackend"})
        bad = self.client.get(reverse("whoami"), headers={"Authorization": "Bearer nope"})
        self.assertEqual(bad.status_code, 401)

    def test_backend_signature_selects_backend(self):
        # username/password credentials never reach ApiTokenBackend, and vice versa
        self.assertIsNone(authenticate(token="nope"))
        self.assertEqual(authenticate(token=self.raw), self.user)

Example: refusing too many attempts

ModelBackend does not rate-limit, and neither does the admin login. A backend that raises PermissionDenied when an identifier has failed too often makes authenticate() stop immediately, without trying the remaining backends. The counter lives in the cache, keyed by a hash of the (lowercased) identifier so that raw emails never appear in cache keys. The class overrides both authenticate() and aauthenticate(), because the parent has native versions of both and an override of one only would leave the other path unprotected.

accounts/backends.py (ThrottledModelBackend, shown in the file above)
class ThrottledModelBackend(ModelBackend):
    """ModelBackend that gives up after too many failures for one identifier."""

    max_failures = 5
    window = 15 * 60  # seconds

    def _key(self, username, password, kwargs):
        identifier = username or kwargs.get(get_user_model().USERNAME_FIELD)
        if not identifier or password is None:
            return None  # not a username/password attempt (a token, say): not ours to count
        return "login-failures:" + hashlib.sha256(identifier.lower().encode()).hexdigest()

    def authenticate(self, request, username=None, password=None, **kwargs):
        key = self._key(username, password, kwargs)
        if key is None:
            return None
        if cache.get(key, 0) >= self.max_failures:
            raise PermissionDenied("Too many failed attempts")
        user = super().authenticate(request, username, password, **kwargs)
        if user is None:
            cache.add(key, 0, self.window)
            cache.incr(key)
        else:
            cache.delete(key)
        return user

The early return None is not decoration. authenticate(token=…​) is also offered to this backend, and counting those attempts under an empty identifier would lock out every later token request after five bad tokens; the test test_token_attempts_do_not_trip_the_password_throttle pins that down.

(The full class, including aauthenticate() with await cache.aget(…​), aadd(), aincr() and adelete(), is in the file listed above.) With LocMemCache the counter is per process; behind several workers use a shared cache (Redis or Memcached), otherwise each worker counts separately, and consider a dedicated rate-limiting layer in the web server or a package for the policy (lockout duration, per-IP limits). The sixth failed login in the test below is refused even with the right password:

accounts/tests.py (AuthenticationTests)
import secrets

from django.contrib.auth import authenticate
from django.core.cache import cache
from django.test import TestCase
from django.urls import reverse

from accounts.models import User

PASSWORD = secrets.token_urlsafe(16)  # a throwaway value per test run


class AuthenticationTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user = User.objects.create_user("bea@example.com", PASSWORD)

    def setUp(self):
        cache.clear()

    def test_login_by_email_any_case(self):
        self.assertEqual(authenticate(username="BEA@example.com", password=PASSWORD), self.user)
        self.assertEqual(authenticate(email="bea@example.com", password=PASSWORD), self.user)
        self.assertTrue(self.client.login(username="bea@EXAMPLE.com", password=PASSWORD))

    def test_throttling_raises_permission_denied(self):
        for _ in range(5):
            self.assertIsNone(authenticate(username="bea@example.com", password="wrong"))
        # the sixth attempt is refused even with the right password
        self.assertIsNone(authenticate(username="bea@example.com", password=PASSWORD))
        cache.clear()
        self.assertEqual(authenticate(username="bea@example.com", password=PASSWORD), self.user)

    def test_token_attempts_do_not_trip_the_password_throttle(self):
        for _ in range(10):
            self.assertIsNone(authenticate(token="nope"))
        self.assertEqual(authenticate(username="bea@example.com", password=PASSWORD), self.user)

    def test_login_view(self):
        response = self.client.post(reverse("login"), {"username": "bea@example.com", "password": PASSWORD})
        self.assertEqual(response.status_code, 302)
        self.assertEqual(self.client.session["_auth_user_backend"], "accounts.backends.ThrottledModelBackend")

    def test_login_needs_backend_when_several_are_configured(self):
        from django.contrib.auth import login
        from django.contrib.sessions.backends.db import SessionStore
        from django.test import RequestFactory

        request = RequestFactory().get("/")
        request.session = SessionStore()
        with self.assertRaises(ValueError):  # user was not returned by authenticate()
            login(request, self.user)
        login(request, self.user, backend="accounts.backends.ThrottledModelBackend")
        self.assertEqual(request.session["_auth_user_backend"], "accounts.backends.ThrottledModelBackend")

    def test_inactive_user_rejected(self):
        self.user.is_active = False
        self.user.save()
        self.assertIsNone(authenticate(username="bea@example.com", password=PASSWORD))

Example: authorisation in a backend

Backends can also answer permission questions. The user model and its manager delegate get_user_permissions(), get_group_permissions(), get_all_permissions(), has_perm(), has_module_perms() and with_perm() to every backend that implements them, and the user’s permissions are the union across backends: a permission is granted when any one backend grants it, and a PermissionDenied raised in has_perm() or has_module_perms() fails the check at once. Backends receive the user object, which may be an AnonymousUser, as the first argument, and they must test is_active themselves. Django ships no object-level implementation (the check returns False or an empty list), so a small backend can supply one:

catalog/backends.py
from django.contrib.auth.backends import BaseBackend

from .models import Review


class ReviewAuthorBackend(BaseBackend):
    """Object-level permissions: authors may change and delete their own reviews."""

    def has_perm(self, user_obj, perm, obj=None):
        if not user_obj.is_active or not isinstance(obj, Review):
            return False
        return perm in {"catalog.change_review", "catalog.delete_review"} and obj.author_id == user_obj.pk
catalog/tests.py
import secrets

from django.test import TestCase

from accounts.models import User
from catalog.models import Review


class ReviewAuthorBackendTests(TestCase):
    def test_object_permissions(self):
        author = User.objects.create_user("a@example.com", secrets.token_urlsafe(16))
        other = User.objects.create_user("b@example.com", secrets.token_urlsafe(16))
        review = Review.objects.create(author=author, body="Great")
        self.assertTrue(author.has_perm("catalog.change_review", review))
        self.assertFalse(other.has_perm("catalog.change_review", review))
        self.assertFalse(author.has_perm("catalog.change_review"))  # model-level: no
        self.assertFalse(author.has_perm("catalog.view_review", review))

The three backends in AUTHENTICATION_BACKENDS of the scratch project, in the order in which they are consulted:

config/settings.py (excerpt)
AUTH_USER_MODEL = "accounts.User"
AUTHENTICATION_BACKENDS = [
    "accounts.backends.ThrottledModelBackend",
    "accounts.backends.ApiTokenBackend",
    "catalog.backends.ReviewAuthorBackend",
]

For the permission model (groups, has_perm, mixins, guardian and rules for per-object rules) see Permissions & Authorization. Anonymous users have no stored permissions, but a backend can authorise them (useful for reusable apps that delegate every decision), and inactive users can hold permissions only if a backend allows them to authenticate.

Sync and async

The backend interface has an optional async twin for each method: aget_user(user_id) and aauthenticate(request, **credentials), plus async permission methods on ModelBackend and BaseBackend (aget_user_permissions(), aget_group_permissions(), aget_all_permissions(), ahas_perm(), ahas_module_perms()). The rules:

  • If a backend extends BaseBackend and defines only the sync methods, Django synthesises the async ones with sync_to_async, which costs a thread hop per call.

  • A synchronous interface is always required. Nothing synthesises a sync method from an async one.

  • Django’s own backends are natively async since 5.2 (ModelBackend.aauthenticate(), RemoteUserBackend.aauthenticate() and aconfigure_user(), the async permission methods, BaseUserManager.aget_by_natural_key()). If you extend one and change a sync method, change its async twin as well, as the throttling backend does.

  • The user-facing helpers are aauthenticate(), alogin(), alogout(), aget_user(), aupdate_session_auth_hash() (5.0) and request.auser(), plus acheck_password() on the user and ahas_perm() on the user object (5.2).

accounts/tests.py (AsyncTests)
import secrets

from asgiref.sync import sync_to_async
from django.contrib.auth import aauthenticate
from django.core.cache import cache
from django.test import AsyncClient, TestCase

from accounts.backends import hash_token
from accounts.models import ApiToken, User

PASSWORD = secrets.token_urlsafe(16)  # a throwaway value per test run


class AsyncTests(TestCase):
    def setUp(self):
        cache.clear()

    async def test_async_backends(self):
        user = await sync_to_async(User.objects.create_user)("as@example.com", PASSWORD)
        self.assertEqual(await aauthenticate(username="AS@example.com", password=PASSWORD), user)
        await ApiToken.objects.acreate(user=user, digest=hash_token("not-a-real-token"))
        found = await aauthenticate(token="not-a-real-token")
        self.assertEqual(found, user)
        self.assertEqual(found.backend, "accounts.backends.ApiTokenBackend")
        client = AsyncClient()
        self.assertTrue(await client.alogin(username="as@example.com", password=PASSWORD))

An async test method is run by TestCase on an event loop, so the test above calls the async helpers directly. Note that request.user is a lazy object that evaluates the session in sync code; in an async view use await request.auser().

REMOTE_USER: letting the web server authenticate

Intranet sites often sit behind IIS with Integrated Windows Authentication, Apache with mod_auth_sspi or LDAP, a CAS or WebAuth server, or a reverse proxy that performs single sign-on. The web server authenticates the user and hands the name to the application, traditionally in the REMOTE_USER variable. Django can trust it with three pieces:

  • RemoteUserMiddleware (or PersistentRemoteUserMiddleware) in django.contrib.auth.middleware;

  • RemoteUserBackend in django.contrib.auth.backends (inherits from ModelBackend, so permissions still work);

  • the MIDDLEWARE and AUTHENTICATION_BACKENDS settings that connect them.

The value arrives in request.META["REMOTE_USER"] when it is an environment variable (WSGI) and in request.META["HTTP_REMOTE_USER"] when it is an HTTP header (ASGI).

Remote-user configuration for the Bookshelf intranet deployment
MIDDLEWARE = [
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.auth.middleware.RemoteUserMiddleware",   # after AuthenticationMiddleware
]
AUTHENTICATION_BACKENDS = [
    "accounts.remote.ProxyUserBackend",
    "accounts.backends.ThrottledModelBackend",  # fallback so the admin keeps working
]

Replacing ModelBackend with RemoteUserBackend alone disables password login, including the admin, whenever REMOTE_USER is absent, so keep ModelBackend (or a subclass) after it as a fallback. Django’s own user management (the admin and createsuperuser) does not integrate with remote users; those interfaces work with the users stored in the database regardless of the backends. Users with is_active=False cannot authenticate unless you use AllowAllUsersRemoteUserBackend.

Customising the backend

RemoteUserBackend is designed for subclassing:

Member Role

create_unknown_user

True (default) creates the user on first sight; False returns None for unknown names.

clean_username(username)

Cleans the name before lookup or creation (strip an LDAP DN, a domain, whitespace, case).

configure_user(request, user, created=True)

Runs after the user is fetched or created, on every authentication; created tells you which. Use it to sync groups or attributes with an LDAP directory. Since 5.0 the signature must include created.

aconfigure_user(request, user, created=True)

The async twin (5.2), called from async contexts.

user_can_authenticate(user)

False for inactive users.

accounts/remote.py
from django.contrib.auth.backends import RemoteUserBackend
from django.contrib.auth.middleware import PersistentRemoteUserMiddleware, RemoteUserMiddleware


class ProxyUserBackend(RemoteUserBackend):
    create_unknown_user = True

    def clean_username(self, username):
        return username.strip().lower()  # identifiers are emails here

    def configure_user(self, request, user, created=True):
        if created:
            user.set_unusable_password()  # the proxy is the only way in
            user.display_name = user.email.split("@")[0].title()
            user.save(update_fields=["password", "display_name"])
        return user


class ProxyHeaderMiddleware(RemoteUserMiddleware):
    header = "HTTP_X_PROXY_USER"


class SsoLoginMiddleware(PersistentRemoteUserMiddleware):
    header = "HTTP_X_PROXY_USER"

The backend lowercases and strips the identifier (the model’s uniqueness is case-insensitive, and get_or_create uses an exact lookup, so unclean input would create near-duplicates), and marks newly created users with an unusable password: a user created by get_or_create() has an empty password, which can never match but is not flagged as unusable, and the account should have no password path at all.

The middleware, the header and persistence

RemoteUserMiddleware assumes the header is present on every authenticated request. If it is missing, a user previously logged in by the middleware is logged out; if a different user appears, the old session is replaced. That is correct behind Basic auth, but expensive or impossible with Negotiate (Kerberos), where the front end authenticates only a login URL. For that use case PersistentRemoteUserMiddleware is a drop-in replacement that keeps the session until an explicit logout.

To read a different header, subclass and set header to the request.META key. Under WSGI the key for an HTTP header carries the HTTP_ prefix (X-Proxy-User becomes HTTP_X_PROXY_USER):

accounts/remote.py (middleware part, shown in the file above)
class ProxyHeaderMiddleware(RemoteUserMiddleware):
    header = "HTTP_X_PROXY_USER"


class SsoLoginMiddleware(PersistentRemoteUserMiddleware):
    header = "HTTP_X_PROXY_USER"

The tests cover the WSGI environment variable, the ASGI header, a custom header, the replacement of one user by another, the logout on a missing header, the persistent variant, and a forged header being ignored by default under WSGI:

accounts/tests.py (RemoteUserTests)
from asgiref.sync import sync_to_async
from django.test import AsyncClient, TestCase, override_settings
from django.urls import reverse

from accounts.models import User


class RemoteUserTests(TestCase):
    MW = [
        "django.contrib.sessions.middleware.SessionMiddleware",
        "django.contrib.auth.middleware.AuthenticationMiddleware",
        "django.contrib.auth.middleware.RemoteUserMiddleware",
    ]
    BACKENDS = ["accounts.remote.ProxyUserBackend", "accounts.backends.ThrottledModelBackend"]

    @override_settings(MIDDLEWARE=MW, AUTHENTICATION_BACKENDS=BACKENDS)
    def test_remote_user_wsgi(self):
        response = self.client.get(reverse("whoami"), REMOTE_USER="  Carl@Example.com ")
        self.assertEqual(response.status_code, 401)  # whoami wants a token...
        user = User.objects.get(email="carl@example.com")  # ...but the user was created
        self.assertEqual(user.display_name, "Carl")
        self.assertEqual(self.client.session["_auth_user_backend"], "accounts.remote.ProxyUserBackend")
        self.assertFalse(user.has_usable_password())

    @override_settings(MIDDLEWARE=MW, AUTHENTICATION_BACKENDS=BACKENDS)
    def test_header_is_not_trusted_under_wsgi(self):
        self.client.get(reverse("whoami"), headers={"Remote-User": "evil@example.com"})
        self.assertFalse(User.objects.filter(email="evil@example.com").exists())

    @override_settings(MIDDLEWARE=MW, AUTHENTICATION_BACKENDS=BACKENDS)
    def test_other_user_logs_out_previous(self):
        self.client.get("/api/whoami/", REMOTE_USER="a@example.com")
        self.client.get("/api/whoami/", REMOTE_USER="b@example.com")
        self.assertEqual(int(self.client.session["_auth_user_id"]), User.objects.get(email="b@example.com").pk)

    @override_settings(MIDDLEWARE=MW, AUTHENTICATION_BACKENDS=BACKENDS)
    def test_missing_header_logs_out_with_plain_middleware(self):
        self.client.get("/api/whoami/", REMOTE_USER="a@example.com")
        self.assertIn("_auth_user_id", self.client.session)
        self.client.get("/api/whoami/")
        self.assertNotIn("_auth_user_id", self.client.session)

    @override_settings(
        MIDDLEWARE=[MW[0], MW[1], "accounts.remote.SsoLoginMiddleware"], AUTHENTICATION_BACKENDS=BACKENDS
    )
    def test_persistent_middleware_keeps_session(self):
        self.client.get("/api/whoami/", headers={"X-Proxy-User": "p@example.com"})
        self.assertIn("_auth_user_id", self.client.session)
        self.client.get("/api/whoami/")  # no header: session survives
        self.assertIn("_auth_user_id", self.client.session)

    @override_settings(
        MIDDLEWARE=[MW[0], MW[1], "accounts.remote.ProxyHeaderMiddleware"], AUTHENTICATION_BACKENDS=BACKENDS
    )
    def test_custom_header(self):
        self.client.get("/api/whoami/", headers={"X-Proxy-User": "h@example.com"})
        self.assertTrue(User.objects.filter(email="h@example.com").exists())

    @override_settings(MIDDLEWARE=MW, AUTHENTICATION_BACKENDS=BACKENDS)
    async def test_remote_user_asgi_header(self):
        client = AsyncClient()
        await client.get("/api/whoami/", headers={"Remote-User": "asgi@example.com"})
        self.assertTrue(await User.objects.filter(email="asgi@example.com").aexists())

The security rule is absolute: RemoteUserMiddleware must never be deployed where a client can supply the header. The web server or reverse proxy must always set or strip it based on a real authentication check, and must not accept spoofed variants: X-Auth-User and X-Auth_User both normalise to HTTP_X_AUTH_USER in request.META, so a server that blocks only the dashed form is still open. Under WSGI the default REMOTE_USER key is safe, because a key that does not start with HTTP_ can be set only by the WSGI server, not by a request header (the test_header_is_not_trusted_under_wsgi test checks that Remote-User: evil@example.com creates nothing). Under ASGI there is no such protection in any configuration, so ASGI deployments must sit behind a proxy that controls the header.

Two version notes keep upgrades honest. In 5.2 the middleware gained async support (aprocess_request()) and the HTTP_ prefix was applied to custom header values under ASGI; 6.1 restored the pre-5.2 behaviour, so to receive -H "AuthUser: …​" the attribute is HTTP_AUTHUSER (the default REMOTE_USER is unaffected). And subclasses that override process_request() without overriding aprocess_request() were deprecated in 5.2 and are no longer supported in 6.1: override both when you customise the middleware.

Social login and MFA with django-allauth

Writing the account lifecycle yourself (email verification, password reset by code, social providers, multi-factor authentication) is the work django-allauth packages. It is a third-party project (65.19.7 on 2026-10-01, classifiers for Django 5.2, 6.0 and 6.1) with several apps you add as needed: allauth.account (sign-up, login, email confirmation, password reset, email management), allauth.socialaccount plus one app per provider (Google, GitHub, Apple, generic OpenID Connect, and many more), allauth.mfa (TOTP, recovery codes, optionally WebAuthn and passkeys), and others. The protocols those flows implement (OAuth 2.0 authorization code with PKCE, OpenID Connect, tokens and JWT, social login and federation, second factors) are explained in the OAuth section of the site:

This page covers only what is Django-specific: wiring allauth to a custom user model.

Installing it for the custom model

allauth must be told which fields your model uses. Its notion of "username" is a nickname field, not Django’s USERNAME_FIELD: for a model without a username field set ACCOUNT_USER_MODEL_USERNAME_FIELD = None, remove username from ACCOUNT_SIGNUP_FIELDS (an asterisk marks a required field), and choose email as the login method.

config/settings_allauth.py
import os

from .settings import *  # noqa: F401,F403
from .settings import INSTALLED_APPS, MIDDLEWARE

SITE_ID = 1
INSTALLED_APPS = INSTALLED_APPS + [
    "django.contrib.sites",
    "allauth",
    "allauth.account",
    "allauth.socialaccount",
    "allauth.socialaccount.providers.google",
    "allauth.mfa",
]
MIDDLEWARE = MIDDLEWARE + ["allauth.account.middleware.AccountMiddleware"]
ROOT_URLCONF = "config.urls_allauth"

AUTHENTICATION_BACKENDS = ["allauth.account.auth_backends.AuthenticationBackend"]

ACCOUNT_USER_MODEL_USERNAME_FIELD = None
ACCOUNT_LOGIN_METHODS = {"email"}
ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
LOGIN_URL = "account_login"

SOCIALACCOUNT_PROVIDERS = {
    "google": {
        "APP": {
            "client_id": os.environ.get("GOOGLE_CLIENT_ID", ""),
            "secret": os.environ.get("GOOGLE_CLIENT_SECRET", ""),
        },
        "SCOPE": ["profile", "email"],
    }
}
MFA_SUPPORTED_TYPES = ["totp", "recovery_codes"]
config/urls_allauth.py
from allauth.account.decorators import secure_admin_login
from django.contrib import admin
from django.urls import include, path

admin.autodiscover()
admin.site.login = secure_admin_login(admin.site.login)

urlpatterns = [
    path("admin/", admin.site.urls),
    path("accounts/", include("allauth.urls")),
]

Points that matter:

  • AUTHENTICATION_BACKENDS: allauth’s backend replaces ModelBackend. Do not add ModelBackend back just to allow username login in the admin: the admin login view is not rate limited.

  • AccountMiddleware must be in MIDDLEWARE (and django.template.context_processors.request in the template options).

  • Sessions: allauth stores secrets such as verification codes in the session, so it is not designed to work with SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" (signed, not encrypted).

  • The admin: Django’s admin has its own login view, so allauth features (rate limits, 2FA enforcement, custom adapter logic) do not apply to it. The documented workaround, shown in the URL configuration above, is admin.site.login = secure_admin_login(admin.site.login), applied to every AdminSite instance: users must sign in through allauth before the admin login page lets them continue.

  • Settings changed names: older tutorials use ACCOUNT_AUTHENTICATION_METHOD, ACCOUNT_EMAIL_REQUIRED and ACCOUNT_USERNAME_REQUIRED; the installed package marks these deprecated in favour of ACCOUNT_LOGIN_METHODS and ACCOUNT_SIGNUP_FIELDS.

  • Email verification: ACCOUNT_EMAIL_VERIFICATION = "mandatory" blocks login until the address is verified and requires "email*" in ACCOUNT_SIGNUP_FIELDS. The default is "optional".

  • Provider credentials come from SOCIALACCOUNT_PROVIDERS[…​]["APP"] or from a SocialApp row in the admin, never from source code: the example reads them from environment variables, and the empty defaults mean the scratch project runs without real credentials.

  • MFA: add allauth.mfa and install the mfa extra (pip install "django-allauth[mfa,socialaccount]"); MFA_SUPPORTED_TYPES chooses the factors. WebAuthn support is disabled by default.

Running the check and the tests against the allauth settings
$ pip install "django-allauth[mfa,socialaccount]==65.19.7"
$ python manage.py check --settings=config.settings_allauth
System check identified no issues (0 silenced).
$ python manage.py test accounts.test_allauth --settings=config.settings_allauth
Found 3 test(s).
...
Ran 3 tests in 0.1s

OK
accounts/test_allauth.py (the three tests that ran)
import re
import secrets

from django.contrib.auth import get_user_model
from django.core import mail
from django.test import TestCase


class AllauthTests(TestCase):
    def test_signup_login_flow(self):
        r = self.client.get("/accounts/signup/")
        self.assertEqual(r.status_code, 200)
        self.assertNotContains(r, 'name="username"')
        self.assertContains(r, 'name="email"')
        password = secrets.token_urlsafe(16)
        r = self.client.post("/accounts/signup/", {"email": "Eve@Example.com", "password1": password, "password2": password})
        self.assertEqual(r.status_code, 302)
        user = get_user_model().objects.get(email__iexact="eve@example.com")
        self.assertEqual(len(mail.outbox), 1)
        link = re.search(r"http://testserver(/accounts/confirm-email/\S+/)", mail.outbox[0].body).group(1)
        self.assertEqual(self.client.get(link).status_code, 200)
        self.client.post(link)
        r = self.client.post("/accounts/login/", {"login": "eve@example.com", "password": password})
        self.assertEqual(r.status_code, 302)
        self.assertEqual(self.client.session["_auth_user_backend"], "allauth.account.auth_backends.AuthenticationBackend")

    def test_login_page_and_mfa_and_google(self):
        self.assertEqual(self.client.get("/accounts/login/").status_code, 200)
        self.assertEqual(self.client.get("/accounts/2fa/").status_code, 302)  # needs login
        r = self.client.get("/accounts/google/login/")
        self.assertIn(r.status_code, (200, 302))

    def test_admin_login_requires_allauth_login(self):
        r = self.client.get("/admin/login/")
        self.assertEqual(r.status_code, 302)
        self.assertIn("/accounts/login/", r["Location"])

The first test posts the sign-up form (an email and two passwords, no username), reads the verification link from the outbox, confirms the address and logs in by email; the session records allauth.account.auth_backends.AuthenticationBackend as the backend. The second renders the login page, finds the MFA area behind a login, and hits the Google provider’s login URL (which, without real credentials, only redirects or renders). The third shows that the admin login now forwards to allauth’s login. Redirecting to Google and exchanging the code need real client credentials and a registered redirect URI, so that round trip was not run.

Testing a custom user

  • Use get_user_model() in tests and factories instead of importing the class, and generate throwaway passwords per run (secrets.token_urlsafe(16)) instead of writing literals; never reuse a real password in a fixture.

  • A fast password hasher in the test settings (for example MD5, never in production) removes most of the cost of creating users; the scratch project switches it on with an environment variable.

  • Client.force_login() skips the backends; Client.login() goes through them (so it exercises the throttling backend); AsyncClient.alogin() is the async form.

  • A test that uses @override_settings(AUTH_USER_MODEL=…​) swaps the class but not the tables; use a separate settings module and test run, as the accounts_abu variant does.

Source: Testing tools

Pitfalls checklist

  • Swap the user model before the first migrate; fixing it later is a manual data migration.

  • Create the user model in the first migration of its app, and never import django.contrib.auth.models.User in application code.

  • Keep REQUIRED_FIELDS free of the USERNAME_FIELD and the password.

  • Make the identifier unique case-insensitively (UniqueConstraint(Lower("email"))) and look it up with __iexact, in the sync and the async manager method.

  • List fields in every ModelForm for the user; never all on a user form.

  • A custom UserAdmin needs its own fieldsets and add_fieldsets; include usable_password in the latter on 5.1+.

  • Do not call ModelBackend permission methods on a user model without PermissionsMixin.

  • Reusable apps must not ship a user model; use settings.AUTH_USER_MODEL.

  • A backend that overrides authenticate() must override aauthenticate() too; synthesised async methods cost a thread hop.

  • Changing AUTHENTICATION_BACKENDS does not affect live sessions; clear them to force re-authentication.

  • Never trust the REMOTE_USER header unless the server strips it from clients, and keep a ModelBackend fallback so the admin still works.

  • Do not monkey-patch the user class (add_to_class, extra attributes) from another app; use a profile model or a custom user.

  • Log out with a POST (LogoutView accepts only POST since 5.0); a logout link is a CSRF vector and now returns 405.

Sync and async at a glance

Operation Sync Async

Authenticate

authenticate(), backend authenticate()

aauthenticate(), backend aauthenticate()

Load the user from the session

get_user(), request.user

aget_user(), await request.auser()

Log in and out

login(), logout()

alogin(), alogout()

Check passwords

check_password()

acheck_password()

Permissions

has_perm(), has_perms(), get_all_permissions()

ahas_perm(), ahas_perms(), aget_all_permissions() (5.2)

Look up by identifier

get_by_natural_key()

aget_by_natural_key() (5.2)

Remote user

RemoteUserMiddleware.process_request(), RemoteUserBackend.configure_user()

aprocess_request(), aconfigure_user() (5.2)

Where the books are outdated

The four reference books (written for Django 1.11–2.1), and tutorials derived from them, describe custom users in a way that needs updating:

  • Custom users that predate the current forms and admin. Email-as-username examples built on AbstractUser, BaseUserManager and USERNAME_FIELD are still structurally right, but they use ugettext_lazy (removed in 4.0, use gettext_lazy), a REQUIRED_FIELDS = ["email"] left in place that fails auth.E002, a creation form based on UserCreationForm (use BaseUserCreationForm), and an admin whose add_fieldsets has no usable_password (5.1).

  • Email login by a custom backend. The old recipe of an EmailBackend that looks up a user by email and then checks the password is unnecessary once USERNAME_FIELD = "email": ModelBackend does exactly that. Keep the pattern only for logging in with either a username or an email on the default model.

  • GET logout links in templates return 405 since the 5.0 LogoutView accepts POST only.

  • Swapping the user late. Advice to add a custom user "when you need it" is the opposite of Django’s guidance; the swap belongs in the first commit.

  • User.add_to_class() and monkey-patching to add fields or methods to the built-in user are fragile and are not supported; use a profile model or a custom user.

  • Async ignorance. Books from the synchronous era do not mention aauthenticate(), alogin(), request.auser() or async backends; those arrived in 5.0 and 5.2 (see below).

  • Settings that moved in third-party packages. allauth tutorials that set ACCOUNT_EMAIL_REQUIRED and ACCOUNT_AUTHENTICATION_METHOD should use ACCOUNT_SIGNUP_FIELDS and ACCOUNT_LOGIN_METHODS.

  • Security habits. Secrets in settings.py, fields = "all" on user forms, and unprotected admin login pages appear in the older material; read secrets from the environment and use explicit fields.

What changed recently

Version Change

3.2

DEFAULT_AUTO_FIELD introduced; a custom user with the default key type gets a warning until it is set.

4.0

ugettext family removed (use gettext/gettext_lazy).

4.1

RemoteUserBackend.configure_user() can synchronise attributes of existing users with a remote system (the created argument); SECRET_KEY_FALLBACKS introduced, which is what get_session_auth_fallback_hash() uses.

5.0

aauthenticate(), aget_user(), alogin(), alogout(), aupdate_session_auth_hash() and acheck_password(); HttpRequest.auser(); created=True mandatory in configure_user() overrides; LogoutView accepts POST only.

5.1

AdminUserCreationForm and the usable_password option (and the matching option on AdminPasswordChangeForm); LoginRequiredMiddleware; the PBKDF2 default iteration count and the scrypt parallelism increase again.

5.2

Native async backend methods (ModelBackend.aauthenticate() and the async permission methods, RemoteUserBackend.aauthenticate()/aconfigure_user(), BaseUserManager.aget_by_natural_key()); RemoteUserMiddleware async support; login() with user=None falling back to request.user deprecated.

6.0

BigAutoField becomes the default primary key type for new projects.

6.1

RemoteUserMiddleware custom header values no longer get an extra HTTP_ prefix under ASGI (pre-5.2 behaviour restored); process_request()-only overrides of RemoteUserMiddleware and the user=None fallback in login()/alogin() removed.