Passwords

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python 3.12-3.14 and PostgreSQL 15 or newer, checked on 2026-10-03; where Django 5.2 LTS (5.2.17) behaves differently the text says so. Every listing was run in a scratch project on Python 3.13.16 and Django 6.1.1 with SQLite and argon2-cffi 25.1.0 installed: manage.py check and a suite of 17 tests (manage.py test) passed, and the outputs shown are real. Nothing on this page needs PostgreSQL or Redis.

A password is the one piece of user data a site must be able to verify but should never be able to read. Django therefore stores only the result of a deliberately slow one-way function, together with the parameters needed to repeat it. This page covers how that storage works, the hashers Django ships (PBKDF2, Argon2, scrypt, bcrypt), how to tune them and how to move existing users to a stronger one, the small API in django.contrib.auth.hashers, and the password validators that stop users from choosing password1234. The user model that owns the password field is covered on Authentication and Custom User Model & Backends; transport security (never send a password over plain HTTP) on Security.

In a hurry: what to use for what

I need to…​ Use Section

Understand what is in the password column

<algorithm>$<iterations>$<salt>$<hash>; the first hasher in PASSWORD_HASHERS writes, any listed hasher verifies.

How Django stores passwords

Keep the default and stay safe

Do nothing: PBKDF2-SHA256 with 1,500,000 iterations in 6.1, raised in every release.

PBKDF2: the default and its iteration counts

Use a memory-hard algorithm

Argon2PasswordHasher first (needs argon2-cffi), or ScryptPasswordHasher (OpenSSL only).

Argon2, scrypt

Make hashing slower or faster

Subclass the hasher and override iterations (or time_cost/memory_cost/parallelism, work_factor/…​).

Tuning work factors and salt entropy

Move users to a new algorithm

Put the new hasher first, keep the old ones in the list; hashes upgrade at the next login.

Upgrading stored hashes

Upgrade hashes without waiting for logins

A "wrapped" hasher plus a data migration.

Upgrading without waiting for a login

Hash or check a password by hand

make_password, check_password, acheck_password, is_password_usable.

Hashing and checking by hand

Reject weak passwords

AUTH_PASSWORD_VALIDATORS and validate_password().

Password validation with AUTH_PASSWORD_VALIDATORS

Add my own rule

A class with validate() and get_help_text().

Writing your own validator

Speed up the test suite

A fast hasher in the test settings only.

A fast hasher for the test suite

The Bookshelf pieces used on this page

The examples belong to the catalog app of the Bookshelf project. The page needs no models of its own: it uses the built-in User model, a small hashers.py and validators.py, and a sign-up form. This is the scratch project’s settings excerpt that every later listing builds on (SECRET_KEY comes from the environment, never from the source):

config/settings.py (excerpt)
import os

from django.core.management.utils import get_random_secret_key

SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY") or get_random_secret_key()

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "catalog",
]

LOGIN_REDIRECT_URL = "/"

How Django stores passwords

The password field of a User is a string with four parts separated by dollar signs:

The stored format (placeholders, not a real hash)
<algorithm>$<iterations>$<salt>$<hash>
pbkdf2_sha256$1500000$<random salt>$<base64 digest>

The algorithm names the hasher, the iterations are the work factor, the salt is a random per-password seed (it defeats precomputed tables and makes two users with the same password hash differently) and the hash is the output of the one-way function. Not every algorithm has exactly these four fields: Argon2 and scrypt store their own parameters in the same $-separated style, as the examples below show. The format means the database row is self-describing: the work factor in use when a password was stored travels with it, which is what makes upgrading possible.

Which algorithm is used is decided by the PASSWORD_HASHERS setting, a list of hasher classes:

  • To store a password Django uses the first hasher in the list.

  • To verify a password Django finds the hasher whose algorithm name matches the stored hash. If that algorithm is not in the list, identify_hasher() raises ValueError, and check_password() treats that as a failed check (it returns False).

The default list in Django 6.1 is PBKDF2PasswordHasher, PBKDF2SHA1PasswordHasher, Argon2PasswordHasher, BCryptSHA256PasswordHasher and ScryptPasswordHasher, in that order: PBKDF2 writes everything, the others can still verify (and the Argon2, bcrypt and scrypt ones are used as soon as you move them to the front or a hash using them already exists). A test pins the format and the order of precedence:

catalog/tests.py (excerpt)
from django.contrib.auth.hashers import check_password, identify_hasher, make_password
from django.test import TestCase, override_settings

PW = "not-a-real-password"


class HasherTests(TestCase):
    def test_format_and_defaults(self):
        encoded = make_password(PW, hasher="pbkdf2_sha256")
        algorithm, iterations, salt, digest = encoded.split("$", 3)
        self.assertEqual(algorithm, "pbkdf2_sha256")
        self.assertEqual(int(iterations), 1_500_000)
        self.assertTrue(salt and digest)

    def test_unknown_algorithm_raises(self):
        encoded = make_password(PW)  # the project's first hasher (Argon2, see below)
        with override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.PBKDF2PasswordHasher"]):
            with self.assertRaises(ValueError):
                identify_hasher(encoded)
            self.assertFalse(check_password(PW, encoded))  # check_password() swallows it

The unknown-algorithm message is Unknown password hashing algorithm 'argon2'. Did you specify it in the PASSWORD_HASHERS setting?, which is the first thing to look for when a restored database of old users suddenly cannot log in.

PBKDF2: the default and its iteration counts

By default Django uses PBKDF2 with SHA-256, a password-stretching mechanism recommended by NIST: not memory-hard, but well understood, FIPS-friendly and available everywhere because it needs only the standard library. Its strength is the iteration count, and Django raises the default in every release to follow growing hardware:

Django Default PBKDF2 iterations

4.0

320,000 (from 260,000)

4.1

390,000

4.2

600,000

5.0

720,000

5.1

870,000

5.2 LTS

1,000,000

6.0

1,200,000

6.1

1,500,000

The values come straight from each release’s notes. Two consequences matter in practice. First, a stored hash keeps the count it was created with, so after a Django upgrade every existing PBKDF2 password is "out of date" and is silently re-hashed at the user’s next login (see Upgrading stored hashes). Second, logins get slower with each release: on the scratch machine one PBKDF2 hash takes about 0.1 seconds at 1,500,000 iterations; measure on your own hardware before choosing numbers.

Measuring the cost of each hasher (run in manage.py shell)
import time

from django.contrib.auth.hashers import make_password

for algorithm in ("pbkdf2_sha256", "argon2", "scrypt", "bcrypt_sha256"):
    start = time.perf_counter()
    make_password("not-a-real-password", hasher=algorithm)
    print(algorithm, round(time.perf_counter() - start, 3))

On the scratch machine (a developer laptop) this printed roughly 0.11 s for PBKDF2, 0.03 s for Argon2, 0.09 s for scrypt and 0.18 s for bcrypt-SHA256. The absolute numbers mean little; the habit of measuring them is the point.

Argon2

Argon2 won the 2015 Password Hashing Competition and is designed to be no easier to compute on custom hardware than on an ordinary CPU, because every hash needs a configurable amount of memory. Django’s variant is Argon2id. It is not the default only because it needs a third-party library, and the Django documentation notes that the Password Hashing Competition panel recommends it over the other algorithms Django supports. To use it:

  1. Install the library: python -m pip install "django[argon2]" (equivalent to installing argon2-cffi with the version constraint Django declares).

  2. Put Argon2PasswordHasher first in PASSWORD_HASHERS and keep the others after it.

config/settings.py (excerpt)
PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.Argon2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
    "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
    "django.contrib.auth.hashers.ScryptPasswordHasher",
]

The old hashers stay in the list so that existing users keep logging in and are upgraded as they do. A hash now looks like this (segments replaced by placeholders; the parameters are Django’s 6.1 defaults):

argon2$argon2id$v=19$m=102400,t=2,p=8$<salt>$<hash>

Because argon2-cffi is a third-party library, it must be installed in every environment where a hash is created or verified (web workers, task workers, management commands, CI).

scrypt

scrypt, like PBKDF2 and bcrypt, uses a set amount of work to slow brute force, but it is also designed to use a lot of memory, which limits how much an attacker can parallelise (see RFC 7914). It was added in Django 4.0 and is recommended by the release notes over PBKDF2, but it is not the default because it needs OpenSSL 1.1 or newer in the Python build. It needs no extra package:

config/settings.py (excerpt)
PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.ScryptPasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
    "django.contrib.auth.hashers.Argon2PasswordHasher",
    "django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
]

