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

Fix weak password hashing in Python and FastAPI

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) from a plain def path operation. On login, check legacy SHA-256 rows with hmac.compare_digest and the rest with ph.verify(); after a success, rehash legacy rows and those check_needs_rehash() flags.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks7 cards
Download the project ZIP, 21 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, passlib or pwdlib configured with a weak scheme, 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. Declare the request body as a Pydantic model with Field(min_length=1, max_length=256) for the password, so FastAPI refuses empty and absurdly long passwords with 422 before any hashing happens.
  4. Hash and verify in plain def path operations (FastAPI runs them in a thread pool) or with await anyio.to_thread.run_sync(...) from async code, never directly inside async def.
  5. On login, recognise the old format first and compare with hmac.compare_digest; send everything else to ph.verify(stored, password), catching VerificationError and InvalidHashError. Answer an unknown username with the same 401 after verifying against a dummy hash.
  6. Only after a successful verification, rehash when the row is legacy or ph.check_needs_rehash(stored) is true, and save it with UPDATE ... WHERE id = ? AND password_hash = <the hash you verified>.

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: Credentials, db: DbDep) -> dict:
    user = db.execute(
        "SELECT id, password_hash FROM users WHERE username = ?", (credentials.username,)
    ).fetchone()
    result = impl.verify_password(credentials.password, user["password_hash"] if user else dummy_hash)
    if user is None or not result.valid:
        raise HTTPException(status_code=401, detail="invalid username or password")

    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(credentials.password), user["id"], user["password_hash"]),
            )
    return {"username": credentials.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

  • An Argon2 hash at these settings took about 20 ms on the test machine. Inside async def it blocks the event loop and every other request waits; FastAPI runs plain def path operations in AnyIO's worker threads instead.
  • AnyIO's default worker-thread limit is 40, so up to 40 hashes can run at once, each allocating memory_cost KiB (about 19 MiB here): budget for the peak, or put a semaphore around hashing.
  • 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.
  • 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.
  • FastAPI's own security tutorial hashes passwords with pwdlib's Argon2 recommendation; whichever library you use, check the parameters it applies against the OWASP minimums and keep a rehash-on-login path for the old ones.

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-fastapi 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 Semgrep 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 FastAPI
ToolRuleWhat it found here
Semgreppython.lang.security.audit.md5-used-as-password.md5-used-as-passwordNo findings: the rule's sources are MD5 constructors only, so the card's unsalted and salted SHA-256 pass it unnoticed. The packs reported nothing about hashing (p/default's one finding is the deliberate importlib.import_module in app.py).Checked: Semgrep 1.180.0 (CE); on app.py and impl
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: B324 covers MD4, MD5, SHA and SHA-1, not SHA-256.Checked: Bandit 1.9.4, bandit -r impl app.py

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
argon2-cffi25.1.0
argon2-cffi-bindings (bundled libargon2, abi3 wheel)26.1.0
fastapi0.142.2
sqlite3 (standard library)Python 3.14.8; SQLite 3.53.4
httpx2 (TestClient transport, test only)2.13.1
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-fastapifolder.

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

Download the project ZIP, 21 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • python CPython 3.14.8
  • fastapi 0.142.2
  • argon2Cffi 25.1.0
  • argon2CffiBindings 26.1.0
  • pytest 9.1.1
  • 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), offline after install
Dependency freshness
argon2-cffi 25.1.0 (2025-06-03) and argon2-cffi-bindings 26.1.0 (2026-08-20). FastAPI 0.142.2 (2026-09-30) and Pydantic 2.13.5 rather than 0.143.0 and 2.14.0 (2026-10-08), which fix no security issue; iniconfig 2.3.0 and pycparser 3.0 because their newer releases are under 7 days old. OSV reported no known vulnerabilities for any of the 24 pinned packages.
Hash cost
20.6 ms per Argon2id hash at m=19456, t=2, p=1 (mean of 20 after warm-up)
Notes
impl/*.py is the Flask card's hashing code unchanged; what is FastAPI-specific is app.py: the Pydantic body model, plain def path operations and HTTPException for 401 and 409. Oversized passwords get FastAPI's 422 rather than the Flask card's 400.

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. FastAPI: concurrency and async / await (def path operations run in a thread pool)
  6. AnyIO: working with threads (the default limit of 40 worker threads)
  7. FastAPI tutorial: OAuth2 with password hashing (pwdlib)
  8. Python: hmac.compare_digest()
  9. 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.