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.
- 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.
- 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.
- 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.
- 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>.
- 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.
- 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.
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
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
# 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
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
@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
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 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.
# 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"- 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.
- 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).
- 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.
- 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.
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.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.lang.security.audit.md5-used-as-password.md5-used-as-password | No 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) |
| CodeQL | py/weak-sensitive-data-hashing | Read 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 |
| Bandit | B324hashlib_insecure_functions | No 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 |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | Flask 3.1.3 |
| argon2-cffi | 25.1.0 |
| argon2-cffi-bindings | 26.1.0 (bundled libargon2, abi3 wheel) |
| flask | 3.1.3 |
| werkzeug | 3.1.9 |
| sqlite3 (standard library) | Python 3.14.8; SQLite 3.53.4 |
| pytest | 9.1.1 |
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.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# 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 pytestEach 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.
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
- OWASP Password Storage Cheat Sheet
- argon2-cffi: PasswordHasher API (verify, check_needs_rehash)
- argon2-cffi: choosing parameters
- RFC 9106: Argon2 (parameter choice)
- Python: hmac.compare_digest()
- Werkzeug: generate_password_hash()
- NIST SP 800-63B-4: Password verifiers
- Bandit B324: hashlib_insecure_functions
Attack patterns: