Filter concepts by levelShowing all levels.

Django · Section 37

Authentication

Level
intermediate
Read
26 min
Concepts
3

AUTH_USER_MODEL and get_user_model() are the indirection every Django internal goes through to reference "the" user model — deciding on a custom user model (AbstractUser for the default shape plus extra fields, AbstractBaseUser for a from-scratch shape) is a decision to make before the first migration, since swapping it later is not a safe, casual operation. authenticate() checks credentials against AUTHENTICATION_BACKENDS and returns a user or None without touching the session; login(request, user) is the separate step that actually establishes it, and should only ever run on an already-verified value. Passwords are always hashed via set_password()/check_password() (PASSWORD_HASHERS, PBKDF2 by default) and validated via AUTH_PASSWORD_VALIDATORS at set/change time; password reset uses a signed, time-limited token rather than requiring the old password. is_staff gates admin-site access, is_superuser bypasses every permission check entirely, Groups bundle reusable Permissions, and has_perm() is the actual unit django.contrib.auth checks, whether granted directly or via a group.

What is true here

  1. AUTH_USER_MODEL + get_user_model() is the indirection to use everywhere — never import User directly; decide on a custom user model before the first migration.
  2. authenticate() checks credentials and returns a user or None without touching the session; login(request, user) is the separate step that establishes it.
  3. AUTHENTICATION_BACKENDS is an ordered list, letting multiple authentication mechanisms (SSO, API keys, legacy hashes) coexist.
  4. set_password()/check_password() are the only supported way to write/verify a password; AUTH_PASSWORD_VALIDATORS run at set/change time, not automatically on every save().
  5. is_staff gates admin access, is_superuser bypasses all permission checks, Groups bundle reusable Permissions, and has_perm() is the actual unit checked.

What you will be able to do

  • Decide correctly, and early, whether a project needs a custom user model
  • Implement a login/logout flow using authenticate() and login() correctly, never skipping the credential check
  • Manage passwords through the supported hashing/validation/reset APIs, never touching a raw password field
  • Use is_staff/is_superuser/Groups/Permissions for the access-control layer each is actually meant for

The User model

The default User model, AUTH_USER_MODEL, and swapping it for a custom one.

The User model, and swapping it for a custom one

coreintermediate

django.contrib.auth.models.User is the default user model — username, email, password (hashed), first_name/last_name, is_staff/is_superuser/is_active, and groups/permissions. AUTH_USER_MODEL in settings.py points at whichever user model is actually in use; swapping it to a custom model (extending AbstractUser for a mostly-default shape, or AbstractBaseUser for a from-scratch one, e.g. email-only login) must happen BEFORE the first migration, since every other app's ForeignKey(User) actually resolves through this setting.

Think of it as

Django never hardcodes "the user model is auth.User" anywhere in its own code — every internal reference (ForeignKey to a user, request.user, the authentication system) goes through django.conf.settings.AUTH_USER_MODEL and get_user_model(), specifically so a project CAN swap in a custom model. The trap is that this indirection only helps if it's used from the start: the first migration that creates auth-related tables bakes in whichever model AUTH_USER_MODEL pointed at that day, and every other app's ForeignKey(User) — if written as a direct import rather than settings.AUTH_USER_MODEL — resolves against that same baked-in model. Swapping later means either a genuinely painful data migration across every table with a user foreign key, or starting the database over — which is why Django's own docs are blunt that this is a decision to make BEFORE the first migrate, not something to defer.

python
# settings.py
AUTH_USER_MODEL = "accounts.User"

# accounts/models.py
class User(AbstractUser):
    ...

What we're doing: Add a custom user model with email as the login identifier, before any migrations exist.

accounts/models.pypython
class User(AbstractUser):
    email = models.EmailField(unique=True)
    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []   # email + password already required by USERNAME_FIELD/AbstractBaseUser

    def __str__(self):
        return self.email
2
unique=True on email is required once it becomes USERNAME_FIELD — Django needs one field that uniquely identifies a user for login.
3
USERNAME_FIELD tells the auth system which field to treat as the login identifier — it does not have to be literally called "username".

