PythonMastery
intermediate 22 min read · lesson 1 of 4 in Auth & Security

Password Hashing & Sessions

1 · The lesson

read

Examples 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.

python
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) → plaintext tables 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:

AlgorithmYearNotes
bcrypt1999Battle-tested. Still acceptable. 72-byte input cap.
scrypt2009Memory-hard. Less common in 2026 than argon2.
argon2id2015OWASP'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

bash
pip install argon2-cffi
python
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 and verify() 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. VerifyMismatchError is the wrong-password path; VerifyError covers malformed hashes. Catch both at your login boundary.

Bcrypt for comparison, in case you need it:

python
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:

python
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:

python
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.

sql
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:

python
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.

python
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 use random.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):

text
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:

python
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.


A login cookie that isn't marked properly is a credential leak waiting for the right browser extension. The flags:

python
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.cookie in 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. Use Strict for 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:

python
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:

python
@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:

text
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.

python
# 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:

python
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:

python
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
python
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:

python
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.