skip to content

How would you write a custom Django authentication backend that lets users sign in with their email address and password?

level: middleimportance: must knowfreq 62%

answer

  1. subclass, don't start from scratch
  2. the form still sends username=
  3. iexact and uniqueness
  4. hash even when nobody matches

basics

~10 s

Subclass ModelBackend, override authenticate() to look the user up by email case-insensitively, check_password() it, return the user only if user_can_authenticate() passes, and add the class to AUTHENTICATION_BACKENDS. get_user() and permissions are inherited.

solid answer

~40 s

I subclass `django.contrib.auth.backends.ModelBackend` so `get_user()`, `user_can_authenticate()` and the permission methods come for free, and override only `authenticate(self, request, username=None, password=None, **kwargs)`. I keep the `username` parameter because `AuthenticationForm` and `LoginView` always call `authenticate(username=..., password=...)`; a backend without it is skipped silently. Inside I do `get(email__iexact=username)`, catch `DoesNotExist` and `MultipleObjectsReturned`, and on a miss still hash the password once so response time does not reveal which emails exist. Then `user.check_password(password) and self.user_can_authenticate(user)`, returning the user or `None`, never raising for bad input. Finally I list it in `AUTHENTICATION_BACKENDS`, before `ModelBackend` if usernames should keep working, and make email unique at the database level.

code

python · 16 lines
python
from django.contrib.auth import authenticate
from django.test import TestCase

from accounts.models import User


class EmailBackendTests(TestCase):
    def setUp(self):
        User.objects.create_user(username="alice", email="[email protected]", password="s3cret!pw")

    def test_email_is_case_insensitive(self):
        user = authenticate(None, username="[email protected]", password="s3cret!pw")
        self.assertEqual(user.backend, "accounts.backends.EmailBackend")

    def test_unknown_email_returns_none(self):
        self.assertIsNone(authenticate(None, username="[email protected]", password="x"))

go deeper

for a junior

Remember the shape: a class with authenticate() and get_user(), listed by dotted path in AUTHENTICATION_BACKENDS.

for a middle

Explain why subclassing ModelBackend, keeping the username parameter, iexact lookup and check_password() are each necessary.

for a senior

Cover the production edges: duplicate emails, enumeration by timing, fallback cost with two backends, and when a USERNAME_FIELD change is the cleaner fix.

for a principal

Weigh backend versus user-model change against migration risk and the identity rules you want enforced by the database rather than by lookup code.

