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

Fix weak password hashing in Python and Flask

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

In short

Use argon2-cffi's PasswordHasher with explicit parameters at or above OWASP's minimum, PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1), and store ph.hash(password). On login, check legacy SHA-256 rows with hmac.compare_digest and the rest with ph.verify(). After a success, rehash when the row is legacy or ph.check_needs_rehash() is true, and save it with a compare-and-swap.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework Flask 3.1.3
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks7 cards
Download the project ZIP, 20 KB
On this page

Steps

  1. Find every place that hashes or compares passwords: hashlib.sha256(), sha1() or md5() on a password, hashlib.pbkdf2_hmac with a low iteration count, werkzeug's generate_password_hash with an old method string, and == between a computed and a stored hash.
  2. Create one module-level PasswordHasher with the policy spelled out (type defaults to Argon2id) and store ph.hash(password). It generates a 16-byte salt and returns a PHC string that records the parameters.
  3. On login, recognise the old format first (here, 64 lowercase hex digits, matched with re.fullmatch) and compare with hmac.compare_digest; send everything else to ph.verify(stored, password), catching VerificationError and InvalidHashError.
  4. Only after a successful verification, rehash when the row is legacy or ph.check_needs_rehash(stored) is true, and save it inside a transaction with UPDATE users SET password_hash = ? WHERE id = ? AND password_hash = <the hash you verified>.
  5. When the username is unknown, verify against a dummy hash anyway and return the same 401 as for a wrong password, so neither the body nor the timing shows which accounts exist.
  6. Count the legacy rows that remain and decide what happens to them: expire them after a deadline and require a reset, or wrap them now as Argon2id(SHA-256(password)) behind a marker until their owners log in.

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

Vulnerableimpl/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: hashing policy

Fixedimpl/fixed.pyPython
# OWASP minimum for Argon2id: 19 MiB (19456 KiB) of memory, 2 passes, 1 lane. Raise
# memory_cost and time_cost as far as your login latency and memory budget allow: every
# hash records its own parameters, so older hashes keep verifying.
PASSWORD_HASHER = PasswordHasher(time_cost=2, memory_cost=19456, parallelism=1, hash_len=32, salt_len=16)


def hash_password(password: str) -> str:
    return PASSWORD_HASHER.hash(password)

With IMPL=fixed, 12 of 12 tests pass.

The fix: verify and upgrade

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


def verify_password(password: str, stored: str) -> Verification:
    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)
    try:
        PASSWORD_HASHER.verify(stored, password)
    except (VerificationError, InvalidHashError):
        return Verification(valid=False, needs_rehash=False)
    # True when any parameter differs from PASSWORD_HASHER's, the variant included.
    return Verification(valid=True, needs_rehash=PASSWORD_HASHER.check_needs_rehash(stored))

With IMPL=fixed, 12 of 12 tests pass.

The fix: write the upgrade at login

Fixedapp.pyPython
@app.post("/login")
def login():
    credentials = read_credentials(request.get_json(silent=True))
    if credentials is None:
        return BAD_REQUEST, 400
    username, password = credentials
    db = get_db()
    user = db.execute("SELECT id, password_hash FROM users WHERE username = ?", (username,)).fetchone()
    result = impl.verify_password(password, user["password_hash"] if user else dummy_hash)
    if user is None or not result.valid:
        return {"error": "invalid username or password"}, 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.
        with db:
            db.execute(
                "UPDATE users SET password_hash = ? WHERE id = ? AND password_hash = ?",
                (impl.hash_password(password), user["id"], user["password_hash"]),
            )
    return {"username": username}

With IMPL=fixed, 12 of 12 tests pass.

A fix that looks safe but is not

