skip to content

In Django, how does authenticate() walk the AUTHENTICATION_BACKENDS list, and how does a backend returning None differ from raising PermissionDenied?

level: middleimportance: must knowfreq 52%

answer

  1. first positive match wins
  2. None means 'not mine, next'
  3. PermissionDenied means 'stop'
  4. signature must accept the credentials

basics

~10 s

authenticate() tries each backend in AUTHENTICATION_BACKENDS order and returns the first user found. Returning None passes to the next backend; raising PermissionDenied stops the chain, so authenticate() returns None without trying later backends.

solid answer

~40 s

`django.contrib.auth.authenticate(request, **credentials)` loads every backend listed in `AUTHENTICATION_BACKENDS`, in order. It first checks that the backend's `authenticate()` signature can bind the supplied keyword arguments; if not, that backend is skipped silently. Otherwise it calls it: `None` means "not my user" and the loop moves on; a user object ends the loop, gets `user.backend` set to the backend's dotted path, and is returned. A backend that raises `PermissionDenied` ends the loop at once: later backends are never tried. When no backend succeeds, including after a `PermissionDenied`, Django sends `user_login_failed` with sensitive-looking credential keys masked and returns `None`. So order matters: put the authoritative source first and use `PermissionDenied` for a hard veto such as a blocked account.

code

python · 20 lines
python
from django.contrib.auth.backends import BaseBackend
from django.core.exceptions import PermissionDenied

from accounts.models import BlockedLogin


class BlocklistBackend(BaseBackend):
    """Listed first: vetoes blocked usernames before any other backend runs."""

    def authenticate(self, request, username=None, password=None, **kwargs):
        if username and BlockedLogin.objects.filter(username__iexact=username).exists():
            raise PermissionDenied
        return None  # not a decision: let the next backend check the password


# settings.py
AUTHENTICATION_BACKENDS = [
    "accounts.backends.BlocklistBackend",
    "django.contrib.auth.backends.ModelBackend",
]

go deeper

for a junior

Remember that backends are tried top to bottom and the first one returning a user wins.

for a middle

Explain the three outcomes (user, None, PermissionDenied), the silent signature skip, and the user.backend annotation set on success.

for a senior

Show when a veto is needed: overlapping sources where a fallback backend would accept a user the primary source has disabled, and the latency cost of a slow first backend.

for a principal

Treat the list as policy: one authoritative source per identity, explicit vetoes, and a documented reason for each fallback so no stale credential path survives.

## The loop in one picture Django's `authenticate()` is a short loop over **authentication backends**, the classes named by dotted path in the `AUTHENTICATION_BACKENDS` setting. It is the only place credentials are checked; views and forms such as `LoginView` and `AuthenticationForm` just call it. In Django 6.1 the loop does this for each backend, in list order: 1. **Compatibility check.** Django inspects the backend's `authenticate()` signature and tries to bind `(request, **credentials)` to it. If binding raises `TypeError`, for example because the backend accepts `token=` and the caller passed `username=` and `password=`, the backend is **skipped silently**. 2. **Call it.** `user = backend.authenticate(request, **credentials)`. 3. **Veto.** If the call raises `django.core.exceptions.PermissionDenied`, the loop **breaks**: no further backend runs. 4. **Pass.** If it returns `None`, the loop continues to the next backend. 5. **Success.** If it returns a user, Django sets `user.backend = "<dotted.path>"` and returns the user immediately. If the loop finishes without a user, whether by exhausting the list or by a veto, Django sends the `user_login_failed` signal and `authenticate()` returns `None`. The credentials passed to that signal are cleaned first: any key whose name matches `api`, `token`, `key`, `secret`, `password` or `signature` is replaced by asterisks. ## None versus PermissionDenied | Backend outcome | What authenticate() does | When to use it | |---|---|---| | Returns a user | Stops, annotates `user.backend`, returns the user | Credentials valid for this source | | Returns `None` | Tries the next backend | "I don't know this user" or "wrong password here" | | Raises `PermissionDenied` | Stops, sends `user_login_failed`, returns `None` | "This person must not get in through any backend" | | Signature cannot bind | Skips the backend silently | Backend handles a different credential shape | The distinction matters when backends overlap. Suppose a project lists a directory backend first and `ModelBackend` second, so local accounts still work when the directory does not know someone. If the directory backend returns `None` for a disabled directory account, `ModelBackend` will happily accept a stale local password for the same username. Raising `PermissionDenied` instead closes that hole. Note that `authenticate()` catches the exception: callers still just see `None`, not a 403. ## Why order matters - **First positive match wins.** If the same username and password are valid in two backends, the earlier one authenticates the user, and its path is what gets stored in the session at login. - **Cost.** Every failed login walks every compatible backend. A slow remote backend placed first adds its latency to every local-account login. - **Session binding.** The backend that authenticated the user is the one Django asks to reload the user on later requests, so the choice made here persists for the whole session. - **Mixed credential shapes.** Because of the signature check, a token backend and a password backend can share the list without interfering; each is only consulted for calls it can accept. ## The signature trap The silent skip is convenient and dangerous. A backend written as `def authenticate(self, request, email=None, password=None)` is never reached by `LoginView`, because `AuthenticationForm` always calls `authenticate(request, username=..., password=...)`. Nothing errors; logins simply fail. Accept `username=None` (and `**kwargs` if you want flexibility) to stay compatible with the built-in form. ## Async Since Django 5.2, `aauthenticate()` walks the same list and calls each backend's `aauthenticate()`. `BaseBackend` provides a default that wraps the sync method with `sync_to_async`, so a sync-only custom backend still works from async views, just with a thread hop. The same None/`PermissionDenied` rules apply. ## Debugging a chain that never logs anyone in When logins fail with no error, walk the loop by hand: 1. Call `authenticate(None, username=..., password=...)` in `manage.py shell` and print `user.backend` on success, to see which backend accepted it. 2. Check each custom backend's `authenticate()` signature against the keywords the caller passes; a mismatch is skipped without a trace. 3. Look for a backend earlier in the list that raises `PermissionDenied` more widely than intended, since it silently blocks every backend after it. 4. Connect a temporary receiver to `user_login_failed` to confirm the chain ran and to see the cleaned credentials it saw. - A backend that raises any other exception, a database error for example, is **not** caught: it propagates out of `authenticate()` and the request fails, so defensive backends should catch their own lookup errors.

  • How can you observe failed logins across all backends without writing a backend?
    Connect a receiver to `django.contrib.auth.signals.user_login_failed`. `authenticate()` sends it once after the chain fails, with `sender`, the cleaned `credentials` dict (password-like keys masked) and the `request`, so you can log or count failures by username and client address.
  • What happens when an async view calls aauthenticate() and a custom backend only defines the sync authenticate()?
    It still works. Since Django 5.2 `aauthenticate()` calls each backend's `aauthenticate()`, and `BaseBackend` supplies a default that runs the sync `authenticate()` through `sync_to_async`. The cost is a thread hop per backend call; override `aauthenticate()` natively if login sits on a hot async path.

saying these in an interview costs you the question

  • Django stops at the first backend even when it returns None.
  • A PermissionDenied from a backend becomes a 403 response automatically.
  • Django raises TypeError when a backend cannot accept the credentials.
  • The last backend in the list wins when several accept the user.
  • Returning None and raising PermissionDenied mean the same thing to authenticate().