## The goal and the two ways to get there Django's built-in `ModelBackend` looks users up by the field named in the user model's `USERNAME_FIELD`. With the default `User` model that is `username`, so signing in with an email address needs one of two changes: - **Change the model**: a custom user model whose `USERNAME_FIELD = "email"`. Then `ModelBackend` already works, but this is a user-model decision best made before the first migration. - **Change the backend**: keep the model and add a **custom authentication backend** that resolves an email to a user. This is the interview question, and the right answer when usernames must keep working too or the model cannot change. A backend is any class with `authenticate(request, **credentials)` and `get_user(user_id)`, listed by dotted path in `AUTHENTICATION_BACKENDS`. ## Building it ```python from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend UserModel = get_user_model() class EmailBackend(ModelBackend): def authenticate(self, request, username=None, password=None, **kwargs): email = username or kwargs.get("email") if email is None or password is None: return None try: user = UserModel._default_manager.get(email__iexact=email) except (UserModel.DoesNotExist, UserModel.MultipleObjectsReturned): UserModel().set_password(password) # same cost as a real check return None if user.check_password(password) and self.user_can_authenticate(user): return user return None ``` ```python # settings.py AUTHENTICATION_BACKENDS = [ "accounts.backends.EmailBackend", "django.contrib.auth.backends.ModelBackend", # keep only if usernames still log in ] ``` ## Decisions hidden in those lines 1. **Subclass `ModelBackend`, not `BaseBackend`.** `BaseBackend.get_user()` returns `None`, which would make every login evaporate on the next request. `ModelBackend.get_user()` loads by primary key and applies the active check. 2. **Accept `username`.** Django checks each backend's signature against the call's keyword arguments and silently skips any backend that cannot accept them. `AuthenticationForm` passes `username=` and `password=`, so a backend declared with `email=` only is never consulted by `LoginView`. 3. **Case-insensitive lookup.** People type `[email protected]` and `[email protected]` interchangeably. `email__iexact` handles that at query time. 4. **Uniqueness.** The default `User.email` is not unique. With duplicates `get()` raises `MultipleObjectsReturned`; catching it avoids a 500, but the real fix is a database-level case-insensitive unique constraint, so the lookup is unambiguous. 5. **Equal timing.** `ModelBackend` hashes a dummy password when the user does not exist. A hand-written backend must do the same, or unknown emails return noticeably faster and an attacker can enumerate registered addresses. 6. **Never compare passwords yourself.** `check_password()` understands every hasher in `PASSWORD_HASHERS` and upgrades old hashes on success. 7. **Return `None`, don't raise.** Invalid credentials should let the next backend try; raise `PermissionDenied` only for a deliberate veto. ## Order and coexistence | Setup | Email login | Username login | |---|---|---| | `[EmailBackend]` | Yes | No | | `[EmailBackend, ModelBackend]` | Yes | Yes, via fallback | | `[ModelBackend]` with `USERNAME_FIELD = "email"` | Yes | No, only the email field is looked up | With both backends listed, a failed email login also walks `ModelBackend`, which costs one extra hash per failure. That is a reasonable price, but worth knowing when tuning login latency. ## Things candidates forget - The login form's label still reads "Username"; subclass `AuthenticationForm` if the copy should say "Email". - The admin login uses the same `authenticate()` call, so staff can sign in by email as well. - Async views calling `aauthenticate()` will run this sync backend through `BaseBackend.aauthenticate()`'s `sync_to_async` wrapper; override `aauthenticate()` too if login is on a hot async path. - Brute-force throttling is still not provided; the backend is only the lookup. ## Testing the backend A backend is a plain class, so it is cheap to test through the public `authenticate()` function rather than by calling the method directly: - Assert that a mixed-case email authenticates and that `user.backend` equals your backend's dotted path, which proves the right backend accepted it. - Assert that an unknown email and a wrong password both return `None`. - Assert that an inactive user returns `None`, proving the inherited `user_can_authenticate()` still applies. - Assert that two accounts sharing an email return `None` rather than raising, if your data can still contain duplicates. Testing through `authenticate()` also exercises the signature check, so a backend that accidentally dropped the `username` parameter fails the test instead of failing silently in production.

  • Why is overriding get_user() usually unnecessary in this backend?
    Because the class inherits ModelBackend.get_user(), which loads the user by primary key and returns None for inactive users. The session stores the primary key, not the email, so how the user was found at login does not matter when they are reloaded.
  • When is changing USERNAME_FIELD better than a backend?
    On a new project whose users never had usernames: a custom user model with `USERNAME_FIELD = "email"` makes email the natural key everywhere (createsuperuser, the admin, `get_by_natural_key()`) with no extra backend. On an existing project with usernames in use, the backend avoids a user-model migration.
  • What should happen if two accounts share an email address?
    The backend should refuse (return None) rather than guess which account is meant, and the data should be fixed with a unique constraint so the case cannot arise. Picking the first match could log someone into another person's account.

It is like a hotel front desk that finds a guest's booking by phone number instead of surname: the desk procedure (checking ID, the key card system) stays the same, only the lookup index changes, and the index is useless if two bookings share one phone number.

saying these in an interview costs you the question

  • Filter with email=... exactly and assume users type addresses consistently.
  • Compare the submitted password to user.password with ==.
  • Declare authenticate(self, request, email, password) and expect LoginView to call it.
  • Inherit from BaseBackend and skip get_user(), since authenticate() already worked.
  • Return None early for unknown emails without any hashing.