skip to content

In Django, what does the default ModelBackend check when authenticate() is called, and why can an inactive user not log in?

level: juniorimportance: should knowfreq 45%

answer

  1. the one default backend
  2. lookup by USERNAME_FIELD
  3. hash check, not string compare
  4. user_can_authenticate() gate

basics

~10 s

ModelBackend, the default AUTHENTICATION_BACKENDS entry, looks the user up by USERNAME_FIELD, verifies the password hash with check_password(), and returns the user only if user_can_authenticate() passes, which rejects is_active=False.

solid answer

~30 s

`AUTHENTICATION_BACKENDS` defaults to `["django.contrib.auth.backends.ModelBackend"]`. Its `authenticate(request, username=None, password=None, **kwargs)` reads the username (or the kwarg named by the user model's `USERNAME_FIELD`), fetches the user with `get_by_natural_key()`, and calls `check_password()` against the stored hash. When no user matches it still runs the default hasher once, so a missing account takes about as long as a wrong password. Finally `user_can_authenticate()` returns `getattr(user, "is_active", True)`: an inactive user gets `None`, exactly like bad credentials. `get_user()` applies the same check when later requests reload the user. `AllowAllUsersModelBackend` overrides that one method to allow inactive users. ModelBackend has no rate limiting.

code

python · 8 lines
python
from django.contrib.auth import authenticate

user = authenticate(request, username="alice", password="correct horse")
if user is None:
    # wrong password, unknown username, or is_active=False: all look the same
    ...
else:
    print(user.backend)  # 'django.contrib.auth.backends.ModelBackend'

go deeper

for a junior

Recall the default backend's dotted path and the three checks it performs: lookup by USERNAME_FIELD, password hash check, and the is_active gate.

for a middle

Explain user_can_authenticate() as the single overridable hook, why the dummy hash exists, and why the inactive-user message never shows with the default backend.

for a senior

Point out what ModelBackend leaves to you in production: brute-force throttling, case-insensitive usernames, and deactivation taking effect on the next request via get_user().

for a principal

Frame the backend as the seam between identity sources: decide which store is authoritative and keep policy such as suspension messaging in the form, not scattered backends.

## What ModelBackend is An **authentication backend** in Django is a class with an `authenticate()` method that turns credentials into a user object (or `None`) and a `get_user()` method that turns a stored primary key back into a user. The `AUTHENTICATION_BACKENDS` setting lists them by dotted path. Its default in Django 6.1 is a single entry: ```python AUTHENTICATION_BACKENDS = ["django.contrib.auth.backends.ModelBackend"] ``` `ModelBackend` authenticates against the model named by `AUTH_USER_MODEL`, the built-in `User` or a custom one. It also answers permission queries, but that half belongs to the permissions topic; this answer is about the credential half. ## The steps inside authenticate() When `django.contrib.auth.authenticate(request, username=..., password=...)` reaches `ModelBackend`, the backend does this: 1. **Find the identifier.** It takes `username`; if that is `None` it looks in the keyword arguments for the name held in `USERNAME_FIELD`, so a user model whose `USERNAME_FIELD` is `"email"` can be called with `email=`. 2. **Bail out on missing input.** If the identifier or the password is `None`, it returns `None` at once. 3. **Load the user** with `UserModel._default_manager.get_by_natural_key(username)`, which is a `get()` on `USERNAME_FIELD`. A `DoesNotExist` is caught and the user is treated as missing. 4. **Check the password.** For an existing user it calls `user.check_password(password)`, which hashes the candidate with the algorithm recorded in the stored hash and compares. For a missing user it hashes the password anyway with the default hasher and discards the result. 5. **Apply the active gate** with `self.user_can_authenticate(user)` and return the user only if both checks pass. Step 4's dummy hash is a **timing defence**: without it, "no such user" would return in microseconds while "wrong password" would take the full hashing time, and an attacker could enumerate accounts by measuring response times. ## user_can_authenticate() and is_active `user_can_authenticate()` is a one-liner: `return getattr(user, "is_active", True)`. That has three consequences worth knowing: - A user with `is_active=False` gets `None` from `authenticate()`, indistinguishable from a wrong password. - A custom user model **without** an `is_active` attribute is always allowed. - `ModelBackend.get_user()` applies the same check, so a user deactivated while logged in becomes anonymous on their next request. Because the backend already returns `None`, the login form's own `confirm_login_allowed()` check (which raises the "This account is inactive." error) never runs with the default backend: the user sees the generic invalid-login message. That message only appears when the backend lets inactive users through. | Backend | Inactive user with the right password | Typical use | |---|---|---| | `ModelBackend` | `authenticate()` returns `None` | The default | | `AllowAllUsersModelBackend` | Returns the user; the login form must decide | You want a custom "account suspended" flow | | `RemoteUserBackend` | Returns `None` | Trusting a web-server-provided username | | `AllowAllUsersRemoteUserBackend` | Returns the user | The same, without the active gate | ## What ModelBackend does not do - **No rate limiting or lockout.** The docs say so explicitly; throttling belongs in a custom backend, the web server, or a dedicated package. - **No email lookup** unless email is the model's `USERNAME_FIELD`. - **No case folding.** `get_by_natural_key()` is an exact `get()`, so `Alice` and `alice` are different usernames on a case-sensitive database collation. - **It does not log anyone in.** `authenticate()` only returns a user annotated with `user.backend`; persisting it in the session is `login()`'s job. ## Why interviewers ask it The question checks whether a candidate knows that authentication is pluggable and that the default is a plain database lookup plus a hash check. The follow-up is usually "how would you change it?", which leads to subclassing `ModelBackend` and overriding a single hook such as `user_can_authenticate()` rather than rewriting the whole method. ## Customising the checks without rewriting them `ModelBackend` is designed to be subclassed at its hooks rather than copied. Common, small overrides: - **Extra login conditions.** Override `user_can_authenticate(user)` to also require, say, a verified-email flag, calling `super()` first so the `is_active` rule stays. Because `get_user()` calls the same method, the condition is re-applied when later requests reload the user. - **Different lookup.** Override `authenticate()` only, keeping the inherited `get_user()` and permission methods. - **Letting inactive users through.** Use `AllowAllUsersModelBackend`, which returns `True` from `user_can_authenticate()`, and move the decision into the login form. After writing the subclass, replace the default dotted path in `AUTHENTICATION_BACKENDS` with yours; listing both would let the stricter rule be bypassed by the plain `ModelBackend` further down the list.

  • How would you let suspended users see a specific "account suspended" message instead of the generic login error?
    Put `django.contrib.auth.backends.AllowAllUsersModelBackend` in `AUTHENTICATION_BACKENDS` so inactive users with valid credentials are returned, then subclass `AuthenticationForm` and override `confirm_login_allowed(user)` to raise a `ValidationError` with your own message. Pass the form to `LoginView` via `authentication_form`. Keep in mind the user then exists as authenticated inside the form, so the form must be the gate.
  • Why does ModelBackend hash a password even when the username does not exist?
    To keep the response time for an unknown username close to that of a known username with a wrong password. Without the dummy hash, the fast "not found" path would let an attacker probe which accounts exist by timing the login endpoint.

saying these in an interview costs you the question

  • ModelBackend compares the submitted password to a stored plaintext column.
  • An inactive user sees 'This account is inactive' with the default backend.
  • ModelBackend locks an account after several failed attempts.
  • authenticate() both checks credentials and logs the user in.
  • AUTHENTICATION_BACKENDS is empty by default and must be configured.