Why this works: A project whose users log in with email, not a separate username, should say so explicitly via USERNAME_FIELD rather than keeping an unused username field around purely because AbstractUser happens to define one.

Building out a project for months on the default User, then trying to swap in a custom model later

Wrong

text
dozens of migrations already applied against auth.User;
now needs a custom field on the user →
attempts to swap AUTH_USER_MODEL after the fact

Better

text
AUTH_USER_MODEL set to a custom model (even a nearly-empty
AbstractUser subclass) in the FIRST migration of a new project,
whether or not extra fields are needed yet

What you see: Every existing ForeignKey(User) across every app now points at the wrong model; migration state is fundamentally split between "what auth.User's migrations already created" and "what the new custom model needs" — commonly resolved only by a full database reset, unacceptable once real data exists.

Why: Django's own documentation states this directly: changing AUTH_USER_MODEL after tables have already been created for the default (or another) user model is not a supported operation to do casually — the recommended practice is to start every new project with a custom user model from its very first migration, even one that adds nothing yet, purely to keep the option open cheaply.

Every internal reference goes through AUTH_USER_MODEL

settings.py

AUTH_USER_MODEL

"accounts.User"

Django internals

get_user_model()

ForeignKey(settings.AUTH_USER_MODEL)

accounts/models.py

class User(AbstractUser)

  • settings.py
    • AUTH_USER_MODEL — "accounts.User"
  • Django internals
    • get_user_model()
    • ForeignKey(settings.AUTH_USER_MODEL)
  • accounts/models.py
    • class User(AbstractUser)

AbstractUser vs AbstractBaseUser

AbstractUser vs AbstractBaseUser
Base classStarts withUse when
AbstractUserAll default User fields (username, email, names, flags)The default shape is right, just needs an extra field or two
AbstractBaseUserOnly password hashing + auth API, no other fieldsA genuinely different shape — e.g. email as the only login identifier

Together

python
class User(AbstractUser):
    phone_number = models.CharField(max_length=20, blank=True)

# vs a from-scratch shape:
class User(AbstractBaseUser):
    email = models.EmailField(unique=True)
    USERNAME_FIELD = "email"

Remember: AUTH_USER_MODEL + get_user_model() is the indirection Django uses everywhere internally — never import User directly. Decide on a custom user model (AbstractUser for default-shape-plus-fields, AbstractBaseUser for a from-scratch shape) BEFORE the first migration; swapping later is not a safe, casual operation.

See also: login logout and authentication backends · passwords staff and permissions · modeladmin basics

Advertisement

Login, logout, and authentication backends

authenticate() vs login(), AUTHENTICATION_BACKENDS, and session authentication.

Login, logout, and authentication backends

coreintermediate

authenticate(request, username=..., password=...) checks credentials against every backend listed in AUTHENTICATION_BACKENDS (default: just ModelBackend, which checks username+password against the user model) and returns a User or None — it does NOT log anyone in. login(request, user) is the separate step that actually establishes the session, storing the user's id and (since Django tracks it) which backend authenticated them. logout(request) clears the session. Session authentication means the ongoing, per-request "who is this" comes from request.session, populated by AuthenticationMiddleware reading the session cookie — not from re-checking a password on every request.

Think of it as

authenticate() and login() are deliberately two separate functions because CHECKING credentials and ESTABLISHING a session are different concerns — a system might want to authenticate without logging in (an API token check, a "verify your current password" re-auth flow), or need custom logic between the two. AUTHENTICATION_BACKENDS being a LIST, tried in order, exists so multiple ways of proving identity can coexist — ModelBackend (username+password) alongside an SSO backend, an API-key backend, or a legacy-password-hash backend during a migration — and authenticate() simply returns the first backend's successful result, stamping request.session with which one succeeded so later code (or a permissions backend) can know. Session authentication itself is just AuthenticationMiddleware reading the already-established session on every subsequent request and setting request.user — no re-checking of the password happens per request, which is exactly why keeping the session secure (HttpOnly, Secure, appropriate expiry) matters as much as the login step itself.

python
user = authenticate(request, username=..., password=...)
if user is not None:
    login(request, user)
...
logout(request)

