skip to content

Password Hashing & Reset

Django stores passwords as algorithm$iterations$salt$hash under PASSWORD_HASHERS and upgrades old hashes on login. Interviewers probe hasher choice, validators and how reset tokens expire.

part ofDjangooverview, primer and where to startread it →
on this pageshow

explore

questions

5

How does Django store a user's password, and what role does the order of the PASSWORD_HASHERS setting play?

level: middleimportance: must knowfreq 62%

answer

  1. never the password itself
  2. four fields joined by dollar signs
  3. the prefix picks the verifier
  4. first entry wins for new hashes
  5. iteration count raised in 6.1

basics

~10 s

Django stores algorithm$parameters$salt$hash, by default pbkdf2_sha256 with 1,500,000 iterations in 6.1. The first PASSWORD_HASHERS entry hashes new passwords; the others only verify existing hashes, chosen by the stored prefix.

solid answer

~40 s

`set_password()` calls `make_password()`, which asks the preferred hasher to encode the password with a fresh random salt. With the default settings the `password` column holds `pbkdf2_sha256$1500000$<salt>$<base64 hash>`; Django 6.1 raised the PBKDF2 default from 1,200,000 to 1,500,000 iterations. `PASSWORD_HASHERS` lists the hashers the project trusts: the **first** entry is used for every new hash and is the target of upgrades, and the rest exist only so existing hashes can still be verified. Verification does not try them in turn; `identify_hasher()` reads the algorithm name before the first `$` and loads that hasher. If it is not listed, the check simply fails. To prefer Argon2 you install `argon2-cffi` and put `Argon2PasswordHasher` first, keeping the PBKDF2 hashers below it.

code

python · 8 lines
python
# settings.py - prefer Argon2id, keep the defaults so existing hashes still verify
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',
]

go deeper

for a junior

Recall that Django stores algorithm, parameters, salt and hash in one string, and that set_password() hashes but does not save.

for a middle

Explain that the first PASSWORD_HASHERS entry hashes new passwords, the prefix selects the verifier, and a missing hasher makes those logins fail.

for a senior

Plan hasher changes safely: add the new hasher first, keep the old ones listed until no hashes use them, and watch the 6.1 iteration bump roll through on login.

for a principal

Weigh Argon2's memory hardness against the extra native dependency and the server cost per login, and set a policy for when work factors are raised.

