skip to content

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%

answer

  1. make the old format verifiable first
  2. upgrade happens only on a correct login
  3. dormant accounts never trigger it
  4. wrap the weak hash inside a strong one
  5. unusable passwords cannot be reset

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.

solid answer

~50 s

First make the imported hashes verifiable: if the legacy scheme is `md5(salt + password)` you can store them as `md5$<salt>$<hash>` and add `MD5PasswordHasher` to `PASSWORD_HASHERS` below the preferred hasher; otherwise write a `BasePasswordHasher` subclass with its own algorithm prefix. On each correct login, `user.check_password()` sees the algorithm differs from the preferred one and calls its setter, which re-hashes with the first hasher and saves only the `password` field. That leaves dormant accounts on MD5 indefinitely. The Django docs' answer is a **wrapped hasher**: a PBKDF2 subclass whose input is the MD5 digest, plus a data migration that rewrites every `md5$` row to `pbkdf2_wrapped_md5$...` without knowing any password. After that nothing weak is stored, and MD5 can leave the list. Setting unusable passwords instead would lock those users out, because the reset form skips accounts without a usable password.

code

python · 21 lines
python
# accounts/hashers.py
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)


# settings.py
# PASSWORD_HASHERS = [
#     'django.contrib.auth.hashers.PBKDF2PasswordHasher',
#     'accounts.hashers.PBKDF2WrappedMD5PasswordHasher',
#     'django.contrib.auth.hashers.MD5PasswordHasher',  # remove once no md5$ rows remain
# ]

go deeper

for a junior

Recall that Django re-hashes a password with the preferred hasher when the user signs in with it correctly.

for a middle

Explain the must_update rules: a different algorithm or stale parameters, and the setter that saves only the password field.

for a senior

Close the dormant-account gap with a wrapped hasher and a batched data migration, and anticipate the session and reset-form side effects.

for a principal

Decide how long weak hashes may exist after an import and whether to accept locked-out dormant users, balancing breach exposure against support cost.

## Step 1 - make the old hashes verifiable Django verifies a hash by reading its algorithm prefix and loading the matching hasher from `PASSWORD_HASHERS`. Imported hashes therefore need a prefix Django understands: - If the old system computed `md5(salt + password)`, the built-in `MD5PasswordHasher` matches it exactly; store the value as `md5$<salt>$<hex digest>` and add the hasher to the list. - If it used another construction (a pepper, a different concatenation order, SHA-1 with a custom salt), write a subclass of `BasePasswordHasher` with a unique `algorithm` name and `encode()`, `decode()`, `verify()` and `safe_summary()` methods that reproduce it. In both cases the legacy hasher goes **below** the preferred one, never first. ## Step 2 - upgrade on login When a member signs in, `ModelBackend` calls `user.check_password(raw)`. `AbstractBaseUser.check_password()` passes a **setter** to `django.contrib.auth.hashers.check_password()`, and `verify_password()` returns two booleans: whether the password is correct and whether it must be updated. It must be updated when: 1. the stored algorithm differs from the preferred hasher's; or 2. the preferred hasher's `must_update()` returns `True`, for example PBKDF2 hashes with fewer iterations than the current default or a salt with too little entropy. If both are true, the setter calls `set_password()` and `save(update_fields=['password'])`. It deliberately does not count as a password change, so validators' `password_changed()` hooks are not called. Code that calls the module-level `check_password()` without a setter verifies but never upgrades. ## Step 3 - deal with users who never come back Upgrade-on-login leaves every dormant account on MD5, which is exactly the data an attacker wants from a leaked database. The options: | Option | Result | Catch | |---|---|---| | Wait for logins | active users upgraded | dormant hashes stay weak forever | | **Wrapped hasher** + data migration | every row becomes PBKDF2 over the MD5 digest | a custom hasher to maintain until users log in and move to the plain preferred hasher | | `set_unusable_password()` for dormant users | no weak hashes left | the built-in reset form skips them, so they are locked out unless you override `PasswordResetForm.get_users()` | The Django docs describe the wrapped approach under "Password upgrading without requiring a login": a `PBKDF2WrappedMD5PasswordHasher` subclass of `PBKDF2PasswordHasher` whose `encode()` first computes the MD5 digest of the password and then runs PBKDF2 over it, plus a `RunPython` migration that reads each `md5$salt$hash` row and stores `hasher.encode_md5_hash(md5_hash, salt)`. The migration never needs the plaintext. List the wrapped hasher after the preferred one; when a wrapped user signs in, the upgrade in step 2 moves them to the plain preferred hasher. ## Side effects to plan for - **Sessions.** Django's session verification uses an HMAC of the `password` field. An upgrade rewrites that field, so a member's other signed-in sessions end on their next request after they log in somewhere new. - **Timing.** When the stored hash uses the preferred algorithm but stale parameters, `harden_runtime()` does extra work on a failed check so weaker hashes do not answer faster. - **Cost.** The docs warn the rewrap migration takes minutes per few thousand users; run it in batches. - **Clean-up.** Query for remaining `md5$` prefixes; when none are left, remove `MD5PasswordHasher` from the list.

  • Why does the wrapped hasher need its own algorithm name?
    Verification is chosen by the prefix. A wrapped hash must not be mistaken for a plain `pbkdf2_sha256` hash, because its input is the MD5 digest, not the password. The distinct `pbkdf2_wrapped_md5` prefix routes it to the class that computes MD5 first, and because it differs from the preferred algorithm, the next correct login upgrades it to plain PBKDF2.
  • Does raising the PBKDF2 iteration count in a Django upgrade need a migration?
    No. `PBKDF2PasswordHasher.must_update()` returns `True` when a hash's stored iteration count differs from the class default, so each hash is re-encoded at the new count on the user's next correct login. Hashes of users who never log in keep the old count, which is still a strong hash.

saying these in an interview costs you the question

  • Django upgrades every stored hash when you change PASSWORD_HASHERS
  • Setting unusable passwords is harmless because users can simply reset
  • Adding the legacy hasher first in the list is fine during the migration
  • A data migration can re-hash passwords with the new algorithm directly
  • Hash upgrades on login count as password changes and notify validators