Wrong fiximpl/wrong.pyPython
def hash_password(password: str) -> str:
    salt = secrets.token_bytes(16)
    digest = hashlib.sha256(salt + password.encode("utf-8")).hexdigest()
    return f"sha256${salt.hex()}${digest}"

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

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.
    set_stored_hash("bob", 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

  • ph.verify() never returns False: it returns True or raises VerifyMismatchError for a wrong password, VerificationError for other failures and InvalidHashError for a string that is not an Argon2 hash. Catch VerificationError (VerifyMismatchError is a subclass) and InvalidHashError, or a legacy row turns into a 500.
  • check_needs_rehash() compares every parameter with the hasher's: variant, version, memory, time, parallelism, salt length and hash length. Any difference counts, so lowering the policy rehashes stronger hashes down to it, and a library default that changes would rehash everyone; spelling the parameters out avoids the second surprise.
  • argon2-cffi's default PasswordHasher() has used RFC 9106's low-memory profile (64 MiB, t=3, p=4) since 21.2.0, which is stronger than OWASP's minimum; choose by measuring login latency and memory on your servers.
  • werkzeug.security.generate_password_hash() defaults to scrypt:32768:8:1 in Werkzeug 3.1.9 (N=2^15, r=8, p=1, 32 MiB), below every OWASP scrypt minimum (N=2^15 needs p=3, or N=2^17 with p=1). Its pbkdf2 method uses 1,000,000 iterations of HMAC-SHA256, above OWASP's 600,000.
  • hmac.compare_digest() accepts str only when both are ASCII (hexdigest() output is) and raises TypeError otherwise; compare bytes when in doubt. A plain == stops at the first difference, so its timing depends on how much of the value matched.
  • Each Argon2 call allocates memory_cost KiB for its duration, so peak memory is roughly 19 MiB times the number of concurrent logins; cap worker threads or put a semaphore around hashing. ph.verify() also uses the parameters stored in the hash, so bound m and t if anything else can write the users table.
  • NIST SP 800-63B-4 recommends NFC normalization before hashing, while ASVS 5.0 (6.2.8) asks for the password to be verified exactly as received. This card hashes the UTF-8 bytes it receives; if you normalize, do it identically on every path, and remember that existing hashes were made without it.

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-flask does this for you).
  2. Look at real rows: for accounts created or used since the change, argon2.extract_parameters() on the stored hash should show type ID, memory_cost of at least 19456 and time_cost of at least 2, and the number of rows still matching ^[0-9a-f]{64}$ should fall as people log in.
  3. Bandit and the Semgrep rule above look for MD5 and SHA-1 and found nothing in this card; CodeQL's py/weak-sensitive-data-hashing is the query that targets this bug (read, not run). 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 Flask
ToolRuleWhat it found here
Semgreppython.lang.security.audit.md5-used-as-password.md5-used-as-passwordNo findings on this card: the rule's sources are MD5 constructors only. With hashlib.md5 swapped into impl/vulnerable.py it reported both functions, so the card's unsalted SHA-256 passes it unnoticed.Checked: Semgrep 1.180.0 (CE)
CodeQLpy/weak-sensitive-data-hashingRead from the source, not run: a parameter named password is a sensitive source (SensitiveDataSources.qll), and for password data the input of any plain hashing call such as hashlib.sha256() is a sink, because only password-hashing algorithms count as computationally expensive. That matches impl/vulnerable.py, impl/wrong.py and the legacy branch of impl/fixed.py; the last is expected until no legacy rows remain.Checked: github/codeql main at b5b8165; not executed
BanditB324hashlib_insecure_functionsNo findings anywhere in impl/ or app.py: B324 covers MD4, MD5, SHA and SHA-1, not SHA-256. It reported both functions of an MD5 version of impl/vulnerable.py.Checked: Bandit 1.9.4, all tests

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFlask 3.1.3
argon2-cffi25.1.0
argon2-cffi-bindings26.1.0 (bundled libargon2, abi3 wheel)
flask3.1.3
werkzeug3.1.9
sqlite3 (standard library)Python 3.14.8; SQLite 3.53.4
pytest9.1.1

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-flaskfolder.

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-flask runs all three modes and checks the expected results.

Download the project ZIP, 20 KB

Verified

Machine
Apple M5 Max, macOS 27.0
Toolchain
  • python 3.14.8
  • pip 26.2.1
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
Offline run of run-all.mjs (outbound network blocked): fixed 12/12 pass; vulnerable 5/12 fail (5 exploit); wrong 3/12 fail (3 exploit)
Hash cost
16.8 ms per Argon2id hash at m=19456, t=2, p=1 (mean of 20 after warm-up)

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. argon2-cffi: PasswordHasher API (verify, check_needs_rehash)
  3. argon2-cffi: choosing parameters
  4. RFC 9106: Argon2 (parameter choice)
  5. Python: hmac.compare_digest()
  6. Werkzeug: generate_password_hash()
  7. NIST SP 800-63B-4: Password verifiers
  8. Bandit B324: hashlib_insecure_functions

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.