ProvenPin Install

FastAPI login with argon2-cffi hashing and rehash checks: working versions (tested 2026-10-15)

A form-based FastAPI login that verifies argon2id hashes without leaking which users exist, and transparently upgrades outdated hashes on successful login, tested offline with TestClient and an in-memory store.

passed6 passed · 0 failed2026-10-15 13:29 UTC
Status
passed
Last passed
2026-10-15 13:29 UTC
Tests
6 / 6
Language
Python
Runtime
Python 3.13.16
pinned versions5
fastapi@0.143.1argon2-cffi@25.1.0httpx@0.28.1python-multipart@0.0.32pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 38742087302

The code

Exactly the files that passed. Your agent can pull them with get_recipe("fastapi-argon2-login") (install), or see them on GitHub.

requirements.txt
fastapi==0.143.1
argon2-cffi==25.1.0
httpx==0.28.1
python-multipart==0.0.32
pytest==9.1.1
app.py
from typing import Annotated

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import FastAPI, Form, HTTPException, status

# argon2id with library defaults. The cost parameters are stored inside every hash,
# so raising them later makes old hashes "need rehash" instead of breaking them.
ph = PasswordHasher()

# Verified against when the user does not exist, so both paths cost one argon2 run.
DUMMY_HASH = ph.hash("dummy-password-never-matches")

users: dict[str, str] = {}  # username -> argon2 hash (stand-in for a DB table)

app = FastAPI()


@app.post("/login")
def login(username: Annotated[str, Form()], password: Annotated[str, Form()]):
    # Plain `def`: FastAPI runs it in a threadpool, since argon2 is deliberately slow CPU work.
    hashed = users.get(username)
    try:
        ph.verify(hashed or DUMMY_HASH, password)
    except VerifyMismatchError:
        hashed = None
    if hashed is None:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
    if ph.check_needs_rehash(hashed):
        # Only rehash after a successful login, when we know the plaintext is correct.
        users[username] = ph.hash(password)
    return {"username": username}
tests/test_login.py
from argon2 import PasswordHasher
from fastapi.testclient import TestClient

from app import DUMMY_HASH, app, ph, users

# Deliberately weak parameters to simulate a hash created under an older policy.
WEAK = PasswordHasher(time_cost=1, memory_cost=8, parallelism=1)


def login(client, username, password):
    return client.post("/login", data={"username": username, "password": password})


def setup_function():
    users.clear()


def test_login_ok_and_stores_hash_not_password():
    users["ann"] = ph.hash("s3cret-pass")
    r = login(TestClient(app), "ann", "s3cret-pass")
    assert r.status_code == 200 and r.json() == {"username": "ann"}
    assert users["ann"].startswith("$argon2id$")
    assert users["ann"] != "s3cret-pass"


def test_wrong_password_and_unknown_user_look_identical():
    users["ann"] = ph.hash("right")
    client = TestClient(app)
    wrong = login(client, "ann", "wrong")
    ghost = login(client, "nobody", "wrong")
    assert wrong.status_code == ghost.status_code == 401
    assert wrong.json() == ghost.json()


def test_unknown_user_cannot_match_dummy_password():
    # The dummy hash is only a timing shield; its plaintext must never grant access.
    r = login(TestClient(app), "nobody", "dummy-password-never-matches")
    assert r.status_code == 401
    assert ph.verify(DUMMY_HASH, "dummy-password-never-matches")


def test_failed_login_does_not_rehash():
    old = WEAK.hash("right")
    users["ann"] = old
    r = login(TestClient(app), "ann", "wrong")
    assert r.status_code == 401
    assert users["ann"] == old


def test_weak_hash_is_upgraded_on_successful_login():
    users["ann"] = WEAK.hash("hunter2-long")
    r = login(TestClient(app), "ann", "hunter2-long")
    assert r.status_code == 200
    assert not ph.check_needs_rehash(users["ann"])
    assert ph.verify(users["ann"], "hunter2-long")


def test_json_body_is_rejected_because_login_takes_form_data():
    users["ann"] = ph.hash("pw")
    r = TestClient(app).post("/login", json={"username": "ann", "password": "pw"})
    assert r.status_code == 422
pytest.ini
[pytest]
pythonpath = .

Connect to ProvenPin

Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.