The stored form is scrypt$<work factor>$<salt>$<block size>$<parallelism>$<hash>; with the 6.1 defaults the first number is 16384 (N), then the salt, 8 (r) and 5 (p). The default parallelism was raised from 1 to 5 in Django 5.1 to follow OWASP recommendations, which means scrypt hashes created under 5.0 or earlier are re-hashed at login after upgrading.

bcrypt, briefly

bcrypt is a long-established algorithm designed for password storage. It needs the bcrypt package (python -m pip install "django[bcrypt]") and is used by listing BCryptSHA256PasswordHasher first. The SHA-256 variant is the one to choose: it pre-hashes the password so bcrypt’s 72-byte input limit never truncates a long passphrase. The plain BCryptPasswordHasher exists for verifying old data. Its work factor is rounds, which is logarithmic (12 rounds means 2 ** 12 iterations), unlike PBKDF2’s iterations.

Table 1. Algorithm names and hashers Django includes
Hasher Algorithm name Notes

PBKDF2PasswordHasher

pbkdf2_sha256

Default; no extra dependency.

PBKDF2SHA1PasswordHasher

pbkdf2_sha1

For verifying old hashes only.

Argon2PasswordHasher

argon2

Needs argon2-cffi; Argon2id.

BCryptSHA256PasswordHasher

bcrypt_sha256

Needs bcrypt.

BCryptPasswordHasher

bcrypt

For verifying old hashes only.

ScryptPasswordHasher

scrypt

Needs OpenSSL 1.1+.

MD5PasswordHasher

md5

Insecure; only for legacy data and fast tests.

Tuning work factors and salt entropy

The defaults are chosen to be reasonable, and they are meant to be tuned to your hardware and threat model. The mechanism is always the same: subclass the hasher, override the class attribute, put the subclass first in PASSWORD_HASHERS.

catalog/hashers.py
from django.contrib.auth.hashers import PBKDF2PasswordHasher


class StrongerPBKDF2PasswordHasher(PBKDF2PasswordHasher):
    """PBKDF2-SHA256 with twice the iterations of the Django default."""

    iterations = PBKDF2PasswordHasher.iterations * 2
config/settings.py (excerpt)
PASSWORD_HASHERS = [
    "catalog.hashers.StrongerPBKDF2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
]

Notice that the plain PBKDF2PasswordHasher stays in the list. It has the same algorithm name (pbkdf2_sha256), so it verifies hashes created by the stronger subclass, but the subclass is first, so the stored iteration count differs and check_password() upgrades them. A test shows both effects with the real defaults (3,000,000 iterations is twice 6.1’s 1,500,000):

