Passwords, authentication and MFA done right
Build the core of a safe login in Python: Argon2 password storage, generic errors, rate limiting, TOTP multi-factor codes and secure session cookies, and learn when to use a proven library instead.
In this tutorial you will build the core of a safe login system for the Brew & Bean staff dashboard, in plain Python: store passwords with Argon2, verify them without leaking information, slow down guessing, add one-time-code MFA (TOTP) and set a session cookie safely.
Prerequisites: Python 3.10 or newer, a terminal, and basic Python. You should have read *Cryptography basics* in this track. Everything runs on your own machine and uses no real accounts.
Be clear about one thing first: in a real product, prefer a proven library, framework or identity provider (your framework's auth, Auth0, Firebase Auth, Keycloak and similar) over writing your own. Building it once teaches you what those tools do for you and how to judge them.
Authentication in one minute
Authentication answers "who are you?" Authorisation answers "what are you allowed to do?" (next lesson). Authentication proves identity with one or more factors:
- something you know (password, PIN) - something you have (phone with an authenticator app, hardware key) - something you are (fingerprint, face)
Multi-factor authentication (MFA) combines two different kinds, so a stolen password alone is not enough.
Step 1: set up
Project setup
- 1Create a folder
auth-laband open a terminal in it. - 2Create a virtual environment:
python -m venv .venv. - 3Activate it:
source .venv/bin/activate(macOS, Linux, Git Bash:source .venv/Scripts/activate) or.venv\Scripts\Activate.ps1in PowerShell. - 4Install the libraries:
pip install argon2-cffi pyotp.
Step 2: store passwords properly
Never store a password, and never store it encrypted. Store the output of a password hashing function. Three ideas matter:
Salt: a random value added to each password before hashing, so two users with the same password get different hashes and pre-computed tables are useless. Modern libraries create and store the salt for you inside the hash string.
Slow on purpose: the function uses a lot of CPU and memory so each guess is expensive for an attacker. Argon2id is the current recommended choice; bcrypt and scrypt are also acceptable. Plain SHA-256 or MD5 are not.
Tunable: you can raise the cost as computers get faster.
# passwords.py
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHashError
ph = PasswordHasher() # sensible defaults (Argon2id)
def hash_password(password: str) -> str:
return ph.hash(password)
def verify_password(stored_hash: str, password: str) -> bool:
try:
return ph.verify(stored_hash, password)
except (VerifyMismatchError, InvalidHashError):
return False
def needs_rehash(stored_hash: str) -> bool:
# True if parameters were upgraded since this hash was made
return ph.check_needs_rehash(stored_hash)
if __name__ == "__main__":
h = hash_password("correct horse battery staple")
print(h)
print(verify_password(h, "correct horse battery staple"))
print(verify_password(h, "wrong password"))
Run python passwords.py.
$argon2id$v=19$m=65536,t=3,p=4$<random salt>$<hash>
True
FalseLook at the hash string. It contains the algorithm, its settings and the salt, so you only need to store this one string in a column. Run the script twice: the hash changes every time because the salt is random, yet verification still works.
Step 3: accept sensible passwords
Strength rules that annoy users ("one capital, one symbol, no more than 12 characters") do not help much. Better rules, in line with NIST guidance (SP 800-63B):
- Require a reasonable minimum length (at least 8, longer is better) and allow long passphrases (64 characters or more). - Allow any characters, including spaces and Unicode. - Reject passwords that are very common or known to be breached. - Do not force periodic changes without a reason. Change when you suspect compromise. - Let people use a password manager (allow paste).
# policy.py
COMMON = {"password", "123456", "12345678", "qwerty", "iloveyou", "admin123", "brewandbean"}
def check_password_policy(password: str, email: str = "") -> list[str]:
problems = []
if len(password) < 8:
problems.append("Use at least 8 characters.")
if len(password) > 128:
problems.append("Use at most 128 characters.")
if password.lower() in COMMON:
problems.append("That password is too common.")
if email and email.split("@")[0].lower() in password.lower():
problems.append("Do not include your email name.")
return problems
if __name__ == "__main__":
print(check_password_policy("qwerty"))
print(check_password_policy("tea before sunrise 2026"))
Prints a list of problems for qwerty, then [] for the passphrase. In production, check against a large breached-password list (for example through the Have I Been Pwned Pwned Passwords range API, which never receives the full password) instead of a tiny hard-coded set.
Why have a maximum?
The upper length limit exists so an attacker cannot send a 10 MB 'password' to burn your CPU. Keep it generous.
Step 4: log in without leaking information
Two small habits prevent common leaks.
Use one generic error. Say "Invalid email or password" whether the email does not exist or the password is wrong. Different messages let attackers discover which emails are registered (user enumeration).
Do the same work either way. If an unknown email returns instantly while a real email takes 200 ms for hashing, the timing reveals which exists. Verify against a dummy hash when the user is not found.
# login.py
from passwords import hash_password, verify_password
# Pretend database: email -> record
USERS = {
"asha@brewandbean.example": {
"hash": hash_password("tea before sunrise 2026"),
"role": "staff",
}
}
DUMMY_HASH = hash_password("dummy-password-for-timing")
def authenticate(email: str, password: str):
user = USERS.get(email.strip().lower())
stored = user["hash"] if user else DUMMY_HASH
ok = verify_password(stored, password) # same work either way
if user and ok:
return user
return None
if __name__ == "__main__":
print(authenticate("asha@brewandbean.example", "tea before sunrise 2026") is not None)
print(authenticate("asha@brewandbean.example", "wrong") is not None)
print(authenticate("nobody@brewandbean.example", "anything") is not None)
Prints True, False, False. The caller shows the same message for both failures: "Invalid email or password".
Step 5: slow down guessing
Even strong hashing cannot stop an attacker from trying a million passwords against your login if you allow it. Add rate limiting or lockout. A simple, safe approach is a delay or a temporary block after repeated failures per account and per IP address. Avoid permanent lockout, which lets an attacker lock real users out (a denial of service). More detail on rate limits for APIs is in *Securing APIs*.
# throttle.py
import time
from collections import defaultdict
MAX_FAILS = 5
WINDOW_SECONDS = 15 * 60
_fails = defaultdict(list) # key -> list of failure timestamps
def is_blocked(key: str) -> bool:
now = time.time()
recent = [t for t in _fails[key] if now - t < WINDOW_SECONDS]
_fails[key] = recent
return len(recent) >= MAX_FAILS
def record_failure(key: str) -> None:
_fails[key].append(time.time())
def reset(key: str) -> None:
_fails.pop(key, None)
Call is_blocked(email) before checking the password, record_failure(email) on a bad login and reset(email) on success. This in-memory version works for one process only. In production keep the counters in Redis or use a library, because a server restart would wipe them.
Step 6: add MFA with one-time codes (TOTP)
TOTP (time-based one-time password) is what Google Authenticator, Microsoft Authenticator and similar apps use. When a user enables MFA, the server creates a random secret and shows it as a QR code. The app and the server both compute a 6-digit code from the secret and the current 30-second time window. The code is never sent in advance, so a stolen password alone is not enough.
# totp_demo.py
import pyotp
# 1. When the user enables MFA: create and store a secret (encrypted at rest)
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
# 2. Show this URI as a QR code; the user scans it with an authenticator app
uri = totp.provisioning_uri(name="asha@brewandbean.example", issuer_name="Brew & Bean Staff")
print(uri)
# 3. At login: ask for the 6-digit code and verify it
code = totp.now() # in real life the user types this
print("valid:", totp.verify(code, valid_window=1)) # allow 1 step of clock drift
print("valid:", totp.verify("000000")) # almost certainly False
Prints an otpauth://totp/... URI, then valid: True, then valid: False. To try it with a phone, paste the URI into any free QR generator you trust or use the qrcode package, scan it, and compare the app's code with totp.now().
Good MFA practice: give users backup codes (random one-time codes, stored hashed like passwords) for when the phone is lost; rate-limit code attempts; store the TOTP secret encrypted. SMS codes are better than nothing but weaker than app or hardware-key codes, because phone numbers can be taken over (SIM swap). Passkeys (WebAuthn) are the modern passwordless option and resist phishing; if your platform or provider offers them, use them.
Step 7: sessions and cookies
After login you must remember the user. The usual way is a session: the server creates a long random id, stores who it belongs to, and sends the id to the browser in a cookie. Store the session id with strict cookie flags.
Cookie flags to set
| Cookie flag | What it does |
|---|---|
| HttpOnly | JavaScript cannot read the cookie, which limits damage from XSS |
| Secure | Browser sends it only over HTTPS |
| SameSite=Lax (or Strict) | Browser does not send it on most cross-site requests, which reduces CSRF |
| Max-Age / Expires | Sessions end after a time; keep sensitive ones short |
| __Host- prefix | Optional extra: forces Secure, path / and no Domain attribute |
# app.py (FastAPI) pip install fastapi uvicorn
import secrets
from fastapi import FastAPI, Response
app = FastAPI()
SESSIONS = {} # session_id -> email (use Redis or a database in production)
@app.post("/login-demo")
def login_demo(response: Response):
session_id = secrets.token_urlsafe(32)
SESSIONS[session_id] = "asha@brewandbean.example"
response.set_cookie(
key="session",
value=session_id,
httponly=True,
secure=True,
samesite="lax",
max_age=60 * 60 * 8, # 8 hours
)
return {"ok": True}
Run uvicorn app:app --reload and call POST /login-demo from /docs. The response carries Set-Cookie: session=...; HttpOnly; Secure; SameSite=lax. Browsers only store Secure cookies from HTTPS pages (localhost is treated as secure by modern browsers). Generate a new session id at login so an old one cannot be reused (this prevents session fixation), and delete the server-side session on logout.
Token-based login (JWT) is common for APIs. The same rules apply: short expiry, strong signing secret kept out of source code, verify the signature and the algorithm, and avoid storing long-lived tokens where scripts can read them. *Authentication: sessions, JWT and OAuth* in Backend Development covers the mechanics.
Other things a real login needs
Login checklist
- Password reset links use a long random token, expire in about 15-60 minutes, work once, and are stored hashed.
- Reset and sign-up forms do not reveal whether an email exists.
- Email change and password change ask for the current password or a fresh MFA check.
- Failed logins, lockouts and MFA changes are logged (never log passwords).
- Old sessions are invalidated when the password changes.
- Admin and staff accounts require MFA.
Build to learn, buy or adopt to ship
Do not roll your own crypto or your own login for production if you can avoid it. A managed identity provider also gives you MFA, breach monitoring, social login and audit logs. If you do write your own, follow the steps above and have someone review it.
You are a security reviewer. Review this login code for weaknesses in password storage, error messages, timing, rate limiting, session handling and MFA. For each problem, explain the risk in one sentence and show the fixed code. Do not suggest changes unrelated to security.
Language and framework: [e.g. Python FastAPI]
Code:
[paste your login route and helper functions here, with all secrets removed]What to expect: A good review mentions password hashing choice, a generic error message, rate limiting, cookie flags and session regeneration. Verify each suggestion in the library's documentation before applying it, and never paste real secrets into an AI tool.
How this comes up in interviews
Common questions: "How do you store passwords?" (Argon2id or bcrypt, salted, never reversible). "What is a salt, and why?" "Authentication versus authorisation?" "How does TOTP work?" "Session cookies versus JWT?" "How would you protect a login against brute force and credential stuffing?" (rate limits, MFA, breached-password checks, monitoring).
Try it: Wire the pieces together
Combine the files into one command-line login flow. Ask for email and password; refuse with the generic message when is_blocked is true; call authenticate; on success ask for a TOTP code and verify it; after 5 failed attempts show a message and wait. Then add a needs_rehash check that upgrades the stored hash after a successful login.
Key takeaways
- Store passwords with a slow, salted function such as Argon2id or bcrypt: never plain text, never reversible encryption.
- Return one generic login error and do the same work for unknown users.
- Rate limit and monitor login attempts; avoid permanent lockouts.
- MFA, ideally app-based or passkeys, stops a stolen password being enough.
- Session cookies need HttpOnly, Secure and SameSite, a random id and an expiry.
- Prefer proven libraries or identity providers for production authentication.
Check yourself
Finishing the quiz marks the lesson as done.
1. Why does an Argon2 or bcrypt hash include a salt?
2. Why should login return the same message for 'no such email' and 'wrong password'?
3. Which cookie flag stops JavaScript from reading the session cookie?
4. What is the main weakness of permanent account lockout after 5 failed logins?
5. In TOTP, what do the server and the authenticator app share?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.