What we're doing: A login view that checks credentials, logs the user in only on success, and never assumes an unverified object is safe to log in.

accounts/views.pypython
def login_view(request):
    form = LoginForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        user = authenticate(
            request,
            username=form.cleaned_data["username"],
            password=form.cleaned_data["password"],
        )
        if user is not None:
            login(request, user)
            return redirect("dashboard")
        form.add_error(None, "Invalid username or password.")
    return render(request, "login.html", {"form": form})
4
Passing request into authenticate() lets backends that need it (e.g. rate-limiting a specific client) access it — always pass it even though ModelBackend itself doesn't need it.
6
The password is read from form.cleaned_data, never logged or stored anywhere else — authenticate() is the only place it should be handled.

Why this works: Checking user is not None before calling login() is the entire point of splitting authenticate() and login() into two functions — skipping that check and calling login() on whatever authenticate() returned (including None) would be a hard crash, not a security issue, but it shows why the two-step API exists: nothing gets a session established without an explicit, checked success.

Calling login() with a manually-fetched user object, skipping authenticate() entirely

Wrong

python
def login_view(request):
    user = User.objects.get(username=request.POST["username"])
    login(request, user)   # password was never actually checked!

Better

python
def login_view(request):
    user = authenticate(request, username=request.POST["username"], password=request.POST["password"])
    if user is not None:
        login(request, user)

What you see: Any username that exists logs the submitting client in immediately, with no password check at all — a complete authentication bypass, not merely a subtle bug.

Why: login(request, user) trusts its caller completely — it has no way to know whether "user" was actually verified or just fetched by username. authenticate() is the ONLY function in the auth system that actually checks a credential against a backend; skipping it and calling login() directly with a fetched object removes the credential check entirely, not just the convenience of the combined flow.

authenticate() checks credentials; login() is the separate step that starts the session
Browser
login_view
authenticate()
Session
  1. 1. POST username + password
  2. 2. checks AUTHENTICATION_BACKENDS
  3. 3. User or None
  4. 4. login(request, user) — only if not None
  1. Browser → login_view: POST username + password
  2. login_view → authenticate(): checks AUTHENTICATION_BACKENDS
  3. authenticate() → login_view: User or None
  4. login_view → Session: login(request, user) — only if not None

authenticate() vs login() vs logout()

authenticate() vs login() vs logout()
FunctionDoes
authenticate(request, **credentials)checks credentials against AUTHENTICATION_BACKENDS, returns User or None — no session change
login(request, user)establishes the session for an already-verified user
logout(request)clears the entire session

Together

python
from django.contrib.auth import authenticate, login, logout

def login_view(request):
    user = authenticate(request, username=request.POST["username"], password=request.POST["password"])
    if user is not None:
        login(request, user)
        return redirect("dashboard")
    return render(request, "login.html", {"error": "Invalid credentials"})

Remember: authenticate() checks credentials and returns a user or None — it never touches the session. login(request, user) is the separate step that establishes it; only ever call it on a value authenticate() (or equivalent) actually verified. AUTHENTICATION_BACKENDS is a list tried in order, letting multiple auth mechanisms coexist. request.user only exists because AuthenticationMiddleware reads the session on every request.

See also: the user model · passwords staff and permissions · session storage backends

Advertisement

Passwords, staff, superuser, groups, and permissions

Hashing, validation, reset, and the access-control hierarchy.

Password hashing/validation/reset, and staff/superuser/groups/permissions

coreintermediate