catalog/tests.py (excerpt)
def test_custom_iterations(self):
    hashers = [
        "catalog.hashers.StrongerPBKDF2PasswordHasher",
        "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    ]
    with override_settings(PASSWORD_HASHERS=hashers):
        encoded = make_password(PW)
        self.assertEqual(int(encoded.split("$")[1]), 3_000_000)
        self.assertTrue(check_password(PW, encoded))
        self.assertEqual(identify_hasher(encoded).algorithm, "pbkdf2_sha256")

The attributes each hasher exposes, with their 6.1 defaults (read from the classes in the scratch project):

Hasher Attributes Defaults

PBKDF2

iterations

1,500,000

bcrypt

rounds (logarithmic)

12

Argon2

time_cost, memory_cost (KiB), parallelism

2, 102400, 8

scrypt

work_factor (N, a power of 2), block_size (r), parallelism (p), maxmem

16384, 8, 5, 0

For Argon2 the documented procedure is: choose parallelism as the number of threads you can spare, memory_cost as the KiB of memory you can spare, then raise time_cost until a hash takes an acceptable time; if time_cost = 1 is already too slow, lower memory_cost. The command-line argon2 utility interprets memory differently from Django: its value is the base-2 logarithm (memory_cost == 2 ** memory_cost_commandline). For scrypt, the minimum memory is work_factor * 2 * block_size * 64 bytes, and multiplied by parallelism if the implementation is fully multithreaded, so raising work_factor or block_size may require raising maxmem (the default 0 means OpenSSL’s own limit).

The salt has its own knob: salt_entropy on BasePasswordHasher, in bits, defaults to 128 and is effectively a minimum (because of how salts are encoded, 128 yields a salt that really holds 131 bits). Override it only if you write a hasher of your own.

Finally, if you write a hasher with a work factor, implement harden_runtime(self, password, encoded): it runs the missing iterations so that a login for a user whose hash used an older (cheaper) count takes as long as one for a nonexistent user, which would otherwise reveal which usernames exist through timing. If the algorithm has no work factor, implement it as pass. The built-in hashers already do this; subclasses that only change a class attribute inherit it.

Upgrading stored hashes

Django upgrades hashes on login. When a user authenticates successfully and their stored hash uses anything other than the first hasher in PASSWORD_HASHERS (or the same hasher with outdated parameters), Django re-hashes the password it just verified and saves the new value. Old installations therefore get more secure as users return, and you can adopt better algorithms as they appear. The rules to remember:

  • Upgrading happens only for algorithms still listed in PASSWORD_HASHERS. Never remove an entry while users with that hash exist; they could not log in, let alone upgrade.

  • A change of PBKDF2 iterations, bcrypt rounds or Argon2 attributes also triggers an upgrade (a Django release that raises the default count therefore migrates every PBKDF2 hash over time), as does a change of salt entropy.

  • Users who never come back keep their old hash forever. Until all rows use the default algorithm, a login for an old-style user and a login for a nonexistent user take different amounts of time, which is a user-enumeration timing signal; harden_runtime() narrows it, and Upgrading without waiting for a login removes it.

This test creates a user under the old setting and logs in under the new one:

catalog/tests.py (excerpt)
from django.contrib.auth import get_user_model

User = get_user_model()


def test_login_upgrades_old_hash(self):
    with override_settings(PASSWORD_HASHERS=["django.contrib.auth.hashers.PBKDF2PasswordHasher"]):
        user = User.objects.create_user("ada", password=PW)
    self.assertTrue(user.password.startswith("pbkdf2_sha256$"))
    # the project settings prefer Argon2: a successful login re-hashes
    self.assertTrue(self.client.login(username="ada", password=PW))
    user.refresh_from_db()
    self.assertTrue(user.password.startswith("argon2$"))

The same mechanism is available to code that checks passwords itself: check_password(password, encoded, setter=…​) calls the setter with the plaintext when the stored hash should be regenerated (and never when the password is wrong):

def test_check_password_with_setter(self):
    old = make_password(PW, hasher="pbkdf2_sha256")
    calls = []
    self.assertTrue(check_password(PW, old, setter=calls.append))
    self.assertEqual(calls, [PW])
    self.assertFalse(check_password("wrong", old, setter=calls.append))
    self.assertEqual(len(calls), 1)

Upgrading without waiting for a login

If a database holds a weak hash such as MD5, you may not want to wait for users to return. You cannot re-hash without the plaintext, but you can wrap: apply the strong function to the weak hash. The result, PBKDF2 of the MD5 digest, is checked by a custom hasher that computes MD5 first, then PBKDF2. Django’s documentation describes the pattern; this is the hasher, with the Bookshelf app name:

catalog/hashers.py (addition)
from django.contrib.auth.hashers import MD5PasswordHasher, PBKDF2PasswordHasher


class PBKDF2WrappedMD5PasswordHasher(PBKDF2PasswordHasher):
    algorithm = "pbkdf2_wrapped_md5"

    def encode_md5_hash(self, md5_hash, salt, iterations=None):
        return super().encode(md5_hash, salt, iterations)

    def encode(self, password, salt, iterations=None):
        _, _, md5_hash = MD5PasswordHasher().encode(password, salt).split("$", 2)
        return self.encode_md5_hash(md5_hash, salt, iterations)

A data migration then rewrites each md5$<salt>$<hash> row to pbkdf2_wrapped_md5$…​: it splits the old value, calls encode_md5_hash() and saves with update_fields=["password"]. Run it in a migrations.RunPython step that depends on the latest auth migration; the Django documentation warns it takes minutes for several thousand users, so test it on a copy first. Finally list the wrapped hasher in PASSWORD_HASHERS after the preferred one:

PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",
    "catalog.hashers.PBKDF2WrappedMD5PasswordHasher",
]

