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 |
|
Log in with an email address |
Set |
|
Keep the user table minimal |
|
|
Point a foreign key at the user |
|
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. |
|
Forms for a custom user |
Subclass |
|
Admin for a custom user |
Subclass |
|
Authenticate against another source |
A backend class with |
|
Let the web server authenticate |
|
|
Social login, MFA, email verification |
django-allauth, configured for the custom model. |
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 |
|
Throw-away prototypes and tutorials only. |
|
The default fields and behaviour in your own table, ready to grow. |
Almost every new project, including those that log in with email. |
|
Only password, |
You want a smaller table, a non-username identifier, or no first/last name. |
Profile model ( |
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:
$ 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, …).
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_FIELDSmust not contain theUSERNAME_FIELD.AbstractUsersetsREQUIRED_FIELDS = ["email"]; onceemailis the username, leaving that value in place fails the system check withauth.E002.REQUIRED_FIELDSis only whatcreatesuperuserprompts 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.COMis stored asAnn@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__iexactinget_by_natural_key();ModelBackendcalls 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 whatModelBackenduses 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 overrideclean(), callsuper(). -
use_in_migrations = Trueserialises 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 returnsFalse. -
The stock
UserManagerhas async creation helpers (acreate_user(),acreate_superuser()); a subclass ofBaseUserManagerdoes not, so wrap your owncreate_userwithsync_to_asyncor 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:
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:
$ 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:
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).
Source: System check framework: auth
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).
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)
$ 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 |
|---|---|
|
Name of the unique identifier field. Must be |
|
Extra fields |
|
Optional; the name of the email field, returned by |
|
Provided as |
|
Optional; the admin shows them in object history and in its header greeting. |
A manager with |
Required by |
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()).
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]
Source: Specifying a custom user model
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).
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)
$ 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 |
|
|
Manager |
Stock |
Always custom |
Admin |
Existing |
|
Built-in forms |
Compatible after a |
|
Cost of a new requirement |
Add a field and migrate |
Add a field and migrate; add |
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 |
Source: Extending Django’s default User
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 |
|---|---|---|
|
|
Resolved lazily, so import order does not matter and the model can be defined before the user app is loaded. |
Signal receivers ( |
|
|
Views, forms, admin, tests, management commands |
|
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 |
Calling it before the user app’s models have been imported fails, so keep it out of module-level code that can run earlier. |
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)
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
Source: Referencing the User model
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:
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.
Source: Custom users and proxy models
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 |
|---|---|
|
Work with any |
|
Works if the model has an email field named by |
|
Tied to the built-in |
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.
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):
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>
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.
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:
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")}),
)
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.
The rules that follow from this picture:
-
The first backend that returns a user wins. A backend that returns
Nonepasses the credentials on; one that raisesPermissionDeniedstops the whole attempt, andauthenticate()returnsNone. -
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 withauthenticate(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 returnsNonebecause there is no username or password. A subclass of it that does work before callingsuper()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 itsget_user(). If you changeAUTHENTICATION_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()setsuser.backend. That attribute is whatlogin()stores. When several backends are configured and the user object did not come fromauthenticate()(for example, a user fetched by id),login(request, user)raises aValueErrorasking for thebackendargument. -
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 withuser.get_session_auth_hash()(and triesget_session_auth_fallback_hash()for eachSECRET_KEY_FALLBACKSvalue), 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 |
|---|---|
|
Rejects every user and grants no permissions. Subclass it and override what you need. |
|
The default. Authenticates by |
|
Same, but |
|
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.
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:
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})
$ 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.
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.
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:
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:
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
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))
Source: Handling object permissions
The three backends in AUTHENTICATION_BACKENDS of the scratch project, in the order in which they are consulted:
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
BaseBackendand defines only the sync methods, Django synthesises the async ones withsync_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()andaconfigure_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) andrequest.auser(), plusacheck_password()on the user andahas_perm()on the user object (5.2).
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))
Source: Adding an async interface
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(orPersistentRemoteUserMiddleware) indjango.contrib.auth.middleware; -
RemoteUserBackendindjango.contrib.auth.backends(inherits fromModelBackend, so permissions still work); -
the
MIDDLEWAREandAUTHENTICATION_BACKENDSsettings 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).
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 |
|---|---|
|
|
|
Cleans the name before lookup or creation (strip an LDAP DN, a domain, whitespace, case). |
|
Runs after the user is fetched or created, on every authentication; |
|
The async twin (5.2), called from async contexts. |
|
|
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):
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:
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.
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"]
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")),
]
Source: django-allauth quickstart
Points that matter:
-
AUTHENTICATION_BACKENDS: allauth’s backend replacesModelBackend. Do not addModelBackendback just to allow username login in the admin: the admin login view is not rate limited. -
AccountMiddlewaremust be inMIDDLEWARE(anddjango.template.context_processors.requestin 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 everyAdminSiteinstance: 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_REQUIREDandACCOUNT_USERNAME_REQUIRED; the installed package marks these deprecated in favour ofACCOUNT_LOGIN_METHODSandACCOUNT_SIGNUP_FIELDS. -
Email verification:
ACCOUNT_EMAIL_VERIFICATION = "mandatory"blocks login until the address is verified and requires"email*"inACCOUNT_SIGNUP_FIELDS. The default is"optional". -
Provider credentials come from
SOCIALACCOUNT_PROVIDERS[…]["APP"]or from aSocialApprow 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.mfaand install themfaextra (pip install "django-allauth[mfa,socialaccount]");MFA_SUPPORTED_TYPESchooses the factors. WebAuthn support is disabled by default.
$ 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
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 theaccounts_abuvariant 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.Userin application code. -
Keep
REQUIRED_FIELDSfree of theUSERNAME_FIELDand 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
fieldsin everyModelFormfor the user; neverallon a user form. -
A custom
UserAdminneeds its ownfieldsetsandadd_fieldsets; includeusable_passwordin the latter on 5.1+. -
Do not call
ModelBackendpermission methods on a user model withoutPermissionsMixin. -
Reusable apps must not ship a user model; use
settings.AUTH_USER_MODEL. -
A backend that overrides
authenticate()must overrideaauthenticate()too; synthesised async methods cost a thread hop. -
Changing
AUTHENTICATION_BACKENDSdoes not affect live sessions; clear them to force re-authentication. -
Never trust the
REMOTE_USERheader unless the server strips it from clients, and keep aModelBackendfallback 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 (
LogoutViewaccepts 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 |
|
|
Load the user from the session |
|
|
Log in and out |
|
|
Check passwords |
|
|
Permissions |
|
|
Look up by identifier |
|
|
Remote user |
|
|
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,BaseUserManagerandUSERNAME_FIELDare still structurally right, but they useugettext_lazy(removed in 4.0, usegettext_lazy), aREQUIRED_FIELDS = ["email"]left in place that failsauth.E002, a creation form based onUserCreationForm(useBaseUserCreationForm), and an admin whoseadd_fieldsetshas nousable_password(5.1). -
Email login by a custom backend. The old recipe of an
EmailBackendthat looks up a user by email and then checks the password is unnecessary onceUSERNAME_FIELD = "email":ModelBackenddoes 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
LogoutViewaccepts 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_REQUIREDandACCOUNT_AUTHENTICATION_METHODshould useACCOUNT_SIGNUP_FIELDSandACCOUNT_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 |
|
4.0 |
|
4.1 |
|
5.0 |
|
5.1 |
|
5.2 |
Native async backend methods ( |
6.0 |
|
6.1 |
|
Sources: Django 6.1 release notes, 5.2, 5.1, 5.0, 4.1
References
-
Django 2 by Example (Packt), consulted for the Bookshelf scenario (latest edition: Django 5 By Example)