Django Under Hood 07: Django’s Authentication Backend Chain - How Users Actually Get Authenticated

Django Under Hood 07: Django’s Authentication Backend Chain - How Users Actually Get Authenticated

Part 7 of the “Django Under the Hood ” series — deep dives into Django’s internals, edge cases, and the mechanics that separate production-grade applications from tutorial code.

user = authenticate(username='alice', password='secret123')

This line doesn’t check a password. It starts a chain.

Django iterates through every authentication backend in AUTHENTICATION_BACKENDS, calling each one's authenticate() method until one returns a user or all fail. Most applications have one backend. Some have five.

The mechanics matter. Which backend authenticated? What happens if two backends both recognize the user? Why does your custom backend return a valid user, but request.user stays AnonymousUser?

Understanding the authentication chain isn’t about adding features — it’s about debugging the ones that mysteriously don’t work.

Let’s trace from authenticate() to request.user.

The authenticate() Function

# django/contrib/auth/__init__.py
def authenticate(request=None, **credentials):
    for backend, backend_path in _get_backends(return_tuples=True):
        try:
            user = backend.authenticate(request, **credentials)
        except PermissionDenied:
            # Backend explicitly denied - stop the chain
            break
        
        if user is None:
            continue  # This backend didn't recognize the credentials
        
        # Success! Tag the user with the backend used
        user.backend = backend_path
        return user
    
    return None  # No backend authenticated the user

Three outcomes per backend:

Key insight: user.backend is set to the backend path that authenticated them. This matters for login().

Backend Loading

# settings.py
AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',
    'myapp.backends.LDAPBackend',
    'myapp.backends.TokenBackend',
]

Backends load lazily on first authentication:

# django/contrib/auth/__init__.py
def _get_backends(return_tuples=False):
    backends = []
    for backend_path in settings.AUTHENTICATION_BACKENDS:
        backend = load_backend(backend_path)  # import_string()
        backends.append((backend, backend_path) if return_tuples else backend)
    return backends

def load_backend(path):
    return import_string(path)()  # Instantiate the class

New instance per call: Each authenticate() creates fresh backend instances. Don't store state on backends.

ModelBackend: The Default

# django/contrib/auth/backends.py
class ModelBackend(BaseBackend):
    def authenticate(self, request, username=None, password=None, **kwargs):
        if username is None:
            username = kwargs.get(UserModel.USERNAME_FIELD)
        
        if username is None or password is None:
            return None
        
        try:
            user = UserModel._default_manager.get_by_natural_key(username)
        except UserModel.DoesNotExist:
            # Run password hasher to prevent timing attacks
            UserModel().set_password(password)
            return None
        
        if user.check_password(password) and self.user_can_authenticate(user):
            return user
        
        return None
    
    def user_can_authenticate(self, user):
        # Check is_active (can be overridden)
        is_active = getattr(user, 'is_active', None)
        return is_active or is_active is None

The Timing Attack Protection

Notice this line:

except UserModel.DoesNotExist:
    UserModel().set_password(password)  # Burn CPU time
    return None

Without it, attackers could enumerate valid usernames by measuring response times:

  • User exists: Hash comparison takes time
  • User doesn’t exist: Immediate return

By running the hasher anyway, both paths take similar time.

get_by_natural_key()

# django/contrib/auth/models.py
class UserManager(BaseUserManager):
    def get_by_natural_key(self, username):
        return self.get(**{self.model.USERNAME_FIELD: username})

With default User, this is get(username=username). With custom user models using email:

class CustomUser(AbstractUser):
    USERNAME_FIELD = 'email'

Now it’s get(email=username).

Custom Backends

Minimal Backend

# myapp/backends.py
class EmailBackend:
    def authenticate(self, request, email=None, password=None, **kwargs):
        try:
            user = User.objects.get(email=email)
        except User.DoesNotExist:
            return None
        
        if user.check_password(password):
            return user
        return None
    
    def get_user(self, user_id):
        try:
            return User.objects.get(pk=user_id)
        except User.DoesNotExist:
            return None

Critical: You must implement get_user(). Sessions store user IDs, and Django calls get_user() to retrieve the full user object on each request.

Token Backend

class TokenBackend:
    def authenticate(self, request, token=None, **kwargs):
        if token is None:
            return None
        
        try:
            auth_token = AuthToken.objects.select_related('user').get(
                key=token,
                expires_at__gt=timezone.now(),
            )
        except AuthToken.DoesNotExist:
            return None
        
        return auth_token.user
    
    def get_user(self, user_id):
        try:
            return User.objects.get(pk=user_id)
        except User.DoesNotExist:
            return None