The scratch test builds a legacy hash from a fixed placeholder salt, wraps it and checks the right and a wrong password. At login a wrapped hash is also upgraded to the first hasher, so the wrapping is only a bridge:

def test_wrapped_md5(self):
    from catalog.hashers import PBKDF2WrappedMD5PasswordHasher
    from django.contrib.auth.hashers import MD5PasswordHasher

    legacy = MD5PasswordHasher().encode(PW, "fixed-salt")
    algorithm, salt, md5_hash = legacy.split("$", 2)
    wrapped = PBKDF2WrappedMD5PasswordHasher().encode_md5_hash(md5_hash, salt, iterations=1000)
    hashers = [
        "django.contrib.auth.hashers.PBKDF2PasswordHasher",
        "catalog.hashers.PBKDF2WrappedMD5PasswordHasher",
    ]
    with override_settings(PASSWORD_HASHERS=hashers):
        self.assertTrue(wrapped.startswith("pbkdf2_wrapped_md5$1000$fixed-salt$"))
        self.assertTrue(check_password(PW, wrapped))
        self.assertFalse(check_password("wrong", wrapped))

Hashing and checking by hand

django.contrib.auth.hashers can be used without the User model:

  • make_password(password, salt=None, hasher="default") returns a hash in the application’s format. hasher is an algorithm name from the table above; passing None as the password returns an unusable password, which check_password() never accepts.

  • check_password(password, encoded, setter=None, preferred="default") returns True or False; its asynchronous twin acheck_password(password, encoded, asetter=None, preferred="default") arrived in Django 5.0.

  • is_password_usable(encoded) is False for a hash created by set_unusable_password().

  • identify_hasher(encoded) returns the hasher instance that matches a stored hash.

On the model, user.set_password(raw) hashes, user.check_password(raw) verifies (and upgrades), user.set_unusable_password() and user.has_usable_password() mark accounts that must authenticate some other way (single sign-on, API-only service accounts), and AbstractBaseUser.acheck_password() is the async version. set_password() does not save: call user.save() afterwards.

catalog/tests.py (excerpt)
from django.contrib.auth.hashers import acheck_password, is_password_usable, make_password


def test_unusable_password(self):
    unusable = make_password(None)
    self.assertFalse(is_password_usable(unusable))
    self.assertFalse(check_password(PW, unusable))
    user = User(username="svc")
    user.set_unusable_password()
    self.assertFalse(user.has_usable_password())


async def test_acheck_password(self):
    encoded = make_password(PW)
    self.assertTrue(await acheck_password(PW, encoded))
    self.assertFalse(await acheck_password("wrong", encoded))

Hashing is CPU-bound and takes about a tenth of a second by design. In an async view call acheck_password() (or await user.acheck_password(…​)) so the work is moved off the event loop instead of blocking it.

Password validation with AUTH_PASSWORD_VALIDATORS

Hashing protects stored passwords; validation protects users from themselves. Validation is pluggable and controlled by AUTH_PASSWORD_VALIDATORS, a list of dictionaries with a dotted NAME and optional OPTIONS passed to the validator’s constructor. The setting’s own default is an empty list (no validation), but projects created with startproject enable the four built-in validators. The Bookshelf settings use all four with a longer minimum length, plus one custom validator described in Writing your own validator:

