Software Securityhome
Fix a findingSearch
Fix card · A04:2025 Cryptographic Failures

Fix weak password hashing in Python and Django

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Install argon2-cffi and list django.contrib.auth.hashers.Argon2PasswordHasher first in PASSWORD_HASHERS, keeping older hashers after it. Hash with make_password() (or user.set_password()) and verify with check_password(password, stored, setter): Django calls the setter after a correct login when the hash is outdated, so you can save a new one. Never hash passwords with hashlib.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework Django 6.1.2 (django.contrib.auth.hashers)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks7 cards
Download the project ZIP, 24 KB
On this page

Steps

  1. Find hand-written password hashing: hashlib, hmac or custom salts in registration, login and password-reset code, and PASSWORD_HASHERS lists that start with MD5PasswordHasher or another fast hasher.
  2. Install argon2-cffi (django[argon2]) and put Argon2PasswordHasher first in PASSWORD_HASHERS; keep every hasher your stored hashes still use after it, or those users cannot sign in.
  3. Store new passwords with make_password() or user.set_password(), never with hashlib.
  4. Verify with check_password(password, stored, setter) or user.check_password(): Django calls the setter after a correct password whose hash was made by another hasher or with other parameters, and User.check_password() saves the new hash itself.
  5. For rows from an older system that Django cannot identify, verify them yourself in constant time and rehash after a correct login, or wrap them once with a custom hasher as Django's password-upgrading section shows, so weak hashes do not wait for users who never return.
  6. Add regression tests that read the stored hash and check its algorithm and parameters, as an attacker with a copy of the table would.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerableaccounts/impl/vulnerable.pyPython
def hash_password(password: str) -> str:
    return hashlib.sha256(password.encode("utf-8")).hexdigest()

With IMPL=vulnerable, 5 of 12 tests fail (5 exploit tests).

The fix: Argon2 first in PASSWORD_HASHERS

Fixedaccounts/impl/fixed.pyPython
# Argon2id with Django's parameters: 100 MiB (102400 KiB) of memory, 2 passes, 8 lanes, above the
# OWASP minimum. Raise them by subclassing Argon2PasswordHasher; every hash records its own
# parameters, so older hashes keep verifying and are upgraded at the next sign-in.
PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.Argon2PasswordHasher",
    "django.contrib.auth.hashers.PBKDF2PasswordHasher",  # verifies older PBKDF2 rows, if any
]


def hash_password(password: str) -> str:
    from django.contrib.auth.hashers import make_password

    return make_password(password)

With IMPL=fixed, 12 of 12 tests pass.

The fix: verify and upgrade

Fixedaccounts/impl/fixed.pyPython
LEGACY_SHA256 = re.compile(r"[0-9a-f]{64}")


def verify_password(password: str, stored: str) -> Verification:
    from django.contrib.auth.hashers import check_password

    if LEGACY_SHA256.fullmatch(stored):
        # A row from the old system: unsalted SHA-256. Compare in constant time, and ask for a
        # rehash so the next successful login replaces it.
        digest = hashlib.sha256(password.encode("utf-8")).hexdigest()
        valid = hmac.compare_digest(digest, stored)
        return Verification(valid=valid, needs_rehash=valid)
    # check_password() calls the setter only for a correct password whose hash was made by
    # another hasher than the first in PASSWORD_HASHERS or with other parameters.
    outdated = []
    valid = check_password(password, stored, setter=outdated.append)
    return Verification(valid=valid, needs_rehash=bool(outdated))

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fixaccounts/impl/wrong.pyPython
# Copied from the test settings to make logins faster.
PASSWORD_HASHERS = [
    "django.contrib.auth.hashers.MD5PasswordHasher",
    "django.contrib.auth.hashers.Argon2PasswordHasher",
]


def hash_password(password: str) -> str:
    from django.contrib.auth.hashers import make_password

    return make_password(password)

With IMPL=wrong, 3 of 12 tests fail (3 exploit tests).

Login: rehash after a correct password