LDAP Backend

class LDAPBackend:
    def authenticate(self, request, username=None, password=None, **kwargs):
        if not username or not password:
            return None
        
        # Connect to LDAP
        if not self.ldap_authenticate(username, password):
            return None
        
        # Get or create local user
        user, created = User.objects.get_or_create(
            username=username,
            defaults={'email': f'{username}@company.com'}
        )
        
        if created:
            self.sync_ldap_groups(user)
        
        return user
    
    def ldap_authenticate(self, username, password):
        # LDAP bind attempt
        ...
    
    def get_user(self, user_id):
        try:
            return User.objects.get(pk=user_id)
        except User.DoesNotExist:
            return None

The login() Function

authenticate() verifies credentials. login() establishes the session.

# django/contrib/auth/__init__.py
def login(request, user, backend=None):
    session_auth_hash = ''
    
    if user is None:
        user = request.user
    
    if hasattr(user, 'get_session_auth_hash'):
        session_auth_hash = user.get_session_auth_hash()
    
    # Determine backend
    if backend is None:
        backend = user.backend  # Set by authenticate()
    
    # Cycle session key for security
    if SESSION_KEY in request.session:
        if request.session[SESSION_KEY] != user.pk:
            request.session.flush()
    else:
        request.session.cycle_key()
    
    # Store in session
    request.session[SESSION_KEY] = user.pk
    request.session[BACKEND_SESSION_KEY] = backend
    request.session[HASH_SESSION_KEY] = session_auth_hash
    
    # Attach to request
    request.user = user
    
    # Fire signal
    user_logged_in.send(sender=user.__class__, request=request, user=user)

Session stores three things:

Why Backend Matters

request.session[BACKEND_SESSION_KEY] = backend

When loading user from session, Django uses the stored backend:

def get_user(request):
    user_id = request.session.get(SESSION_KEY)
    backend_path = request.session.get(BACKEND_SESSION_KEY)
    
    if backend_path and user_id:
        backend = load_backend(backend_path)
        user = backend.get_user(user_id)  # Calls YOUR backend
        return user
    
    return AnonymousUser()

If you remove a backend from AUTHENTICATION_BACKENDS but users were authenticated with it, they'll become anonymous (backend can't be loaded).

Session Auth Hash: Password Change Invalidation

# django/contrib/auth/base_user.py
class AbstractBaseUser(models.Model):
    def get_session_auth_hash(self):
        key_salt = "django.contrib.auth.models.AbstractBaseUser.get_session_auth_hash"
        return salted_hmac(key_salt, self.password, algorithm='sha256').hexdigest()

The session stores a hash derived from the password. On each request:

# django/contrib/auth/middleware.py
def get_user(request):
    user = _get_user_from_session(request)
    
    # Verify session hash still matches
    session_hash = request.session.get(HASH_SESSION_KEY)
    session_hash_verified = (
        session_hash and
        hasattr(user, 'get_session_auth_hash') and
        constant_time_compare(session_hash, user.get_session_auth_hash())
    )
    
    if not session_hash_verified:
        request.session.flush()
        user = AnonymousUser()
    
    return user

Password change = all sessions invalidated. The hash changes, existing sessions don’t match, users get logged out everywhere.

AuthenticationMiddleware: Where request.user Comes From

# django/contrib/auth/middleware.py
class AuthenticationMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response
    
    def __call__(self, request):
        request.user = SimpleLazyObject(lambda: get_user(request))
        return self.get_response(request)

request.user is lazy — the session isn't accessed until you use request.user.

def get_user(request):
    if not hasattr(request, '_cached_user'):
        request._cached_user = auth.get_user(request)
    return request._cached_user

Result is cached on the request. Multiple request.user accesses don't hit the session repeatedly.

Permissions: has_perm() and Backend

user.has_perm('myapp.change_article')

This delegates to backends too:

# django/contrib/auth/models.py
class PermissionsMixin(models.Model):
    def has_perm(self, perm, obj=None):
        if self.is_active and self.is_superuser:
            return True  # Superuser has all permissions
        
        return _user_has_perm(self, perm, obj)

def _user_has_perm(user, perm, obj):
    for backend in auth.get_backends():
        if not hasattr(backend, 'has_perm'):
            continue
        
        if backend.has_perm(user, perm, obj):
            return True
    
    return False

Any backend returning True grants the permission. Unlike authenticate() which stops on first success, all backends are checked for permissions.

ModelBackend Permission Implementation

