Home/Learn/FastAPI/Password Hashing — bcrypt/Argon2 Done Right

Password Hashing — bcrypt/Argon2 Done Right

Intermediate
Auth & Security

Store only slow, salted hashes (Argon2 or bcrypt via pwdlib) — never plaintext, never MD5/SHA-256. hash() on registration, verify() on login, and rehash transparently as algorithms age.

Overview

The database WILL leak someday — password storage is designed for that day. Plaintext is instant catastrophe; fast hashes like MD5/SHA-256 are nearly as bad because GPUs guess billions per second, and unsalted hashes fall to precomputed rainbow tables in bulk. Purpose-built password hashes fix both properties: bcrypt and Argon2 are deliberately slow (a tunable cost factor) and automatically salted (same password → different hash every time). In Python the modern choice is pwdlib (passlib is unmaintained); it hashes on registration, verifies on login, and tells you when a stored hash uses outdated parameters so you can rehash during a successful login. The non-negotiables: verify in constant time, keep hashes out of responses (the response_model lesson) and out of logs, and rate-limit the login endpoint — hashing protects the stored secret, not the front door.

hash() and verify() with pwdlib

Two functions cover the whole lifecycle. Note the salt doing its job: hashing the same password twice gives different strings, and verify() still matches both.

Slow + salted, or it is not password hashing
# pip install "pwdlib[argon2,bcrypt]"

from pwdlib import PasswordHash

pwd = PasswordHash.recommended()        # Argon2id today; updated as research moves

# ── registration ──
h1 = pwd.hash("MySecret@2026")
h2 = pwd.hash("MySecret@2026")
print(h1 == h2)                         # False — unique salt baked into each hash
print(h1)                               # $argon2id$v=19$m=65536,t=3,p=4$...salt...$...

# ── login ──
print(pwd.verify("MySecret@2026", h1))  # True
print(pwd.verify("MySecret@2026", h2))  # True — salt read from the hash itself
print(pwd.verify("wrong-guess",  h1))   # False (constant-time comparison inside)

# Why NOT sha256:
#   import hashlib; hashlib.sha256(b"pass").hexdigest()
#   - designed to be FAST → GPU rigs try tens of billions of guesses/second
#   - unsalted → one rainbow table cracks every user with "password@123"
#   Argon2/bcrypt: ~50-300ms EACH by design, unique salt per hash →
#   the same leak takes centuries instead of a weekend.

# Cost tuning: aim ~100-300ms per hash on YOUR production hardware —
# slow enough to hurt attackers, fast enough for login UX.

Wired into Register/Login + Transparent Rehash

Registration stores the hash; login verifies and — when the stored hash predates current parameters — rehashes with the fresh one. Add the operational guards: rate limiting, no hashes in logs or responses.

Hash at register, verify at login, upgrade old hashes in place
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from pwdlib import PasswordHash

app = FastAPI()
pwd = PasswordHash.recommended()
USERS: dict[str, dict] = {}                 # email → {"hashed": ...}

@app.post("/register", status_code=201)
def register(form: OAuth2PasswordRequestForm = Depends()):
    if form.username in USERS:
        raise HTTPException(409, "account already exists")
    if len(form.password) < 8:              # sane minimum; prefer length over l33t rules
        raise HTTPException(422, "password must be at least 8 characters")
    USERS[form.username] = {"hashed": pwd.hash(form.password)}
    return {"registered": form.username}    # hash NEVER leaves the server

@app.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
    user = USERS.get(form.username)
    if not user or not pwd.verify(form.password, user["hashed"]):
        raise HTTPException(401, "incorrect username or password",
                            headers={"WWW-Authenticate": "Bearer"})

    # Transparent upgrade: old bcrypt hash? re-store as Argon2 now,
    # while we briefly, legitimately hold the plaintext:
    if pwd.verify_and_update(form.password, user["hashed"])[1]:
        user["hashed"] = pwd.hash(form.password)

    return {"access_token": "jwt-next-chapter", "token_type": "bearer"}

# Operational non-negotiables:
#   - rate-limit /token (the RateLimiter dependency): hashing does not
#     stop online guessing — throttling does
#   - never log form.password; scrub request logging on auth routes
#   - response_model without hashed fields (Response Models chapter)
#   - breached-password checks (haveibeenpwned k-anonymity API) are cheap wins

Key Points to Remember

  • 1Only slow + salted algorithms: Argon2id (preferred) or bcrypt — via pwdlib
  • 2Same password hashes differently every time; verify() reads the salt from the hash
  • 3verify_and_update enables transparent rehashing as parameters age
  • 4Hashing protects the stored secret; rate-limiting protects the login endpoint

Interview Questions

Sign in to ask Aria
1

Why is SHA-256 wrong for passwords when it is fine for file integrity?

MediumPhonePe
2

What does the salt prevent exactly? Walk through cracking 1M unsalted vs salted hashes.

HardGoogle
3

Your DB leaked with Argon2 hashes — what is your incident story to users and why is it survivable?

HardRazorpay

Ask Aria about Password Hashing — bcrypt/Argon2 Done Right

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…