Password Validation in Django

A reasonable default

Since NIST updated its password recommendations in 2017, a lot has changed. Although there are still plenty of applications that rely on the old-fashioned complexity-based rules (lower case, upper case, numbers, special characters… you know the drill), a lot has improved.

A good example is Django. The web framework comes with a sensible set of defaults that are also representative of common implementations:

  • A password can’t be too similar to the users other personal information.
  • A password must contain at least 8 characters.
  • A password can’t be a commonly used password.
  • A password can’t be entirely numeric.

The first two and the last rule are fairly straightforward, but the third needs a bit of explanation: “A password can’t be a commonly used password.”

Django implements this by comparing the password against a list of 20 000 passwords passwords compiled from previously breached passwords. This is a common and obvious choice. However, I would argue that it’s a suboptimal choice.

There are hundreds of millions of passwords, that have been exposed in data breaches over time. Is it even feasible to check only against 20 000 of them? And if so, how to choose the subset?

Also there are other passwords, that are commonly used by attackers, that are not necessarily contained in breaches (for example 6uPF5Cofvyjcew9).

Another problem with Django’s default list is, that 57% of the entries are shorter than 8 characters and are therefore effectively redundant to the first rule.

I want to suggest a configuration for Django, that solves all of these questions and gives a good starting point for most projects.

A reasonable config for Django

We will keep the first two validators from Django’s default config:

  • UserAttributeSimilarityValidator
  • MinimumLengthValidator

Both come pre-configured but can be customized. We will increase the minimum password length to 10. You can change this, but remember that anything less than 8 is completely inadequate.

With that out of the way, we can move on to commonly used passwords. For this we will use Django’s CommonPasswordValidator, but we will replace the default password list. Instead, we compiled our own password list. This list is based on passwords seen in real-world attacks on honeypots, and therefore reflects which passwords are most at risk. In addition, the list only contains passwords that are at least 8 characters long. This allows us to reduce redundancy with the minimum password length and get more out of this check.

We save the password list common-passwords-lower.txt.gz in our base directory and now we have the first version of our configuration:

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
        'OPTIONS': {
            'min_length': 10,
        }
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
        'OPTIONS': {
            'password_list_path': BASE_DIR / 'common-passwords-lower.txt.gz',
        }
    }
]

This is a solid start, but we can do better. The idea of checking against passwords that have previously been exposed in data breaches is good in principle. But instead of a static list, we will use the passwords API of Have I Been Pwned (HIBP).

I have written a plugin for Django that does this. Add django-hibp to your requirements and insert the following in your config:

    {
        'NAME': 'django_hibp.HIBPPasswordValidator',
        'OPTIONS': {
            'fail_on_error': False,
        }
    }

Since this plugin relies on an external API, this introduces a new point of failure. If the API is inaccessible, the check will fail. In order not to break our application in this case (users wouldn’t be able to register or change their password), we set fail_on_error to False.

I consider this acceptable in most cases, as an attacker can’t exploit this behavior directly against any user. However, if you disagree, or deem that the availability of your application is less important than knowing that all passwords have passed this check, feel free to change this behavior.

With this check we have our final configuration:

AUTH_PASSWORD_VALIDATORS = [
    {
        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
    },
    {
        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
        'OPTIONS': {
            'min_length': 10,
        }
    },
    {
        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
        'OPTIONS': {
            'password_list_path': BASE_DIR / 'common-passwords-lower.txt.gz',
        }
    },
    {
        'NAME': 'django_hibp.HIBPPasswordValidator',
        'OPTIONS': {
            'fail_on_error': False,
        }
    }
]

With this configuration, the needs of most applications should be covered. If you want to improve your users’ security further (and you should), the next step would be to offer them two-factor authentication. Even with these password validators in place, passwords alone are simply not the best choice any more. But that’s perhaps a topic for another day.

Konstantin Weddige

Managing director and co-founder

The most important job of IT security is to make risks understandable. My ambition is to live up to this challenge with Lutra Security.

May 25, 2023