accounts/views.pyPython
@require_POST
def login(request):
    credentials = read_credentials(request)
    if credentials is None:
        return JsonResponse(BAD_REQUEST, status=400)
    username, password = credentials
    impl = load_impl()
    account = Account.objects.filter(username=username).first()
    result = impl.verify_password(password, account.password_hash if account else dummy_hash())
    if account is None or not result.valid:
        return JsonResponse({"error": "invalid username or password"}, status=401)
    if result.needs_rehash:
        # Only now is the plaintext known to be right. Replace exactly the hash that was
        # verified: if a password change landed in the meantime, no row matches.
        Account.objects.filter(pk=account.pk, password_hash=account.password_hash).update(
            password_hash=impl.hash_password(password)
        )
    return JsonResponse({"username": username})

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/test_passwords.pyPython
# Exploit: what a stolen copy of the users table gives an attacker, and how hashes upgrade.

def test_exploit_a_new_hash_is_argon2id_at_or_above_the_owasp_minimum(client, stored_hash):
    assert register(client, "ivan", "ivan-has-a-long-passphrase").status_code == 201
    assert_meets_policy(stored_hash("ivan"))


def test_exploit_the_same_password_gives_each_user_a_different_hash(client, stored_hash):
    register(client, "judy", LEGACY_PASSWORD)
    register(client, "mallory", LEGACY_PASSWORD)
    assert stored_hash("judy") != stored_hash("mallory")


def test_exploit_a_legacy_sha256_hash_is_replaced_after_a_successful_login(client, stored_hash):
    legacy = stored_hash("alice")
    assert login(client, "alice", LEGACY_PASSWORD).status_code == 200
    assert stored_hash("alice") != legacy
    assert_meets_policy(stored_hash("alice"))
    assert login(client, "alice", LEGACY_PASSWORD).status_code == 200, "the new hash verifies"


def test_exploit_a_wrong_password_is_rejected_and_leaves_the_legacy_hash_alone(client, stored_hash):
    legacy = stored_hash("alice")
    assert login(client, "alice", "correct horse battery stapler").status_code == 401
    assert stored_hash("alice") == legacy


def test_exploit_a_hash_with_weaker_parameters_is_rehashed_after_login(client, stored_hash, set_stored_hash):
    # 4 MiB and 3 passes: below every OWASP minimum configuration, in Django's argon2$ format.
    set_stored_hash("bob", "argon2" + PasswordHasher(time_cost=3, memory_cost=4096, parallelism=1).hash(LEGACY_PASSWORD))
    assert login(client, "bob", LEGACY_PASSWORD).status_code == 200
    assert_meets_policy(stored_hash("bob"))


def test_exploit_the_legacy_hash_comparison_is_constant_time(client, monkeypatch):
    calls = []
    real_compare_digest = hmac.compare_digest

    def spy(a, b):
        calls.append((a, b))
        return real_compare_digest(a, b)

    # secrets.compare_digest is the same function under another name.
    monkeypatch.setattr(hmac, "compare_digest", spy)
    monkeypatch.setattr(secrets, "compare_digest", spy)
    assert login(client, "alice", LEGACY_PASSWORD).status_code == 200
    assert calls, "the SHA-256 comparison did not use hmac.compare_digest"