# django/contrib/auth/backends.py
class ModelBackend:
    def has_perm(self, user_obj, perm, obj=None):
        if not user_obj.is_active:
            return False
        return perm in self.get_all_permissions(user_obj, obj)
    
    def get_all_permissions(self, user_obj, obj=None):
        if not user_obj.is_active or user_obj.is_anonymous:
            return set()
        
        if not hasattr(user_obj, '_perm_cache'):
            user_obj._perm_cache = {
                *self.get_user_permissions(user_obj),
                *self.get_group_permissions(user_obj),
            }
        
        return user_obj._perm_cache

Permissions are cached on the user object (_perm_cache). First access queries, subsequent accesses use cache.

Custom Permission Backend

Object-level permissions:

class ObjectPermissionBackend:
    def authenticate(self, request, **kwargs):
        return None  # This backend doesn't authenticate
    
    def has_perm(self, user_obj, perm, obj=None):
        if obj is None:
            return False  # Only handles object permissions
        
        # Check object-specific permission table
        return ObjectPermission.objects.filter(
            user=user_obj,
            permission=perm,
            content_type=ContentType.objects.get_for_model(obj),
            object_id=obj.pk,
        ).exists()
# settings.py
AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',  # Model-level perms
    'myapp.backends.ObjectPermissionBackend',     # Object-level perms
]

The Anonymous User

# django/contrib/auth/models.py
class AnonymousUser:
    id = None
    pk = None
    username = ''
    is_staff = False
    is_active = False
    is_superuser = False
    
    def __str__(self):
        return 'AnonymousUser'
    
    def save(self):
        raise NotImplementedError
    
    def delete(self):
        raise NotImplementedError
    
    def has_perm(self, perm, obj=None):
        return _user_has_perm(self, perm, obj=obj)
    
    @property
    def is_anonymous(self):
        return True
    
    @property
    def is_authenticated(self):
        return False

AnonymousUser mimics the User interface but all permissions are False (by default). You can create a backend that grants permissions to anonymous users if needed.

Common Issues

Issue 1: Custom Backend Not Working

# Backend
class MyBackend:
    def authenticate(self, request, **credentials):
        user = User.objects.get(...)
        return user  # Returns user but login fails!

Cause: Missing get_user() method.

class MyBackend:
    def authenticate(self, request, **credentials):
        ...
    
    def get_user(self, user_id):  # REQUIRED
        try:
            return User.objects.get(pk=user_id)
        except User.DoesNotExist:
            return None

Issue 2: User Authenticated but is_anonymous

user = authenticate(username='alice', password='secret')
print(user)  # <User: alice>
print(request.user.is_authenticated)  # False!

Cause: Forgot to call login().

user = authenticate(request=request, username='alice', password='secret')
if user:
    login(request, user)  # Don't forget this!

Issue 3: Sessions Invalidated Unexpectedly

Cause: Password changed, session hash no longer matches.

# After password change
user.set_password('new_password')
user.save()

# Re-login to create new session hash
login(request, user)  # Updates session hash

Issue 4: Multiple Backends, Wrong One Used

AUTHENTICATION_BACKENDS = [
    'myapp.backends.LDAPBackend',
    'django.contrib.auth.backends.ModelBackend',
]

Both backends might recognize the same username. Order matters — first success wins.

Fix: Make backends more specific:

class LDAPBackend:
    def authenticate(self, request, username=None, password=None, **kwargs):
        if not username.endswith('@company.com'):
            return None  # Let ModelBackend handle non-LDAP users
        ...

Issue 5: Backend Removed, Users Logged Out

# Before
AUTHENTICATION_BACKENDS = [
    'myapp.backends.OldBackend',  # Users authenticated with this
    'django.contrib.auth.backends.ModelBackend',
]

# After removing OldBackend
AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',
]
# Users who were authenticated via OldBackend become AnonymousUser!

Fix: Migrate session backend references, or let users re-login.

Authentication Flow Diagram

What’s Next

This was the authentication backend chain — from credentials to session.

Next in the series: Static Files and WhiteNoise Internals — how Django finds and serves static files, the staticfiles finders, manifest storage, and why WhiteNoise is faster than you’d expect.

Series: Django Under the Hood

  1. What Actually Happens When a Request Hits Your Server
  2. The ORM Query Compiler
  3. Connection Management and the Database Wrapper
  4. Signal Dispatch Internals
  5. Template Engine Compilation
  6. Form and Validation Pipeline
  7. Authentication Backend Chain ← You are here
  8. Static Files and WhiteNoise Internals (coming next)
  9. Migration System Deep Dive
  10. Test Client and Request Factory Mechanics

SUBSCRIBE FOR NEW ARTICLES

@
comments powered by Disqus