Password Hashing & Sessions
1 · The lesson
readThe examples use Flask or FastAPI. Run them locally with the framework installed, because auth flows need a running server. Expected output is shown in comments.
The most important rule in this path: never store passwords in plain text. Don't store them "encrypted" either. Anything encrypted can be decrypted, and if the key is on the same server, one breach leaks every password. The right tool is one-way hashing: slow, salted and modern. If your database is stolen tomorrow, the attacker should get only useless hashes. They should not get passwords they can try on every other service where your users reused the same password (this is called credential stuffing).
This lesson covers how to hash properly, how to manage the cookie-or-token that follows after login, and a group of smaller attacks (timing, fixation, brute-force) that look minor but can lead to account takeover.
1. Why hashlib.sha256() Is Broken For Passwords
A password hash has two jobs: it must be one-way (irreversible), and it must be slow. hashlib.sha256 does the first job but completely fails 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) is cracked 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. An attacker also only has to compute each guess once for your whole user table.
md5 and sha1 are worse: they are cryptographically broken and fast. The fix is to use an algorithm that was designed to be slow on purpose and salts every hash automatically.
The three modern choices:
| Algorithm | Year | Notes |
|---|---|---|
| bcrypt | 1999 | Well tested over many years. Still acceptable. 72-byte input limit. |
| scrypt | 2009 | Memory-hard. Less common in 2026 than argon2. |
| argon2id | 2015 | OWASP's current top recommendation. Memory-hard + time-hard. |
Use argon2id by default. Use bcrypt only if your platform doesn't have a maintained argon2 library.
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()reads everything it needs from it. - 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, andInvalidHashErrorcovers 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
The code is short and works the same way. Watch out for the 72-byte input limit. If you must use bcrypt, pre-hash long passwords with sha256 first, or use argon2 instead.
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 accept: roughly 250ms on production hardware in 2026. If it is slower, your login form feels broken. If it is faster, offline cracking becomes cheaper for attackers.
The three argon2 parameters:
from argon2 import PasswordHasher ph = PasswordHasher( time_cost=3, # iterations (higher is slower) memory_cost=64 * 1024, # KiB of memory used (more memory makes GPU cracking harder) parallelism=4, # number of threads (match your server's CPU cores) )
time_cost: number of passes over memory. The cost grows linearly with it.memory_cost: KiB used per hash. This is the memory-hard property: attackers can't easily run a million hashes in parallel on a GPU when each one needs 64MB of fast RAM.parallelism: threads per hash. Set to your server's typical free core count.
Measure it on your own hardware:
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
Increase time_cost if it's faster than 200ms, and decrease it 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. Leave plenty of room: use VARCHAR(255) or TEXT, and you won't need to change it later.
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
Recommended cost parameters increase 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() # Re-hash quietly if our cost settings changed since this hash was made 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 time you legitimately have the plaintext password in memory, so it is the only place where you can rehash.
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 time, and emails the user a link containing the plain token.
3. User clicks the link. Server hashes the supplied token, looks it up, checks the expiry, and lets them set a new password.
4. Token is single-use: invalidate it 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 # the plain token 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(), because therandommodule is for simulations, not security.- Hash the token at rest. If your DB leaks, an attacker shouldn't be able to use pending reset tokens to get into accounts. SHA-256 is fine here: the token already has 256 bits of entropy, so brute force is not a risk.
- Short expiry. 15 minutes to an hour. Longer windows give more time for stolen-link attacks (browser history, shared computers).
- Single use. Delete or mark-used after consumption. Don't allow replays.
7. Sessions: How Does the Server Remember the Login?
After the user authenticates, the server needs to remember who they are on subsequent requests. There are three common designs:
Server-side session: a random ID in a signed cookie, while the real data is stored 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: easy to revoke (delete the row), no size limit, and you never trust the client with session data. Cons: needs a session store, extra read per request.
Client-side session: the data is stored in the signed cookie. This is Flask's default behaviour:
from flask import Flask, session app = Flask(__name__) app.secret_key = os.environ["SECRET_KEY"] # required, and must be long and 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: limited to ~4KB, the entire session is sent on every request, revocation needs a denylist or a "session version" column. Never put sensitive data in it: 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 without the right flags can easily be stolen, for example by a malicious script on the page (XSS) or over plain HTTP. The flags:
response.set_cookie( "session_id", value=token, httponly=True, # JavaScript in the page cannot read it (limits XSS theft) secure=True, # only sent over HTTPS, never over plain HTTP samesite="Lax", # not sent on most cross-site requests (helps protect against CSRF) max_age=60 * 60 * 24, # 1 day, use less for more sensitive apps )
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 manages an XSS attack, they still can't read the session cookie. 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-site POSTs. This gives CSRF protection for the common case with no extra code. UseStrictfor high-sensitivity sites. The cost: a user who clicks a link to your site in an email arrives logged out.
In Flask, set these in the app config:
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 is 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 the same: after login, the session identifier must be one the attacker has never seen.
10. "Remember Me": A Separate Long-Lived Token
It is tempting to set max_age=30 days on the main session cookie. Don't. With a 30-day session cookie, anyone who steals the laptop has full account access for 30 days, and is never asked to log in again. 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 the re-use. Then log out every session on that account and force a new login.
11. Brute-Force Defence: Rate Limits and Lockouts
A login endpoint with no rate limit is easy to attack with a dictionary attack. Two layers:
Rate limit per IP and per username.
# Flask + flask-limiter from flask_limiter import Limiter limiter = Limiter(lambda: request.remote_addr, app=app) @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 per minute per IP is a loose limit, so make it stricter in production. Also limit by username, so an attacker can't use many IPs to brute-force one account.
Account lockout, with care.
After N failures, lock the account for M minutes. The UX trade-off: an attacker who knows your usernames can lock out every user (a denial of service) 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 goal is to slow the attacker down enough that they give up, without letting them use the lockout itself for a DoS attack.
12. Common Mistakes
1. hashlib.sha256() for passwords. Unsafe. 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" counts as your own crypto. Use the library defaults. Many clever variations have been broken before, and the defaults avoid those mistakes.
3. random.choices() for tokens. random uses the Mersenne Twister, which becomes fully predictable once an attacker has seen 624 of its 32-bit outputs. Use secrets.token_urlsafe() or secrets.token_hex() for anything related to security.
4. Forgetting cookie flags. A session cookie without HttpOnly + Secure + SameSite can be stolen with one XSS or one plain-HTTP request. Set all three.
5. Emailing the password back. "Here is your password: hunter2" in a reset email means you stored the plaintext, and that is the security 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, use compare_digest.
7. Logging passwords. A stray logger.info(f"login attempt: {form_data}") sends the plaintext password to every log system you use. Log usernames, never passwords. See envconfig for secret-masking helpers.
8. Same error on "user not found" vs "wrong password". This is 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, InvalidHashError class PasswordService: def __init__(self): self._ph = PasswordHasher() # default cost is sensible for 2026 def hash_password(self, plain: str) -> str: # TODO 1: return the 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: return a 256-bit URL-safe token and its sha256 hex ...
Hint 1: verify should not leak exceptions
ph.verify() raises VerifyMismatchError on wrong password and InvalidHashError on malformed input. Wrap in try/except, and 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, InvalidHashError 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, InvalidHashError): 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 handles passwords. Application code calls
svc.verify(...), and it never touches the underlying library or its exception types. - Boolean-returning
verify: easier to use in login flows. The question "did the password match?" gets a yes/no answer instead of a possible exception. needs_rehash: the upgrade-on-login pattern becomes one line at the call site.- Reset tokens with the right entropy:
secrets.token_urlsafe(32)for the plain value, and its SHA-256 for storage. The plain value is never stored in your DB. - Constant-time token comparison via
hmac.compare_digest, which prevents the timing leak.
The full login pipeline now reads:
user = users.find_by_email(email) stored = user.password if user else DUMMY_HASH # DUMMY_HASH: one svc.hash_password() made at startup if not svc.verify(stored, form.password) or user is None: # unknown emails cost the same time 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() # prevents session fixation session["user_id"] = user.id
setup added so this can run · defines email, DUMMY_HASH, 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') DUMMY_HASH = _AutoMock('DUMMY_HASH') 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 defends against a type of attack: enumeration, weak hashing, outdated cost parameters, fixation. Together they form the standard production pattern.
What You Learned
- Never store passwords as plaintext or "encrypted". Hash with argon2id (or bcrypt as fallback). The cost factor is what makes it safe, so 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 increase 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, and 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, so 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 alternative to session cookies, the algorithm-confusion vulnerability, and when JWT is the wrong tool.
Found this useful? Share it with a friend who’s learning Python.