Module 4 · Authentication and security

Lesson 20 — Password hashing and MFA

bcrypt/argon2, MFA and account recovery without opening the door to attackers.

Published
In this lesson
  1. Objectives
  2. 1. Why not a plain "hash"
  3. 2. The password in the flow (beyond the hash)
  4. 3. TOTP: the second factor that actually works
  5. 4. Account recovery: the door that must not be a back door
  6. 5. MFA and the module's future
  7. 5. Biometrics and hardware: MFA beyond TOTP
  8. 6. Account recovery: the door that opens and closes

Stack: Django (argon2) + pyotp · Project: TicketFlow Status: Published Prerequisite: Lesson 19 — OAuth2


Objectives

  1. Store passwords with modern KDFs (argon2/bcrypt) understanding salt, iterations and memory cost.
  2. Add TOTP (Google Authenticator) with QR, verification and single-use recovery codes.
  3. Design account recovery without turning it into the system's back door.

1. Why not a plain "hash"

SHA-256 of a password is useless: GPUs do billions/second. KDFs are designed to be slow on purpose:

KDFCostNote
argon2idconfigurable CPU + MEMORYthe contest winner; memory hurts GPUs
bcryptCPU (cost 10-12)veteran, 72-byte limit
PBKDF2pure CPUthe weakest of the three, but ubiquitous

Django: PASSWORD_HASHERS with Argon2 first (pip install django[argon2]). The hash stores algorithm+parameters+salt+digest (argon2id$v=19$m=65536,t=3,p=4$...): upgrade parameters per user at the next login (transparent upgrade).

The salt (random per user, included in the hash) kills rainbow tables: two users with "password123" have different hashes. The slow cost kills offline brute force: if your DB leaks, "password123" still costs hours per mass attempt.

Verification with check_password is constant-time: it doesn't leak through timing how much matched.

2. The password in the flow (beyond the hash)

  • Rate limit of login per user+IP (23): the slow hash also protects the endpoint, but 10 attempts/s against the real user's "p@ssw0rd" = online dictionary. Limit + backoff + progressive captcha.
  • Identical error messages: "Invalid credentials" whether the email or the password fails (don't enumerate users).
  • Never log passwords (not even in exception handlers): a log sanitizer (45).
  • Length > complexity: allow 64+ chars, don't force blind monthly changes (NIST: no forced rotation; yes: a breached-password list — django-pwned-passwords against the Have I Been Pwned API with k-anonymity).

3. TOTP: the second factor that actually works

  1. Enrolment: you generate a per-user shared secret (pyotp.random_base32()), show it as a QR (otpauth://totp/TicketFlow:ana?secret=...&issuer=TicketFlow), the user's app scans it.
  2. Verification: pyotp.TOTP(secret).verify(code, valid_window=1) — a ±30s window for desynchronized clocks.
  3. Storing the secret: encrypted at rest (a field encrypted with a settings/KMS key — 23). If your DB leaks and the secret is plain, your 2FA is decorative.
  4. Recovery codes: 8-10 single-use codes, hashed like passwords, shown ONCE at activation. Without them, a lost phone = a lost account (support = a back door if it is lax).
  5. Remember device (optional): a signed 30-day cookie with an "MFA completed" mark — a conscious UX/risk balance.

4. Account recovery: the door that must not be a back door

The "forgot my password" flow is the most attacked spot (it bypasses hash and MFA if you do it wrong):

  • Token: random (secrets.token_urlsafe), hashed in DB (like a password), TTL 1h, single use, bound to the user.
  • Delivery: email to the registered user (NEVER "is this your email?" in the response — it enumerates users). The response is always the same: "If an account exists, you will receive an email".
  • On reset: invalidate all sessions/tokens (global logout), notify by email, and if they had MFA... do NOT allow a reset without MFA (or demand recovery codes: the attacker with only the email doesn't pass the TOTP).
  • Links: your own domain, no token in logs/referers (the email client may pre-load links: the single-use token + hash in DB mitigate).

5. MFA and the module's future

TOTP covers 95%. SMS is weak (SIM swapping) but better than nothing for users without the app. Passkeys/WebAuthn (OS keys) is the 2026 destination — cited as the ADR's natural evolution, not implemented today to avoid opening another front.

5. Biometrics and hardware: MFA beyond TOTP

Lesson 20's TOTP covers the standard; the complete map of the second factor, from weakest to strongest:

FactorWhat it isPhishing resistance
SMSa code by text messageLOW: SIM-swapping and SMS forwarding; discouraged by NIST
Emaila code/click by emailLOW: email is the most stolen factor in the world
TOTP (app)a 6-digit code rotating every 30 sMEDIUM: real-time phishing (a proxy between user and site) captures it and uses it instantly
Push with numbera notification with cross-verificationMEDIUM-HIGH: it demands user attention ("MFA fatigue" is the attack)
WebAuthn / passkeya cryptographic key bound to the origin (TPM/key)HIGH: phishing does NOT steal it — it only answers the legitimate domain

TicketFlow's recommendation: TOTP as the universal factor (what the lesson implemented), passkeys/WebAuthn as the premium path (the browser does everything; the backend stores the public key and verifies the challenge with django-webauthn or py_webauthn), and SMS only as a last-resort recovery channel — documented as accepted risk. The phishing rule: a factor that does NOT know which site it is on (SMS, TOTP) can be fooled; one that does (WebAuthn) cannot.

6. Account recovery: the door that opens and closes

The reset flow already implemented (MFA demanded, hashed codes, no enumeration) is half of it; the other half is the REST of the side doors an attacker uses: (1) human support (the "I call and they reset it over the phone": a support protocol with verification equivalent to the automatic reset — social engineering is the real #1 vector); (2) the account's email (recovering someone's email = recovering everything: document the dependency chain); (3) recovery codes (the 10 single-use ones: hashed, already implemented — the user must store them OFFLINE, not in the same compromisable password manager); (4) SESSION rotation after the reset (invalidate all the user's active sessions — 18: the attacker already inside does not inherit the recovery).

The door test: an account-takeover pentest lists EVERY way back in and verifies each one demands the same strength. A secure reset with one weak side door is a lock on the front door and the window open.


  1. Which three argon2 parameters would you tune on a 2GB server, and why is memory part of the defense?
  2. Why is "change your password every month" no longer recommended, and what is done instead?
  3. Why is the TOTP secret encrypted at rest, and what is your MFA worth if your whole DB leaks with plain secrets?
  4. Design the reset flow for an account WITH MFA: what does it demand besides the email, and why?
  5. Why is the "forgot password" endpoint's response identical whether or not the user exists?

Continue with the exercises. The solutions only after trying it yourself.