Pitfalls

  • Django's testing docs suggest PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] to speed up tests. Copied into production settings it hashes every new password with one salted MD5, and check_password() then downgrades stronger hashes to MD5 at the next login. That is this card's wrong implementation.
  • Django upgrades only hashes made by hashers still listed in PASSWORD_HASHERS. Removing an old entry locks those users out instead of upgrading them.
  • Django's default hasher is PBKDF2-SHA256 with 1,500,000 iterations in 6.1, which is acceptable; Argon2 needs argon2-cffi and is not the default only because of that dependency.
  • Argon2PasswordHasher's parameters (100 MiB, 2 passes, 8 lanes) are class attributes; change them in a subclass and list the subclass. Every login then rehashes older hashes, in both directions, because must_update() compares parameters for equality.
  • Argon2 allocates its memory per hash: 100 MiB times the number of concurrent logins. Measure, and limit concurrent sign-ins if needed.
  • Django stores Argon2 as argon2$argon2id$v=19$..., the standard PHC string with an argon2 prefix; tools that read PHC strings need the prefix removed.
  • For an unknown username, verify against a dummy hash (as this card's view does, and as ModelBackend does by running the default hasher) so response times do not reveal which accounts exist.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs password-hashing/python-django does this for you).
  2. In a Django shell, check that make_password('x') starts with argon2$argon2id$ and that check_password() on an old PBKDF2 or MD5 hash calls its setter.
  3. Do not rely on scanners: nothing checked here flags SHA-256 password hashes or an MD5PasswordHasher entry in PASSWORD_HASHERS. The regression tests are the control.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix weak password hashing in Python and Django
ToolRuleWhat it found here
Semgreppython.lang.security.audit.md5-used-as-password.md5-used-as-passwordNo findings: its sources are MD5 constructors, so it misses the unsalted SHA-256 in vulnerable.py, and it does not read the "django.contrib.auth.hashers.MD5PasswordHasher" string that puts MD5 first in wrong.py.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> accounts config (Ran 1 rule)
Semgreppython.django.security.audit.unvalidated-password.unvalidated-passwordNo findings on any implementation. The rule looks for set_password() without validate_password(); this card stores hashes through its own model rather than User.set_password().Checked: Semgrep 1.180.0 (CE) (Ran 1 rule); also p/django, p/python, p/security-audit and p/owasp-top-ten
CodeQLpy/weak-sensitive-data-hashingRead from the source, not run: a parameter named password is a sensitive source, and for password data any plain hash such as hashlib.sha256() is a sink. That matches vulnerable.py and the legacy branches of fixed.py and wrong.py (expected until no legacy rows remain). Django's MD5PasswordHasher, reached through make_password() and a settings string, is library code the query does not treat as a sink, so the wrong implementation's real mistake goes unreported.Checked: github/codeql main at b5b8165; not executed
BanditB324hashlib_insecure_functionsNo findings: B324 covers MD4, MD5, SHA and SHA-1 calls in your own code, not SHA-256, and not a hasher chosen by name in PASSWORD_HASHERS.Checked: Bandit 1.9.4, all tests; bandit -r accounts config

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkDjango 6.1.2 (django.contrib.auth.hashers)
django6.1.2
argon2-cffi (Django's Argon2PasswordHasher backend)25.1.0
argon2-cffi-bindings (bundled libargon2, abi3 wheel)26.1.0
pytest9.1.1
pytest-django4.14.0

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the password-hashing/python-djangofolder.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest

# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest

# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytest

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs password-hashing/python-django runs all three modes and checks the expected results.

Download the project ZIP, 24 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python CPython 3.14.8
  • django 6.1.2
  • argon2Cffi 25.1.0
  • argon2CffiBindings 26.1.0
  • pytest 9.1.1
  • pytest-django 4.14.0
  • semgrep 1.180.0
  • bandit 1.9.4
Matrix
fixed: 12 of 12 tests pass; vulnerable: 5 of 12 tests fail (5 exploit tests); wrong: 3 of 12 tests fail (3 exploit tests)
Test results
fixed 12/12 pass; vulnerable 5/12 fail (5 exploit tests); wrong 3/12 fail (3 exploit tests: the new hash, the legacy upgrade and the weaker-parameter rehash all end up as salted MD5), offline after install
Dependency freshness
Django 6.1.2 was published on 2026-10-06 and is taken at four days old because it is a security release (CVE-2026-77050, CVE-2026-84429, CVE-2026-87890); pytest-django 4.14.0 dates from 2026-08-10, argon2-cffi 25.1.0 from 2025-06-03 and argon2-cffi-bindings 26.1.0 from 2026-08-20. iniconfig 2.3.0 and pycparser 3.0 are pinned rather than 2.3.1 (2026-10-06) and 3.11 (2026-10-09), which are not security releases. OSV reported no known vulnerabilities for any pin. Rerun on CPython 3.14.8, the 30 September 2026 expedited security release.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Password Storage Cheat Sheet
  2. Django 6.1: Password management (Using Argon2, password upgrading)
  3. Django 6.1: make_password() and check_password()
  4. Django 6.1: Speeding up the tests (MD5PasswordHasher, for tests only)
  5. argon2-cffi: choosing parameters
  6. RFC 9106: Argon2 (parameter choice)
  7. Python: hmac.compare_digest()
  8. NIST SP 800-63B-4: Password verifiers

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.