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
- What Actually Happens When a Request Hits Your Server
- The ORM Query Compiler
- Connection Management and the Database Wrapper
- Signal Dispatch Internals
- Template Engine Compilation
- Form and Validation Pipeline
- Authentication Backend Chain ← You are here
- Static Files and WhiteNoise Internals (coming next)
- Migration System Deep Dive
- Test Client and Request Factory Mechanics