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
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
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.
fastapi==0.143.1 argon2-cffi==25.1.0 httpx==0.28.1 python-multipart==0.0.32 pytest==9.1.1
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}
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] 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.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI OAuth2 password flow with PyJWT tokens and scope checks
OAuth2 password login issuing PyJWT access tokens, Argon2 hashes via pwdlib, and per-route scope checks with SecurityScopes, tested offline with TestClient.
FastAPI config via pydantic-settings as a cached dependency
Load config from env vars with pydantic-settings, inject it into FastAPI routes through a cached dependency, and test it offline with monkeypatch and dependency_overrides.
FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
FastAPI endpoints rate limited with slowapi, tested with TestClient
Add per-route and per-API-key rate limits to FastAPI with slowapi, then test 429 behavior offline with TestClient and the in-memory store.
All FastAPI recipesAll argon2-cffi recipesAll python-multipart recipesAll httpx recipes