Django never stores a plain password — set_password()/check_password() run it through PASSWORD_HASHERS (PBKDF2 by default, a slow, salted algorithm designed to resist brute-forcing). AUTH_PASSWORD_VALIDATORS runs a separate set of rules (minimum length, not too similar to the user's own attributes, not a common password, not all-numeric) at signup/change time, not at login. Password reset uses a signed, time-limited, single-use token (PasswordResetTokenGenerator) emailed to the user, so a reset link works without ever exposing or requiring the old password. is_staff gates admin-site access; is_superuser bypasses ALL permission checks; Groups bundle permissions for reuse across many users; individual Permissions (model-level, or custom ones) are the actual unit django.contrib.auth checks against.

Think of it as

Password hashing is designed to be SLOW on purpose — PBKDF2 (and Argon2/bcrypt, both supported) intentionally costs real CPU time per hash, so that even if a database of hashes leaks, brute-forcing them back to plaintext is expensive at scale, not just "hidden." Validators exist as a SEPARATE layer from hashing because hashing only protects an already-chosen password — validators try to stop a weak password from being chosen in the first place, checked only when a password is set/changed, never on every login (which uses check_password() against the stored hash instead). Password reset's signed-token approach exists specifically so a reset flow never requires knowing (or transmitting) the old password at all — the token itself, not a password, is temporary proof "this really is the person with access to this email." Staff/superuser/groups/permissions form a deliberate hierarchy: is_staff is a coarse admin-site gate, is_superuser is an escape hatch that skips permission checking entirely (powerful and rarely what a real business rule actually wants), and Groups exist because assigning the same 8 permissions to 200 users individually doesn't scale — a Group is just a named, reusable bundle of Permissions.

python
user.set_password(raw_password)
user.check_password(raw_password)
user.has_perm("app_label.codename")

What we're doing: Change a user's password through the supported API, running it through both hashing and the configured validators.

accounts/views.pypython
from django.contrib.auth.password_validation import validate_password
from django.core.exceptions import ValidationError

def change_password(user, new_raw_password):
    validate_password(new_raw_password, user=user)   # raises ValidationError on failure
    user.set_password(new_raw_password)
    user.save()
4
validate_password() runs every configured AUTH_PASSWORD_VALIDATORS rule — call it explicitly when setting a password outside of a Django form/serializer that would normally do this automatically.
5
set_password() hashes via the first entry in PASSWORD_HASHERS — never assign to user.password directly with a raw string.

Why this works: A password-change code path outside Django's built-in forms (a custom API endpoint, a management command) is easy to write in a way that hashes correctly but silently skips validation — calling validate_password() explicitly is the only way to get the same strength rules a form would have applied automatically.

Setting user.password directly instead of going through set_password()

Wrong

python
user.password = new_raw_password   # stored as PLAINTEXT
user.save()

Better

python
user.set_password(new_raw_password)   # hashed via PASSWORD_HASHERS
user.save()

What you see: The user can never log in again (check_password() compares a hash against what it expects to be a hash, and finds plaintext instead) — and until that's noticed, a plaintext password sits in the database, a severe security exposure if the database is ever read by anyone unauthorized, backed up, or leaked.

Why: user.password is a plain CharField storing a hash string — Django does not intercept a direct assignment to hash it automatically. set_password() is the ONLY method that actually runs the value through PASSWORD_HASHERS; assigning to the field directly stores exactly whatever string was given, hashed or not.

Four access-control layers, coarsest to most granular

is_superuser

bypasses every permission check entirely

is_staff

can log into /admin/ at all

Groups

reusable named bundles of permissions

Permissions

the actual unit has_perm() checks

  1. is_superuser — bypasses every permission check entirely
  2. is_staff — can log into /admin/ at all
  3. Groups — reusable named bundles of permissions
  4. Permissions — the actual unit has_perm() checks

The four access-control layers

The four access-control layers
LayerControls
is_staffcan log into /admin/ at all
is_superuserbypasses every permission check entirely
Groupsreusable named bundles of permissions, assigned to many users at once
Permissionsthe actual unit checked by has_perm() — model-level (add/change/delete/view) or custom

Together

python
user.set_password("a-strong-new-password")
user.save()

user.has_perm("articles.change_article")   # True if granted directly OR via a group
user.is_superuser   # True bypasses has_perm() checks entirely, regardless of the above

Remember: set_password()/check_password() are the only supported way to write/verify a password — never assign user.password directly. AUTH_PASSWORD_VALIDATORS run at set/change time via validate_password(), not automatically on every save(). is_staff gates admin access; is_superuser bypasses ALL permission checks; Groups are reusable, named permission bundles; has_perm() is the actual unit checked, direct or via a group.

See also: the user model · login logout and authentication backends · model permissions and groups

Advertisement