## What ends up in the password column Django never stores a password or a reversible form of it. `AbstractBaseUser.set_password(raw)` calls `django.contrib.auth.hashers.make_password()`, which: 1. picks the **preferred hasher**, the first class in `PASSWORD_HASHERS`; 2. generates a random salt with at least 128 bits of entropy; 3. calls the hasher's `encode()`, which returns a self-describing string. For the default `PBKDF2PasswordHasher` in Django 6.1 that string is: ```text pbkdf2_sha256$1500000$<salt>$<base64 of the derived key> ``` - **algorithm** - `pbkdf2_sha256`, the hasher's `algorithm` attribute; - **work factor** - the iteration count used for this particular hash; - **salt** - random per password, so equal passwords hash differently; - **hash** - the derived key. Because each hash records its own algorithm and parameters, Django can verify old hashes after the defaults change. `set_password()` does not save; the caller must call `save()`. `set_unusable_password()` stores `!` followed by 40 random characters, which no hasher will ever match. ## The default PASSWORD_HASHERS | Position | Hasher | Algorithm prefix | Extra dependency | |---|---|---|---| | 1 (preferred) | `PBKDF2PasswordHasher` | `pbkdf2_sha256` | none | | 2 | `PBKDF2SHA1PasswordHasher` | `pbkdf2_sha1` | none | | 3 | `Argon2PasswordHasher` | `argon2` | `argon2-cffi` | | 4 | `BCryptSHA256PasswordHasher` | `bcrypt_sha256` | `bcrypt` | | 5 | `ScryptPasswordHasher` | `scrypt` | none (uses the standard library) | The comment above the setting in `global_settings.py` states the rule: the first hasher is the preferred algorithm, and passwords using the others are converted automatically on login. ## How verification picks a hasher `check_password()` calls `verify_password()`, which calls `identify_hasher(encoded)`. That function reads the text before the first `$` and asks `get_hasher()` for the matching class **among the hashers listed in the setting**. So: - the list is not tried in order; the prefix selects exactly one hasher; - a hash whose algorithm is missing from the list cannot be verified: `get_hasher()` raises `ValueError`, `verify_password()` treats it as a failed check (after running the default hasher once so the timing looks normal), and the user cannot sign in; - removing an entry from the list is therefore a breaking change for every user still stored with it. After a successful check, if the stored hash used a different algorithm from the preferred one, or the preferred hasher's `must_update()` says its parameters are stale, the password is re-hashed and saved. ## Choosing the preferred hasher - **PBKDF2-SHA256** is the default because it needs nothing beyond Python. Django raises the iteration count in new releases (1,200,000 in 6.0, 1,500,000 in 6.1), and existing hashes are upgraded as users log in. - **Argon2** is the algorithm the Django docs point to as the Password Hashing Competition's recommendation. Install it with `python -m pip install django[argon2]` and list `Argon2PasswordHasher` first; Django uses the Argon2id variant with `time_cost=2`, `memory_cost=102400` and `parallelism=8` by default. - **bcrypt** is supported through `BCryptSHA256PasswordHasher`, which pre-hashes with SHA-256 to avoid bcrypt's input length limit. - **scrypt** is memory-hard and needs no extra package. To tune a work factor, subclass the hasher, change the attribute (for example `iterations`), and list your subclass first. ## Removed hashers Django 5.1 removed `SHA1PasswordHasher`, `UnsaltedSHA1PasswordHasher` and `UnsaltedMD5PasswordHasher`. Salted `MD5PasswordHasher` still exists for importing legacy data and for fast test suites, but it is not in the default list and should never be first in production.

  • Why does Django run a hasher even when the stored hash cannot be identified or the user does not exist?
    To keep response times similar. `verify_password()` hashes a random string with the default hasher when the stored value is unusable or its algorithm is unknown, and `ModelBackend` hashes the submitted password when no user matches. Without that, a fast failure would reveal which accounts exist or which ones have unusable passwords.
  • How do you make a test suite that creates many users faster without touching production settings?
    Override `PASSWORD_HASHERS` in the test settings with `['django.contrib.auth.hashers.MD5PasswordHasher']`. Tests that create users or log in then skip the deliberately slow key derivation. The weak hasher never reaches production because only the test settings module lists it.

saying these in an interview costs you the question

  • Django encrypts passwords and can decrypt them for admins
  • Django tries every hasher in PASSWORD_HASHERS until one matches
  • Removing an old hasher from the list forces users to re-hash on login
  • Argon2 is Django's default hasher out of the box
  • set_password() saves the user to the database immediately
open as a page

What do Django's AUTH_PASSWORD_VALIDATORS check in a new project, and which Django code paths actually run them?

level: juniorimportance: should knowfreq 50%

basics

~10 s

A new project lists four validators: attribute similarity, minimum length (8 by default), common passwords and all-numeric. validate_password() runs them from Django's password forms and the createsuperuser and changepassword commands, not from set_password().

open as a page

How does Django's PasswordResetTokenGenerator make reset links expire and work only once without storing any tokens in the database?

level: middleimportance: should knowfreq 40%

basics

~20 s

The token is a base36 timestamp plus an HMAC of the user's pk, password hash, last_login, that timestamp and email. Changing the password changes the HMAC input, killing the link; check_token() rejects tokens older than PASSWORD_RESET_TIMEOUT (3 days).

open as a page

Walk through Django's built-in password reset flow: which four views run, and why does the confirm view redirect to a set-password URL?

level: middleimportance: should knowfreq 46%

basics

~20 s

PasswordResetView emails a uidb64/token link, PasswordResetDoneView confirms, PasswordResetConfirmView checks the token and sets the new password, PasswordResetCompleteView finishes. The confirm view moves the token into the session and redirects so it stays out of Referer headers.

open as a page

After importing a legacy user base with salted MD5 hashes into Django, how do you move every account to a strong hasher, including users who never sign in again?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Store the hashes in a format a listed hasher verifies, so Django upgrades each one on a correct login. Dormant accounts never upgrade, so wrap their MD5 hashes in PBKDF2 with a custom hasher and a data migration, then drop MD5.

open as a page