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

Password Hashing & Sessions

1 · The lesson

read

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

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) is cracked 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. 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:

AlgorithmYearNotes
bcrypt1999Well tested over many years. Still acceptable. 72-byte input limit.
scrypt2009Memory-hard. Less common in 2026 than argon2.
argon2id2015OWASP'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

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() 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. VerifyMismatchError is the wrong-password path, and InvalidHashError 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

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:

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

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

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.

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

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:

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()

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

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                                     # 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 use random.choices(), because the random module 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):

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

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


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:

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

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

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

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

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

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

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