Password Hashing & Sessions
1 · The lesson
readExamples shown in Flask/FastAPI shape. Run locally with the framework installed; auth flows need a running server. Expected output shown in comments.
The single rule that matters more than everything else in this path: never store passwords in plain text. Never. Not "encrypted" — encrypted means decryptable, and a key sitting on the same server is a key one breach away from leaking every credential. The right primitive is one-way hashing — slow, salted, modern. If your DB is dumped tomorrow, the attacker should walk away with garbage instead of a credential stuffing campaign against every other service your users signed up for with the same password.
This lesson covers how to hash properly, how to manage the cookie-or-token that follows after login, and the cluster of small attacks (timing, fixation, brute-force) that look like nothing until they're everything.
1. Why hashlib.sha256() Is Broken For Passwords
A password hash has two jobs: be one-way (irreversible) and be slow. hashlib.sha256 does the first; it fails catastrophically at the second.
import hashlib # DO NOT DO THIS hashed = hashlib.sha256(b"hunter2").hexdigest() # 'f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7'
Why this is broken:
- Too fast. A modern GPU computes ~10 billion SHA-256 hashes per second. Every common password (
password123,letmein, your dog's name) falls in milliseconds. - Rainbow tables. Pre-computed
sha256(plaintext) → plaintexttables for billions of common passwords already exist on disk. Lookup is constant-time. - No salt by default. Two users with the same password get the same hash, so cracking one cracks both — and an attacker only has to compute each candidate once across your whole user table.
md5 and sha1 are worse — broken cryptographically and fast. The fix is to use an algorithm that was deliberately designed to be slow and salts every hash automatically.
The three modern choices:
| Algorithm | Year | Notes |
|---|---|---|
| bcrypt | 1999 | Battle-tested. Still acceptable. 72-byte input cap. |
| scrypt | 2009 | Memory-hard. Less common in 2026 than argon2. |
| argon2id | 2015 | OWASP's current top recommendation. Memory-hard + time-hard. |
Default to argon2id. Reach for bcrypt only if your platform doesn't have a maintained argon2 binding.
2. Argon2 with argon2-cffi
pip install argon2-cffi
from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError ph = PasswordHasher() # sensible defaults hashed = ph.hash("hunter2") # '$argon2id$v=19$m=65536,t=3,p=4$<salt>$<digest>' try: ph.verify(hashed, "hunter2") # returns True on match print("ok") except VerifyMismatchError: print("wrong password")
Three things to notice:
- The hash is self-describing. It carries the algorithm (
argon2id), version, cost parameters (m,t,p), the salt, and the digest in one string. You store that one string in your DB andverify()figures everything out. - Salting is automatic. Every call to
hash()produces a different output for the same password. You never touch the salt yourself, and you never reuse one. - Verify raises, doesn't return False.
VerifyMismatchErroris the wrong-password path;VerifyErrorcovers malformed hashes. Catch both at your login boundary.
Bcrypt for comparison, in case you need it:
import bcrypt hashed = bcrypt.hashpw(b"hunter2", bcrypt.gensalt(rounds=12)) bcrypt.checkpw(b"hunter2", hashed) # True
Five lines, similar shape. The 72-byte input cap is the gotcha — pre-hash long passwords with sha256 first if you must, or just use argon2.
3. Cost Tuning — Target ~250ms per Hash
The whole point of a slow hash is that it's slow. The right speed is as slow as your login endpoint can tolerate — roughly 250ms on production hardware in 2026. Slower and your login form feels broken. Faster and you're handing attackers a cheaper offline crack.
The three argon2 parameters:
from argon2 import PasswordHasher ph = PasswordHasher( time_cost=3, # iterations — higher = slower memory_cost=64 * 1024, # KiB of memory used — higher = harder to parallelise on GPUs parallelism=4, # number of threads — match to your server cores )
time_cost— number of passes over memory. Linear cost scaling.memory_cost— KiB consumed per hash. The memory-hard property — attackers can't trivially run a million parallel hashes on a GPU when each one needs 64MB of fast RAM.parallelism— threads per hash. Set to your server's typical free core count.
Calibrate empirically:
import time from argon2 import PasswordHasher ph = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=4) start = time.perf_counter() ph.hash("test-password") print(f"{(time.perf_counter() - start) * 1000:.0f}ms") # ~250ms on a modern server CPU
Bump time_cost if it's faster than 200ms, back off if it's slower than 400ms. Re-run the calibration any time you change hardware.
4. Storage — Columns and Lengths
The argon2 encoded string is up to ~101 characters for default parameters. Bcrypt is exactly 60. Provision generously — use VARCHAR(255) or TEXT and don't think about it again.
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email CITEXT UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL, -- argon2/bcrypt hash, never the password
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);Never store: the password, a copy of the password "in case the user forgets", an encryption key alongside an encrypted password, or the salt as a separate column (the hash format already contains it).
5. Rehashing on Login
Cost parameters drift upward over the years. The user who registered in 2020 has a hash with weaker parameters than the user who registers today. argon2-cffi has a one-call check:
from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError ph = PasswordHasher() # current parameters def login(email, plain_password): user = users.find_by_email(email) if user is None: raise InvalidCredentials() try: ph.verify(user.password, plain_password) except VerifyMismatchError: raise InvalidCredentials() # Upgrade the hash transparently if our cost factor moved on if ph.check_needs_rehash(user.password): user.password = ph.hash(plain_password) users.save(user) return user
setup added so this can run · defines users, InvalidCredentials
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) users = _AutoMock('users') def InvalidCredentials(*_a, **_kw): print('-> InvalidCredentials() called') return _AutoMock('InvalidCredentials()')
Login is the only moment you have the plaintext password legitimately in memory. Rehash there or never.
6. Password Reset — Tokens Done Right
The flow:
1. User clicks "forgot password" and gives their email.
2. Server generates a cryptographically random token, stores its hash with an expiry, emails the user a link containing the plain token.
3. User clicks the link. Server hashes the supplied token, looks it up, checks expiry, lets them set a new password.
4. Token is single-use — invalidate after use.
import secrets import hashlib from datetime import datetime, timedelta, timezone def generate_reset_token(): """Returns (plain_for_email, hash_for_db).""" plain = secrets.token_urlsafe(32) # 256 bits, URL-safe stored = hashlib.sha256(plain.encode()).hexdigest() return plain, stored def create_reset_request(user_id): plain, stored = generate_reset_token() expires = datetime.now(timezone.utc) + timedelta(hours=1) reset_tokens.insert(user_id=user_id, token_hash=stored, expires_at=expires) return plain # ← this goes in the email link def verify_reset_token(plain): stored = hashlib.sha256(plain.encode()).hexdigest() row = reset_tokens.find_by_hash(stored) if row is None or row.expires_at < datetime.now(timezone.utc): return None return row.user_id
setup added so this can run · defines reset_tokens
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) reset_tokens = _AutoMock('reset_tokens')
Key points:
secrets.token_urlsafe(32)— cryptographically random, URL-safe. Never userandom.choices()— that module is for simulations, not security.- Hash the token at rest. If your DB leaks, an attacker shouldn't be able to take pending reset tokens and walk into accounts. SHA-256 is fine here — the token has 256 bits of entropy already, so brute force isn't the threat.
- Short expiry. 15 minutes to an hour. Longer windows mean stolen-link attacks (browser history, shared computers).
- Single use. Delete or mark-used after consumption. Don't allow replays.
7. Sessions — Where Does The Login Stick?
After the user authenticates, the server needs to remember who they are on subsequent requests. Three common shapes:
Server-side session — random ID in a signed cookie, real data lives in your backend (Redis, the DB):
Cookie: session_id=abc123... ← just the ID, signed so it can't be forged
Server-side store: abc123 → {user_id: 42, roles: ["user"], ...}Pros: trivially revocable (delete the row), no size limit, never trust the client. Cons: needs a session store, extra read per request.
Client-side session — data lives in the signed cookie. Flask's default behaviour:
from flask import Flask, session app = Flask(__name__) app.secret_key = os.environ["SECRET_KEY"] # required, must be long & random @app.post("/login") def login(): # ...verify password... session["user_id"] = user.id # serialised into the cookie return "ok"
setup added so this can run · defines os, user
import os # noqa: F401 os.environ.setdefault("SECRET_KEY", "example-secret-key") # Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) os = _AutoMock('os') user = _AutoMock('user')
Pros: stateless, fast, no server store. Cons: capped at ~4KB, the entire session ships on every request, revocation needs a denylist or a "session version" column. Never put sensitive data in there — the cookie is signed (tamper-proof) but not encrypted (anyone with the cookie can decode the contents).
JWT-as-session — see the next lesson auth-jwt. Same trade-offs as client-side sessions, plus standardised claims and library support.
8. Cookie Security Flags — Set All Three
A login cookie that isn't marked properly is a credential leak waiting for the right browser extension. The flags:
response.set_cookie( "session_id", value=token, httponly=True, # JS in the browser cannot read it (mitigates XSS exfiltration) secure=True, # only sent over HTTPS — never leaked over plaintext HTTP samesite="Lax", # not sent on most cross-site requests (mitigates CSRF) max_age=60 * 60 * 24, # 1 day; tune to your sensitivity )
setup added so this can run · defines response, token
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) response = _AutoMock('response') token = _AutoMock('token')
HttpOnly—document.cookiein the browser can't see it. If an attacker pulls off an XSS, they still can't read the session cookie out. Always on for auth.Secure— HTTPS-only. Without this, a downgrade attack on a public WiFi can capture the cookie. Always on in production.SameSite=Lax— sent on top-level navigations but not on cross-origin POSTs. Free CSRF protection for the common case. UseStrictfor high-sensitivity sites at the cost of "click a link in an email and end up logged out".
Flask sets sensible defaults if you tell it to:
app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SECURE=True, SESSION_COOKIE_SAMESITE="Lax", )
setup added so this can run · defines app
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) app = _AutoMock('app')
9. Session Fixation — Regenerate on Login
The attack: an attacker plants a known session ID in the victim's browser before login (via a malicious link, an XSS, an old cookie still on a shared machine). The victim logs in. The server now associates that attacker-known ID with the victim's account. The attacker uses the same ID and is logged in as the victim.
The defence — one line at login time:
@app.post("/login") def login(): user = authenticate(...) session.clear() # drop the pre-login session session["user_id"] = user.id # Flask issues a fresh signed cookie return redirect("/dashboard")
setup added so this can run · defines authenticate, session, redirect, app
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) def authenticate(*_a, **_kw): print('-> authenticate() called') return _AutoMock('authenticate()') session = _AutoMock('session') def redirect(*_a, **_kw): print('-> redirect() called') return _AutoMock('redirect()') app = _AutoMock('app')
For server-side stores: delete the old session row, generate a new ID, write the new row. The principle is identical — post-login, the session identifier must be one the attacker has never seen.
10. "Remember Me" — A Separate Long-Lived Token
The temptation is to set max_age=30 days on the main session cookie. Don't — a 30-day session cookie is 30 days of "stolen laptop → full account access" with no re-auth checkpoint. Instead, issue a separate long-lived remember-me token:
session cookie: short-lived (hours), HttpOnly + Secure + SameSite remember cookie: long-lived (weeks), single-use, rotates on each use
On every page load, if there's no valid session but there is a valid remember cookie, rotate it (issue a new token, invalidate the old one) and start a fresh session. If an attacker steals an old remember cookie and uses it after the legitimate user has already rotated it, the rotation detects re-use — log everyone out and force re-auth on that account.
11. Brute-Force Defence — Rate Limits and Lockouts
A POST-only login endpoint with no throttle is a dictionary attack waiting to happen. Two layers:
Rate limit per IP and per username.
# Flask + flask-limiter from flask_limiter import Limiter limiter = Limiter(app, key_func=lambda: request.remote_addr) @app.post("/login") @limiter.limit("5 per minute") def login(): ...
setup added so this can run · defines app, request
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) app = _AutoMock('app') request = _AutoMock('request')
5 attempts/minute/IP is loose; tighten in production. Also limit by username so an attacker can't fan out across IPs to brute one account.
Account lockout — with care.
After N failures, lock the account for M minutes. The UX trade-off: a malicious attacker who knows your usernames can deny-of-service every user by deliberately failing logins for them. Mitigations: lock for short windows (5-15 min), require captcha after a few failures rather than a hard lock, alert the user via email when a lockout is triggered.
The defensive mindset: slow the attacker down enough that they go elsewhere, without turning the lockout itself into a DoS vector.
12. Common Mistakes
1. hashlib.sha256() for passwords. Broken. Fast hashes are for file integrity, not credentials. Use argon2 or bcrypt.
2. Writing your own crypto. Even "I'll just add a pepper and concatenate" is your-own-crypto. Use the library defaults; they exist because every clever variation has already been broken.
3. random.choices() for tokens. random is a Mersenne Twister — predictable from a few outputs. Use secrets.token_urlsafe() or secrets.token_hex() for anything security-adjacent.
4. Forgetting cookie flags. A session cookie without HttpOnly + Secure + SameSite is one XSS or one HTTP request away from theft. Set all three.
5. Emailing the password back. "Here is your password: hunter2" in a reset email means you stored the plaintext — congratulations, you are the bug. Send a reset link, never the password.
6. == to compare hashes or tokens. String equality in Python short-circuits on the first mismatched byte, leaking timing information. Use hmac.compare_digest:
import hmac if hmac.compare_digest(stored_hash, supplied_hash): # constant-time ...
setup added so this can run · defines stored_hash, supplied_hash
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) stored_hash = _AutoMock('stored_hash') supplied_hash = _AutoMock('supplied_hash')
For password verification, ph.verify() already does this. For your own token comparison, reach for compare_digest.
7. Logging passwords. A stray logger.info(f"login attempt: {form_data}") ships the plaintext password to every log aggregator you own. Log usernames, never passwords. See envconfig for secret-masking helpers.
8. Same error on "user not found" vs "wrong password". Important — see security-checklist Section 2. Return one generic "invalid credentials" message regardless.
🎯 Your Turn — Build a PasswordService
Build a small service class that encapsulates the password-handling responsibilities:
1. hash_password(plain) -> str — argon2 hash.
2. verify(stored_hash, plain) -> bool — True/False, no exceptions to the caller.
3. needs_rehash(stored_hash) -> bool — for transparent upgrades.
4. generate_reset_token() -> tuple[str, str] — returns (plain_for_email, hash_for_db).
Use argon2-cffi for hashing and secrets + hashlib for the reset token.
Skeleton:
import hashlib import secrets from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError, VerifyError class PasswordService: def __init__(self): self._ph = PasswordHasher() # default cost is sensible for 2026 def hash_password(self, plain: str) -> str: # TODO 1: argon2 hash ... def verify(self, stored_hash: str, plain: str) -> bool: # TODO 2: True on match, False on mismatch or malformed hash ... def needs_rehash(self, stored_hash: str) -> bool: # TODO 3: delegate to the hasher ... def generate_reset_token(self) -> tuple[str, str]: # TODO 4: 256-bit URL-safe plain, sha256-hex stored ...
Hint 1 — verify should not leak exceptions
ph.verify() raises VerifyMismatchError on wrong password and VerifyError on malformed input. Wrap in try/except; return False in both cases. Callers want a boolean, not a custom error hierarchy from the underlying library.
Hint 2 — Reset token symmetry
plain = secrets.token_urlsafe(32) gives a 256-bit URL-safe string. The DB-stored value is hashlib.sha256(plain.encode()).hexdigest(). On verification, hash the user-supplied token the same way and compare with hmac.compare_digest.
Show full solution
import hashlib import hmac import secrets from argon2 import PasswordHasher from argon2.exceptions import VerifyMismatchError, VerifyError class PasswordService: """Hashing, verification, rehashing, and reset-token generation.""" def __init__(self, time_cost: int = 3, memory_cost: int = 64 * 1024, parallelism: int = 4): self._ph = PasswordHasher( time_cost=time_cost, memory_cost=memory_cost, parallelism=parallelism, ) def hash_password(self, plain: str) -> str: if not plain: raise ValueError("password must not be empty") return self._ph.hash(plain) def verify(self, stored_hash: str, plain: str) -> bool: try: return self._ph.verify(stored_hash, plain) except (VerifyMismatchError, VerifyError): return False def needs_rehash(self, stored_hash: str) -> bool: return self._ph.check_needs_rehash(stored_hash) def generate_reset_token(self) -> tuple[str, str]: plain = secrets.token_urlsafe(32) # 256 bits, URL-safe stored = hashlib.sha256(plain.encode()).hexdigest() return plain, stored @staticmethod def verify_reset_token(supplied_plain: str, stored_hex: str) -> bool: supplied_hex = hashlib.sha256(supplied_plain.encode()).hexdigest() return hmac.compare_digest(supplied_hex, stored_hex) # Demo svc = PasswordService() stored = svc.hash_password("hunter2") print(stored[:30] + "...") # $argon2id$v=19$m=65536,t=3,p=... print(svc.verify(stored, "hunter2")) # True print(svc.verify(stored, "wrong")) # False print(svc.needs_rehash(stored)) # False (just hashed with current params) plain_link, db_hash = svc.generate_reset_token() print(f"email link contains: ?token={plain_link[:12]}...") print(f"db stores: {db_hash[:12]}...") print(PasswordService.verify_reset_token(plain_link, db_hash)) # True print(PasswordService.verify_reset_token("tampered", db_hash)) # False
What you built:
- One place that owns password handling. Application code calls
svc.verify(...); it never touches the underlying library or its exception types. - Boolean-returning
verify— easier to compose into login flows. The "did the password match?" question gets a yes/no answer, not a maybe-exception. needs_rehash— drop the upgrade-on-login pattern in one line at the call site.- Reset tokens with the right entropy —
secrets.token_urlsafe(32)for the plain value, SHA-256 of it for storage. The plain value never sits in your DB. - Constant-time token comparison via
hmac.compare_digest, closing the timing-leak window.
The full login pipeline now reads:
user = users.find_by_email(email) if user is None or not svc.verify(user.password, form.password): raise InvalidCredentials("invalid credentials") # same message for both if svc.needs_rehash(user.password): user.password = svc.hash_password(form.password) users.save(user) session.clear() # mitigate fixation session["user_id"] = user.id
setup added so this can run · defines email, session, users, InvalidCredentials, svc, form
# Lightweight mock for objects whose attributes/methods aren't critical class _AutoMock: def __init__(self, name='mock'): self._name = name def __getattr__(self, k): return _AutoMock(self._name + '.' + k) def __call__(self, *a, **kw): print('-> ' + self._name + '() called') return _AutoMock(self._name + '()') def __repr__(self): return '<mock ' + self._name + '>' def __str__(self): return '<mock ' + self._name + '>' def __bool__(self): return True def __iter__(self): return iter([]) def __len__(self): return 0 def __getitem__(self, k): return _AutoMock(self._name + '[...]') def __setitem__(self, k, v): pass def __enter__(self): return self def __exit__(self, *a): return False async def __aenter__(self): return self async def __aexit__(self, *a): return False def __add__(self, o): return self def __radd__(self, o): return self def __sub__(self, o): return self def __mul__(self, o): return self def __rmul__(self, o): return self def __truediv__(self, o): return self def __eq__(self, o): return isinstance(o, _AutoMock) def __hash__(self): return hash(self._name) def __lt__(self, o): return True def __le__(self, o): return True def __gt__(self, o): return False def __ge__(self, o): return False def __mro_entries__(self, bases): return (object,) email = _AutoMock('email') session = _AutoMock('session') users = _AutoMock('users') def InvalidCredentials(*_a, **_kw): print('-> InvalidCredentials() called') return _AutoMock('InvalidCredentials()') svc = _AutoMock('svc') form = _AutoMock('form')
Every line above is here because of an attack class — enumeration, weak hashing, cost drift, fixation. The composition is the production pattern.
What You Learned
- Never store passwords plaintext or "encrypted". Hash with argon2id (or bcrypt as fallback). The cost factor is what makes it safe; tune to ~250ms.
PasswordHasher.hash()salts automatically. Store the full encoded hash, length up to ~101 chars.check_needs_rehash+ rehash on login — your cost factor can move forward without forced password resets.- Reset tokens:
secrets.token_urlsafe(32)for the plain value, store its SHA-256 hex, expire in an hour, single-use. - Sessions: server-side (Redis/DB) for revocable; client-side signed cookies for stateless; JWT-as-session covered in auth-jwt.
- Cookie flags:
HttpOnly,Secure,SameSite=Lax|Strict— set all three on every auth cookie. - Regenerate session ID on login to defeat fixation. Use a separate token for "remember me" and rotate on use.
- Rate-limit login per IP and per username. Lockouts have a DoS trade-off; prefer captcha + short locks.
- Compare hashes and tokens with
hmac.compare_digest, never==. Never log passwords. Never email passwords back.
Next: JWT & Bearer Tokens — the stateless cousin of session cookies, the algorithm-confusion vulnerability nobody saw coming, and when JWT is the wrong tool.