config/settings.py (excerpt)
AUTH_PASSWORD_VALIDATORS = [
    {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
    {
        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
        "OPTIONS": {"min_length": 12},
    },
    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
    {
        "NAME": "catalog.validators.NoSiteNameValidator",
        "OPTIONS": {"forbidden": ["bookshelf"]},
    },
]
Validator What it checks Options

MinimumLengthValidator

Length is at least min_length (code password_too_short).

min_length (default 8)

UserAttributeSimilarityValidator

The password is not too similar to user attributes, measured with difflib.SequenceMatcher.quick_ratio() (code password_too_similar).

user_attributes (default username, first_name, last_name, email; missing attributes are ignored), max_similarity (0.1 to 1.0, default 0.7)

CommonPasswordValidator

The lowercased password is not in a list of 20,000 common passwords (code password_too_common).

password_list_path: a file with one lowercase password per line, plain or gzipped

NumericPasswordValidator

The password is not entirely digits (code password_entirely_numeric).

none

Each validator provides an error message and a help text. Since Django 5.2 the built-in classes expose get_error_message() (alongside get_help_text()) as hooks, so a subclass can reword them. Errors and help texts always come back in the order of the setting.

Where validation runs

Validators run in the password forms (UserCreationForm, SetPasswordForm, PasswordChangeForm, the password-reset confirm form) and in the createsuperuser and changepassword commands. They do not run at the model level: User.objects.create_user(), create_superuser() and set_password() accept anything, on the reasoning that developers, not users, work at that level and that model validation does not run on save. If you accept passwords through an API or a custom form, call the validation functions yourself. A test shows all three behaviours:

catalog/forms.py
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import BaseUserCreationForm


class SignUpForm(BaseUserCreationForm):
    class Meta:
        model = get_user_model()
        fields = ("username", "email")
catalog/tests.py (excerpt)
from catalog.forms import SignUpForm


def test_create_user_skips_validation(self):
    user = User.objects.create_user("bob", password="1")
    self.assertTrue(user.check_password("1"))


def test_signup_form_validates(self):
    form = SignUpForm({"username": "carol", "email": "carol@example.com",
                       "password1": "12345678", "password2": "12345678"})
    self.assertFalse(form.is_valid())
    self.assertIn("password2", form.errors)


def test_password_change_view(self):
    User.objects.create_user("dave", password=PW)
    self.client.login(username="dave", password=PW)
    response = self.client.post("/accounts/password_change/", {
        "old_password": PW, "new_password1": "short", "new_password2": "short"})
    self.assertContains(response, "This password is too short")

BaseUserCreationForm (Django 4.2) is the recommended base class for customising user creation; UserCreationForm extends it with a check that rejects usernames differing only in case (5.0). Both validate the password with validate_password(), so the form above rejects 12345678 and accepts a long passphrase.

Calling validation from your own code

The functions live in django.contrib.auth.password_validation:

  • validate_password(password, user=None, password_validators=None) returns None or raises one ValidationError carrying every validator’s message. Without a user, validators that need one (user-attribute similarity) accept any password.

  • password_changed(password, user=None, password_validators=None) informs validators a password was changed; set_password() on an AbstractBaseUser marks the field dirty and save() calls it for you.

  • password_validators_help_texts() returns the list of help texts; password_validators_help_text_html() returns them as a <ul>, ready to use as a form field’s help_text.

  • get_password_validators(validator_config) builds validators from a configuration with the same shape as AUTH_PASSWORD_VALIDATORS, for the odd flow that needs a different set (for example staff accounts with a longer minimum).

Always pass the clear-text password, never a hash.

catalog/tests.py (excerpt)
from django.contrib.auth.password_validation import password_validators_help_texts, validate_password
from django.core.exceptions import ValidationError


def codes(self, password, user=None):
    with self.assertRaises(ValidationError) as ctx:
        validate_password(password, user)
    return [e.code for e in ctx.exception.error_list]


def test_each_validator(self):
    self.assertEqual(self.codes("short"), ["password_too_short"])
    self.assertEqual(self.codes("password1234"), ["password_too_common"])
    self.assertEqual(self.codes("739105826401"), ["password_entirely_numeric"])


def test_user_attribute_similarity(self):
    user = User(username="ada.lovelace", email="ada@example.com")
    self.assertEqual(self.codes("ada.lovelace2", user), ["password_too_similar"])
    validate_password("a-longer-unrelated-phrase", user)  # returns None

In manage.py shell the messages are plain strings: validate_password("short") raises This password is too short. It must contain at least 12 characters. with the Bookshelf settings, and password1234 gives This password is too common.

Writing your own validator

A validator is any class with two methods: validate(self, password, user=None), which returns None or raises ValidationError (and must cope with user being None), and get_help_text(). The OPTIONS of the setting are passed to __init__, so every constructor argument needs a default. Optionally implement password_changed(password, user=None); if you use it to prevent reuse by storing previous passwords, never store them in clear text. Use a code and params on the error so forms and tests can tell the rules apart:

catalog/validators.py
from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _


class NoSiteNameValidator:
    """Reject passwords that contain the name of the site (or any other forbidden word)."""

    def __init__(self, forbidden=("bookshelf",)):
        self.forbidden = [word.lower() for word in forbidden]

    def validate(self, password, user=None):
        lowered = password.lower()
        for word in self.forbidden:
            if word in lowered:
                raise ValidationError(
                    _("This password must not contain the word “%(word)s”."),
                    code="password_contains_site_name",
                    params={"word": word},
                )

    def get_help_text(self):
        return _("Your password can’t contain the name of this site.")

Passing params to ValidationError keeps the message translatable, because the interpolation happens after translation. The scratch test confirms the custom code and the help-text order:

def test_custom_validator_and_help_texts(self):
    self.assertEqual(self.codes("my-Bookshelf-secret-phrase"), ["password_contains_site_name"])
    texts = password_validators_help_texts()
    self.assertEqual(len(texts), 5)
    self.assertEqual(texts[-1], "Your password can’t contain the name of this site.")

A fast hasher for the test suite

Hashing is slow on purpose, and a suite that creates hundreds of users pays for it every time. Override PASSWORD_HASHERS in the test settings only, with the (insecure, fast) MD5 hasher, and never in production settings:

config/settings_test.py
from .settings import *  # noqa: F401,F403

PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
AUTH_PASSWORD_VALIDATORS = []

Run it with python manage.py test --settings=config.settings_test. Tests that exercise hashing behaviour (like the ones on this page) use override_settings instead, as shown above.

Pitfalls and checklist

  • Removing a hasher from PASSWORD_HASHERS while hashes that use it exist; those users cannot log in. Keep legacy entries until no row uses them.

  • Rolling back to a Django version older than the hasher in use, for example scrypt (added in 4.0) or the Argon2 attributes you tuned.

  • Putting Argon2PasswordHasher first without installing argon2-cffi (or BCryptSHA256PasswordHasher first without bcrypt).

  • Assuming create_user(), createsuperuser --noinput or fixtures validate passwords; only forms and the management commands do.

  • Passing a hash, not the clear-text password, to validate_password().

  • Storing passwords with make_password() yourself in a new column "for convenience" instead of using set_password().

  • Logging, emailing or placing passwords in URLs, messages or exceptions; hashed or not, they are secrets.

  • Using MD5 (or any non-default hasher) in production settings because "tests were slow".

  • Treating validators as a measure of strength: passing them does not make a password strong. Combine them with rate limiting, HTTPS and, where appropriate, multi-factor authentication (see Security).

  • Setting an iteration count so high that logins become a denial-of-service lever; measure on production hardware.

Where older books and tutorials are outdated

The reference books behind this section were written for much older Django releases, and password handling is one area where their numbers and advice have aged:

  • Iteration counts. Any figure quoted for the default PBKDF2 work factor is far below the 1,500,000 of Django 6.1; the default has been raised in every release since, and a hash stored under an old default is upgraded automatically at the next login.

  • Algorithm choice. Advice written before Django 4.0 offers only PBKDF2, bcrypt and Argon2. scrypt (4.0) is now a built-in, dependency-free memory-hard option, and the Argon2 variant is Argon2id.

  • Removed hashers and helpers. CryptPasswordHasher (deprecated 4.1, removed 5.0); SHA1PasswordHasher, UnsaltedSHA1PasswordHasher and UnsaltedMD5PasswordHasher (removed 5.1); BaseUserManager.make_random_password() (removed 5.1, use get_random_string() or the secrets module). MD5PasswordHasher remains only for legacy data and fast tests.

  • Validation. UserCreationForm is now built on BaseUserCreationForm (4.2); the validator classes got get_error_message() in 5.2.

What changed recently

Version Change

4.0

ScryptPasswordHasher added; PBKDF2 default 320,000.

4.1

PBKDF2 default 390,000; CryptPasswordHasher deprecated.

4.2

PBKDF2 default 600,000; BaseUserCreationForm.

5.0

PBKDF2 default 720,000; acheck_password() and AbstractBaseUser.acheck_password(); CryptPasswordHasher removed; UserCreationForm rejects usernames that differ only in case.

5.1

PBKDF2 default 870,000; scrypt default parallelism 1 to 5; SHA1 and unsalted hashers and make_random_password() removed.

5.2 LTS

PBKDF2 default 1,000,000; validator classes gain get_error_message().

6.0

PBKDF2 default 1,200,000.

6.1

PBKDF2 default 1,500,000.

Because the iteration count rises with every release, the practical upgrade path is to do nothing special: upgrade Django, and passwords migrate on login.