Chapter 18 · Authentication, sessions and messages
18.5 Password hashing: make_password and the hashers
make_password turns a password into the string a user model stores, and django checks a password against such a string. Between them stand the hashers, the classes of PASSWORD_HASHERS: the first makes new strings unless another is named, and each checks the strings of its own algorithm.
A site has to check a password without being able to give it back, so it stores a one-way function of it: slow, so that a stolen table costs an attacker time for every guess, and salted with a random value, against rainbow tables. The cost must rise as computers grow faster, without breaking stored strings. So the string describes itself: it names its hasher and carries what that hasher needs to make it again, the cost included. An old string is checked by what it says while its hasher is listed, and replaced at its user’s next login.
The encoded string
"$". The first part chooses the hasher; the rest is all it needs.PBKDF2PasswordHasher derives the key with pbkdf2, which calls hashlib, and joins the four parts. PBKDF2PasswordHasher encodes the password given with the stored salt and iterations and compares the strings with constant_time_compare (Secret keys and django): nothing is decrypted. The digest is the one parameter the string does not carry, and the class’s docstring says the algorithm must be renamed if the digest changes.
make_password
make_password takes a password, a salt and a hasher, "default" unless named. None aside, it refuses anything but a string or bytes with a TypeError, takes the hasher from get_hasher, asks it for a salt when none was given, and returns its encode. AbstractBaseUser calls it (The user model: AbstractBaseUser, User and AUTH_USER_MODEL).
None gives an unusable password, UNUSABLE_PASSWORD_PREFIX, "!", and 40 random characters. is_password_usable answers false for it, and verify_password answers no to every password against it without asking a hasher. The docstring, citing ticket 20079, says the random part makes access to a staff or superuser account less likely.
# a shell of the harbour office's site
>>> make_password(None)
'!2U81a9QevIDf3U3kwO6ouyDp9stBPJUD7FaLmKYu'
Finding the hasher
get_hashers makes an instance of each class of PASSWORD_HASHERS, refusing one whose algorithm is empty with ImproperlyConfigured; get_hashers_by_algorithm indexes them by algorithm. Both are cached with functools, and reset_hashers, a receiver of setting_changed, empties them when a test overrides PASSWORD_HASHERS (Overriding settings). Making a hasher loads nothing: the default list’s Argon2 and bcrypt hashers need their libraries only at a string of theirs, failing then with the ValueError of BasePasswordHasher.
get_hasher returns a hasher it is given as it is, the first of the list for "default", and otherwise the hasher of that algorithm, or a ValueError. identify_hasher takes the algorithm from a stored string’s part before the first "$". It first recognises the unsalted forms of ancient Django by length and form as "unsalted_md5" and "unsalted_sha1", whose hashers Django 5.1 removed, so such a string finds none unless a project supplies one.
Checking a password: verify_password
verify_password answers whether the password matches the string, and whether the string should be made again. In order:
- If the password is
None, the string unusable, oridentify_hasherfinds no hasher, it hashes 40 random characters with the default hasher and answers no to both. The comment’s reason: to narrow the difference in time between a user with an unusable password and a missing user or hasher, as for ticket 20760 (Authenticating:authenticateand the backends). - The string is due for an update if its algorithm is not the preferred hasher’s, the default unless named, or if the preferred hasher’s
must_updatesays so. - The string’s hasher’s
verifydecides the match. - If the password was wrong, the algorithm the preferred one and the string due for an update, the hasher’s
harden_runtimeruns, to close, by the comment, the gap in time between the string’s work factor and the hasher’s default.
verify_password’s answers and times in one run, the last row’s with MD5PasswordHasher listed after PBKDF2PasswordHasher. The wrong password against the older string paid 300,000 iterations more in harden_runtime.| the stored string | password | answer | time |
|---|---|---|---|
| nora’s, 1,800,000 iterations | password right; wrong | answer (True, False); (False, False) |
time 0.58 s |
| 1,500,000 iterations | password right | answer (True, True) |
time 0.49 s |
| 1,500,000 iterations | password wrong | answer (False, True) |
time 0.58 s |
| unusable | password any | answer (False, False) |
time 0.57 s |
MD5’s |
password right; wrong | answer (True, True); (False, True) |
time 0.00001 s |
The upgrade at a login
django returns verify_password’s first answer and, when the password is right and the string due for an update, calls the setter it was given with the password. acheck_password runs verify_password through sync_to_async and awaits the setter.
AbstractBaseUser’s setter sets the password with the default hasher and saves the password field alone, first clearing AbstractBaseUser, since by its comment a hash upgrade is not a password change. With PASSWORD_HASHERS holding PBKDF2PasswordHasher and MD5PasswordHasher, tomas was given an MD5 string and logged in at the login view, whose second SQL statement updated the password column alone:
# a shell of the harbour office's site
>>> User.objects.get(username="tomas").password
'md5$hCHZa3vLmm3mvN6FfAlHom$1cc3fbe1781fa9d711672b83d5f786b5'
>>> response = Client(HTTP_HOST="harbour.example").post(
... "/accounts/login/", {"username": "tomas", "password": "Beagle-1831"})
>>> User.objects.get(username="tomas").password
'pbkdf2_sha256$1800000$Egin7tbQI1sXoIICzgsKjc$Kl7V/ePlbfF9M3mhGoDEg8V24qwLlDUsbZnon8r5VeA='
BasePasswordHasher
A subclass of BasePasswordHasher names its algorithm and implements encode, decode, verify and safe_summary, which raise NotImplementedError on the base. BasePasswordHasher answers false; BasePasswordHasher warns, its docstring asking a hasher with no work factor to do nothing.
BasePasswordHasher draws from RANDOM_STRING_CHARS, 62 letters and digits, enough characters for BasePasswordHasher, 128 bits: 22, which hold about 131. must_update_salt makes the same reckoning of a stored salt, for the PBKDF2, Argon2 and MD5 hashers’ must_update. safe_summary gives a string’s parts for display, mask_hash keeping six characters of the salt (two of an MD5 salt) and of the hash and a "*" for each of the rest; UserChangeForm shows it in place of the password (The authentication forms: AuthenticationForm and the password forms).
The hashers Django ships
PASSWORD_HASHERS lists all but BCryptPasswordHasher and MD5PasswordHasher, in this order (django/conf/global_settings.py ).| hasher | algorithm |
after it, between "$" |
the cost |
|---|---|---|---|
PBKDF2 |
algorithm "pbkdf2_sha256" |
after it, between "$" iterations, salt, key in base64 |
the cost 1,800,000 iterations |
PBKDF2 |
algorithm "pbkdf2_sha1" |
after it, between "$" the same, with SHA-1 |
the cost the same |
Argon2 |
algorithm "argon2" |
after it, between "$" argon2-cffi’s: variety, version, parameters, salt, hash |
the cost time 2, memory 102,400 KiB, parallelism 8 |
BCrypt |
algorithm "bcrypt_sha256" |
after it, between "$" bcrypt’s: an empty part, its algorithm string, rounds, then salt and checksum in one |
the cost 12 rounds |
BCrypt |
algorithm "bcrypt" |
after it, between "$" the same |
the cost the same |
Scrypt |
algorithm "scrypt" |
after it, between "$" work factor, salt, block size, parallelism, key in base64 |
the cost 16,384, 8, 5 |
MD5 |
algorithm "md5" |
after it, between "$" salt, MD5 of salt and password in hex |
the cost none |
PBKDF2PasswordHasher asks for a new string when the stored iterations differ from PBKDF2PasswordHasher, fewer or more, or the salt is short; PBKDF2PasswordHasher runs the iterations missing.
Argon2PasswordHasher hashes with argon2-cffi’s Argon2id and leaves the check to that library. BCryptSHA256PasswordHasher takes its salt from bcrypt, which writes the rounds into it, and hashes the password’s SHA-256 digest in hex, against the truncation of ticket 20138: BCryptPasswordHasher, hashing the password as given, is subject to bcrypt’s limit of 72 bytes, its docstring says. A round doubles bcrypt’s work, so its harden_runtime hashes three more times for two rounds missing. That of Argon2 and of scrypt does nothing, their comments calling the runtime too complicated to harden sensibly.
MD5PasswordHasher is the documentation’s choice for tests that authenticate many users, where the default hasher is “rather slow by design”.