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 |
|
|
Keep the default and stay safe |
Do nothing: PBKDF2-SHA256 with 1,500,000 iterations in 6.1, raised in every release. |
|
Use a memory-hard algorithm |
|
|
Make hashing slower or faster |
Subclass the hasher and override |
|
Move users to a new algorithm |
Put the new hasher first, keep the old ones in the list; hashes upgrade at the next login. |
|
Upgrade hashes without waiting for logins |
A "wrapped" hasher plus a data migration. |
|
Hash or check a password by hand |
|
|
Reject weak passwords |
|
|
Add my own rule |
A class with |
|
Speed up the test suite |
A fast hasher in the test settings only. |
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):
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 = "/"
Source: Django: Settings
How Django stores passwords
The password field of a User is a string with four parts separated by dollar signs:
<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()raisesValueError, andcheck_password()treats that as a failed check (it returnsFalse).
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:
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.
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:
-
Install the library:
python -m pip install "django[argon2]"(equivalent to installingargon2-cffiwith the version constraint Django declares). -
Put
Argon2PasswordHasherfirst inPASSWORD_HASHERSand keep the others after it.
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",
]
Source: Django: Using Argon2 with Django
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>
Source: Django: Using Argon2 with Django
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:
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",
]
Source: Django: Using scrypt with Django
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.
| Hasher | Algorithm name | Notes |
|---|---|---|
|
|
Default; no extra dependency. |
|
|
For verifying old hashes only. |
|
|
Needs |
|
|
Needs |
|
|
For verifying old hashes only. |
|
|
Needs OpenSSL 1.1+. |
|
|
Insecure; only for legacy data and fast tests. |
Source: Django: Included hashers
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.
from django.contrib.auth.hashers import PBKDF2PasswordHasher
class StrongerPBKDF2PasswordHasher(PBKDF2PasswordHasher):
"""PBKDF2-SHA256 with twice the iterations of the Django default."""
iterations = PBKDF2PasswordHasher.iterations * 2
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):
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 |
|
1,500,000 |
bcrypt |
|
12 |
Argon2 |
|
2, 102400, 8 |
scrypt |
|
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:
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$"))
Source: Django: Password upgrading
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)
Source: Django: check_password()
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:
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.hasheris an algorithm name from the table above; passingNoneas the password returns an unusable password, whichcheck_password()never accepts. -
check_password(password, encoded, setter=None, preferred="default")returnsTrueorFalse; its asynchronous twinacheck_password(password, encoded, asetter=None, preferred="default")arrived in Django 5.0. -
is_password_usable(encoded)isFalsefor a hash created byset_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.
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:
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 |
|---|---|---|
|
Length is at least |
|
|
The password is not too similar to user attributes, measured with |
|
|
The lowercased password is not in a list of 20,000 common passwords (code |
|
|
The password is not entirely digits (code |
none |
Source: Django: Included validators
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:
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")
Source: Django: BaseUserCreationForm
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")
Source: Django: Password validation
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)returnsNoneor raises oneValidationErrorcarrying every validator’s message. Without auser, 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 anAbstractBaseUsermarks the field dirty andsave()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’shelp_text. -
get_password_validators(validator_config)builds validators from a configuration with the same shape asAUTH_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.
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
Source: Django: Integrating validation
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:
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:
from .settings import * # noqa: F401,F403
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
AUTH_PASSWORD_VALIDATORS = []
Source: Django: Speeding up the tests
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_HASHERSwhile 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
Argon2PasswordHasherfirst without installingargon2-cffi(orBCryptSHA256PasswordHasherfirst withoutbcrypt). -
Assuming
create_user(),createsuperuser --noinputor 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 usingset_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,UnsaltedSHA1PasswordHasherandUnsaltedMD5PasswordHasher(removed 5.1);BaseUserManager.make_random_password()(removed 5.1, useget_random_string()or thesecretsmodule).MD5PasswordHasherremains only for legacy data and fast tests. -
Validation.
UserCreationFormis now built onBaseUserCreationForm(4.2); the validator classes gotget_error_message()in 5.2.
What changed recently
| Version | Change |
|---|---|
4.0 |
|
4.1 |
PBKDF2 default 390,000; |
4.2 |
PBKDF2 default 600,000; |
5.0 |
PBKDF2 default 720,000; |
5.1 |
PBKDF2 default 870,000; scrypt default parallelism 1 to 5; SHA1 and unsalted hashers and |
5.2 LTS |
PBKDF2 default 1,000,000; validator classes gain |
6.0 |
PBKDF2 default 1,200,000. |
6.1 |
PBKDF2 default 1,500,000. |
Source: Django 6.1 release notes
Because the iteration count rises with every release, the practical upgrade path is to do nothing special: upgrade Django, and passwords migrate on login.