S081 · AuthN vs AuthZ, Sessions & Password Storage
The two questions every request has to answer: who are you, and what are you allowed to do? Plus how to store passwords without ending up in a HIBP breach dump.
🎯 Distinguish AuthN from AuthZ cleanly, pick between sessions and JWTs, and store passwords with bcrypt/argon2 correctly on the first try.
Why this session exists
Every serious breach headline of the last decade starts with one of three mistakes: passwords stored in plaintext (or with MD5, which is basically plaintext), sessions that never expire, or authorization checks that live in the client. Getting these right is the difference between a Tuesday commit and a $200M lawsuit. This session is the minimum every backend engineer must know to not become the next headline — and it's the foundation for every future security topic (OAuth, MFA, RBAC, JWT gotchas, SSO).
- Define authentication vs authorization in one sentence each and give the request-lifecycle order.
- Pick between session-cookies and JWTs for a given app, and defend the choice against 'but JWTs are stateless!'.
- Store passwords with bcrypt or argon2, correctly, including work factors and salt.
- Design a login flow with proper rate limiting, timing-attack resistance, and lockout logic.
- Recognise the four most-exploited authN/authZ mistakes in code reviews.
Prerequisites
- S062 · REST & HTTP — you know what cookies and headers are.
- S068 · Databases — you'll store users, sessions, hashes somewhere.
(a) Intuition · 5 min
You check in at the front desk. The clerk verifies your ID and reservation — that's authentication: 'are you the person you claim to be?'. You get a key card. The key card lets you into your specific room but not the pool, the gym, or the presidential suite — that's authorization: 'what are you allowed to do once we know who you are?'.
The key card itself is neither ID nor room permission — it's a token the hotel gave you as proof of the earlier authentication. When you tap it on a door, the reader looks up 'this card = this guest = this room' from its database (or from data encoded on the card itself). If you lose the card, someone else can use it. If the hotel wants to revoke it, they can — but only if the readers can talk to the central database.
Authentication = 'who are you?' — proved by username+password, or SSO, or magic link, or hardware key. Happens once. Authorization = 'what can you do?' — checked on every request. The session cookie or JWT is your key card. Session cookies are looked up in the server's database (revocable, stateful). JWTs are self-contained (stateless, fast, but revocation is hard).
Every serious breach headline is either: (a) the key-card duplication was too easy, (b) the key never expired, or (c) the room-door reader didn't actually check permissions.
The core concepts you must define perfectly
- AuthN (authentication) — proving WHO the user is. Password, SSO, MFA, magic link, WebAuthn. Happens once, produces a token.
- AuthZ (authorization) — determining WHAT the authenticated user can do. Role check, permission check, ACL. Happens on EVERY request.
- Session — server-side record of an authenticated user, referenced by an opaque session_id in a cookie. Stateful, revocable, requires DB lookup.
- JWT (JSON Web Token) — self-contained token with signed claims (user_id, roles, exp). Stateless, no lookup needed, but revocation requires extra work.
- Password hash — a one-way transformation (bcrypt, argon2, scrypt) with a per-user random salt, tuned to be slow enough that brute-force is expensive.
A brief history of getting this wrong
- 1961First password stored (MIT CTSS)Fernando Corbató's time-sharing system introduces passwords. Also introduces the first password breach (they were stored in plaintext).
- 1979Unix crypt() with saltFirst mainstream salted password hash. Salt prevents rainbow tables.
- 1999bcrypt releasedNiels Provos + David Mazières design a tunable-cost password hash. Still the industry recommendation 26 years later.
- 2012LinkedIn breach: 117M SHA-1 passwordsSHA-1 with no salt. Crackable in seconds. Wake-up call for the industry.
- 2015argon2 wins Password Hashing CompetitionGPU-resistant, memory-hard. New OWASP-recommended default for new systems.
- 2018WebAuthn / FIDO2 standardisedPasswordless authentication with hardware keys. Passkeys built on this.
- 2023Passkeys mainstreamApple, Google, Microsoft all ship passkey support. Post-password era finally arriving.
(b) Visual walkthrough · 15 min
The request lifecycle
Session vs JWT
The password hashing pipeline
Client sends over TLS. Never log, never store plaintext, not even for a millisecond.
16-32 random bytes. Different for every user. Prevents identical passwords from having identical hashes.
bcrypt(pw + salt, cost=12) OR argon2id(pw, salt, m=64MB, t=3, p=4). Cost tuned so a single hash takes ~250ms.
Modern hash libraries encode all three in a single string like `$2b$12$saltandthehash...`. Store this in the users table.
bcrypt.compare(input_pw, stored_hash) — the library extracts the salt + cost from the stored hash and reproduces the same computation.
AuthZ models compared
From simplest to most sophisticated
Sessions vs JWTs at a glance
Boring, correct, revocable
- Server keeps state (session_id → user_id in Redis/DB)
- Revocation: DELETE FROM sessions WHERE user_id=... — instant
- Cookie automatically sent by browser (Set-Cookie)
- Great for browser apps, first-party APIs
- Cost: DB/Redis lookup per request (~1ms)
Stateless, fast, revocation-painful
- Self-contained: {user_id, roles, exp} signed
- No lookup needed — verify signature + exp
- Great for cross-service auth, mobile apps, short-lived tokens
- Revocation requires a blocklist (which reintroduces state)
- Common bugs: alg=none accepted, RS/HS confusion, long-lived tokens
The 2020s consensus
- Short-lived access token (JWT, 15 min)
- Long-lived refresh token (opaque, DB-backed, revocable)
- Best of both: fast per-request, revocable at will
- Used by Auth0, Okta, most SSO providers
The mental model to hold
"Once a user is logged in, authorization is basically solved — I check their role at the API boundary and let the request through. Anyone with a valid token is a legitimate user, so the hard part was authentication."
Authentication answers who exactly once, at the edge. Authorization must be answered per resource, per action, on every request — because the interesting question is not "is this a real user" but "may this real user touch this specific row". A valid token proves identity and says nothing about entitlement. The overwhelming majority of real breaches are authenticated users reaching objects that were never theirs.
The myth is sticky because role checks at the edge genuinely do work for the first system most engineers build — one where resources are not owned by individuals. An admin dashboard where every admin may see everything really is solved by a single role gate. The model only breaks when resources acquire owners, and then it breaks silently: GET /api/documents/1042 with a perfectly valid token returns someone else's document, no error, no alert, no stack trace. Nothing in your test suite fails, because your tests use a user who owns the object.
The ten-second IDOR check. Authenticate as user A, then request user B's object with A's token:
# A's token, A's own doc -> 200 (correct)
curl -H "Authorization: Bearer $TOKEN_A" api/documents/1001
# A's token, B's doc -> MUST be 403 or 404
curl -H "Authorization: Bearer $TOKEN_A" api/documents/2002
# if that returned 200, your authz is 'is the token valid'
# the fix is ownership in the WHERE clause, not an if-statement:
# SELECT * FROM documents WHERE id = ? AND owner_id = ?Why do access tokens expire in minutes while refresh tokens live for weeks? If short expiry is safer, why not make everything short-lived — or if long tokens are convenient, why not skip the second token entirely?
- 1An access token is a bearer credential: whoever holds it is treated as the user, with no further proof. So the entire security question reduces to how long a stolen copy stays useful.forced by · self-contained tokens are verified by signature alone, with no call back to the issuer
- 2That self-contained property is the whole point — it lets any service validate a token locally with a public key, with no network hop to an auth server on every request. Which means the issuer cannot revoke it; nothing consults the issuer.forced by · you traded revocability for stateless, horizontally scalable verification
- 3If you cannot revoke, the only remaining lever on the blast radius of a theft is the expiry time. Short expiry is not defence in depth — it is the sole mitigation, which is why access tokens sit in the 5–15 minute range.forced by · exposure window equals remaining lifetime when revocation is impossible
- 4But forcing the human to re-authenticate every 15 minutes is unacceptable, so something must silently mint new access tokens. That something is a second credential with a long life.forced by · usability constrains how often you may prompt for a password or MFA
- 5The long-lived credential is only safe if it has the opposite properties: it is presented to exactly one endpoint (the token endpoint), it is stored where scripts cannot read it (HttpOnly cookie or OS keychain, never localStorage), and — crucially — it is checked against server state on every use, so it is revocable, and can be rotated so that reuse of an old refresh token detects theft.forced by · a credential you cannot revoke must be short-lived; a credential you keep long must be revocable and rarely transmitted
Therefore the two-token split is forced: it separates frequently transmitted and unrevocable from rarely transmitted and revocable, and gives each the only expiry policy that is safe for its properties. One token cannot be both.
And note what this predicts: logout cannot be instantaneous. Killing the refresh token stops renewal, but any already-issued access token stays valid until it expires. That is why "logout everywhere" is eventually-consistent with a lag equal to the access-token TTL — and why systems that need instant revocation must either shorten that TTL or reintroduce a stateful check, paying back exactly the network hop they were avoiding.
Authentication is the passport: it proves who you are, is issued once by a trusted authority, and is checked at the border. Authorization is the visa: it says what you may do here, for how long, and is checked by every official who matters — not once at the airport.
A valid passport gets you nowhere by itself. And critically, the visa is checked at the door of each room, not at the entrance to the building — because the building's front desk has no idea which rooms are yours.
- AuthN once at the edge, AuthZ at every resource access. If the check is only at the API gateway, you have role-based access with no ownership model.
- Enforce ownership in the data layer, not in an
if.WHERE owner_id = :current_usercannot be bypassed by a forgotten code path; an if-statement can. - Deny by default. New endpoints and new fields must be inaccessible until explicitly permitted — the failure mode of allow-by-default is silent and invisible.
- Never trust anything the client sends about identity or role. The user ID comes from the verified token, never from the request body or a header the client controls.
Fire this the moment you see: a sequential integer ID in a URL · a role check only in the gateway or middleware · user_id read from a request body · JWTs in localStorage · a token with no exp · an admin endpoint distinguished only by its path · a new query added without an ownership predicate.
How do you represent a session: a stateless signed token (JWT), a server-side session with an opaque ID, or a hybrid?
exp, and "ban this user now" is not expressible. Claims also go stale — a permission removed at 10:00 still works until the token expires. Payloads are readable by anyone holding them, so nothing secret goes inside.For a single application, server-side sessions are the boring correct answer and the industry over-adopted JWTs for systems that never needed them. The stateless property is worth paying for only when you genuinely have many independent validators.
When you do use JWTs, the discipline is: short access TTL, refresh token in an HttpOnly cookie with rotation and reuse detection, and never treat the token as a cache for authorization decisions that can change. The most common production failure is not a forged token — it is a perfectly valid token carrying a permission that was revoked twenty minutes ago.
(c) Hands-on · 25 min
Let's build a real login + protected endpoint using bcrypt + sessions, then compare to a JWT version. Both in one Flask app.
#!/usr/bin/env python3
"""auth_lab.py — a working authN/authZ implementation.
Run:
pip install flask bcrypt PyJWT
python auth_lab.py
Then:
curl -X POST localhost:5000/signup -d 'email=a@a.com&password=hunter2'
curl -X POST localhost:5000/login -d 'email=a@a.com&password=hunter2' -c cookies.txt
curl localhost:5000/me -b cookies.txt
curl localhost:5000/admin -b cookies.txt # should 403 (not admin)
"""
from __future__ import annotations
import secrets
import time
import uuid
from dataclasses import dataclass, field
from typing import Callable
import bcrypt
import jwt
from flask import Flask, jsonify, request, g
# --- Fake DBs (in-memory, threading unsafe — demo only) ---
USERS: dict[str, dict] = {}
SESSIONS: dict[str, dict] = {} # session_id -> {user_id, expires_at}
JWT_SECRET = secrets.token_bytes(32)
JWT_ALG = "HS256"
app = Flask(__name__)
# --- Helpers ---
def hash_password(pw: str) -> bytes:
"""bcrypt with cost=12. Tune cost so hashing takes ~250ms on your hardware."""
return bcrypt.hashpw(pw.encode(), bcrypt.gensalt(rounds=12))
def verify_password(pw: str, stored_hash: bytes) -> bool:
"""Constant-time compare via bcrypt's own routine."""
return bcrypt.checkpw(pw.encode(), stored_hash)
def issue_session(user_id: str) -> str:
sid = secrets.token_urlsafe(32)
SESSIONS[sid] = {
"user_id": user_id,
"expires_at": time.time() + 3600, # 1 hour
}
return sid
def issue_jwt(user_id: str, roles: list[str]) -> str:
payload = {
"sub": user_id,
"roles": roles,
"iat": int(time.time()),
"exp": int(time.time()) + 900, # 15 min
}
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
# --- Middleware ---
def require_auth(fn: Callable) -> Callable:
"""Middleware: reject if no valid session OR JWT. Populate g.user."""
def wrapper(*args, **kwargs):
# Try session cookie first
sid = request.cookies.get("sid")
if sid and sid in SESSIONS:
s = SESSIONS[sid]
if s["expires_at"] > time.time():
g.user = USERS.get(s["user_id"])
if g.user:
return fn(*args, **kwargs)
# Try JWT bearer
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:]
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
g.user = USERS.get(payload["sub"])
if g.user:
g.user["roles"] = payload.get("roles", [])
return fn(*args, **kwargs)
except jwt.PyJWTError:
pass
return jsonify({"error": "unauthorized"}), 401
wrapper.__name__ = fn.__name__
return wrapper
def require_role(role: str) -> Callable:
"""Middleware: require an authenticated user with the given role."""
def deco(fn: Callable) -> Callable:
def wrapper(*args, **kwargs):
user = getattr(g, "user", None)
if not user or role not in user.get("roles", []):
return jsonify({"error": "forbidden"}), 403
return fn(*args, **kwargs)
wrapper.__name__ = fn.__name__
return wrapper
return deco
# --- Rate limiting: simple per-email counter ---
LOGIN_ATTEMPTS: dict[str, list[float]] = {}
MAX_ATTEMPTS = 5
WINDOW = 300 # 5 min
def rate_limit_login(email: str) -> bool:
now = time.time()
attempts = LOGIN_ATTEMPTS.setdefault(email, [])
# Prune old
LOGIN_ATTEMPTS[email] = [t for t in attempts if now - t < WINDOW]
if len(LOGIN_ATTEMPTS[email]) >= MAX_ATTEMPTS:
return False
LOGIN_ATTEMPTS[email].append(now)
return True
# --- Routes ---
@app.route("/signup", methods=["POST"])
def signup():
email = request.form.get("email", "").lower().strip()
pw = request.form.get("password", "")
if not email or len(pw) < 8:
return jsonify({"error": "email required, password >= 8 chars"}), 400
if email in USERS:
return jsonify({"error": "user exists"}), 409
user_id = str(uuid.uuid4())
USERS[email] = {
"id": user_id,
"email": email,
"hash": hash_password(pw),
"roles": ["user"], # default
}
return jsonify({"ok": True, "user_id": user_id}), 201
@app.route("/login", methods=["POST"])
def login():
email = request.form.get("email", "").lower().strip()
pw = request.form.get("password", "")
# Rate-limit even before doing the expensive hash
if not rate_limit_login(email):
return jsonify({"error": "too many attempts, try again later"}), 429
user = USERS.get(email)
# CRITICAL: hash a dummy password if user doesn't exist, so timing
# doesn't leak whether email is registered.
dummy_hash = b"$2b$12$0000000000000000000000000000000000000000000000000000"
stored = user["hash"] if user else dummy_hash
ok = verify_password(pw, stored)
if not user or not ok:
return jsonify({"error": "invalid credentials"}), 401
# Issue session cookie
sid = issue_session(email)
resp = jsonify({"ok": True, "roles": user["roles"]})
resp.set_cookie("sid", sid, httponly=True, samesite="Strict",
secure=False) # TRUE in production over HTTPS
return resp
@app.route("/login/jwt", methods=["POST"])
def login_jwt():
email = request.form.get("email", "").lower().strip()
pw = request.form.get("password", "")
if not rate_limit_login(email):
return jsonify({"error": "rate limit"}), 429
user = USERS.get(email)
dummy_hash = b"$2b$12$0000000000000000000000000000000000000000000000000000"
if not user or not verify_password(pw, user["hash"] if user else dummy_hash):
return jsonify({"error": "invalid"}), 401
token = issue_jwt(user["id"], user["roles"])
return jsonify({"access_token": token, "expires_in": 900})
@app.route("/logout", methods=["POST"])
@require_auth
def logout():
sid = request.cookies.get("sid")
if sid:
SESSIONS.pop(sid, None)
resp = jsonify({"ok": True})
resp.set_cookie("sid", "", expires=0)
return resp
@app.route("/me")
@require_auth
def me():
return jsonify({"email": g.user["email"], "roles": g.user["roles"]})
@app.route("/admin")
@require_auth
@require_role("admin")
def admin():
return jsonify({"secret": "you're an admin"})
if __name__ == "__main__":
# Create an admin for testing
USERS["admin@a.com"] = {
"id": "admin-1",
"email": "admin@a.com",
"hash": hash_password("adminpass"),
"roles": ["user", "admin"],
}
print("Try:")
print(" curl -X POST localhost:5000/signup -d 'email=a@a.com&password=hunter2'")
print(" curl -X POST localhost:5000/login -d 'email=a@a.com&password=hunter2' -c c.txt")
print(" curl localhost:5000/me -b c.txt")
print(" curl localhost:5000/admin -b c.txt # 403")
print(" curl -X POST localhost:5000/login -d 'email=admin@a.com&password=adminpass' -c ac.txt")
print(" curl localhost:5000/admin -b ac.txt # 200")
app.run(host="127.0.0.1", port=5000)What each block does
Anatomy of the auth code
Attempt each attack and fix:
# Attack 1: user enumeration by timing
# Fix: keep the dummy_hash path
# Time: `time curl localhost:5000/login -d 'email=exists@a.com&password=x'`
# vs `time curl localhost:5000/login -d 'email=fake@a.com&password=x'`
# Both should take ~250ms.
# Attack 2: JWT alg=none
# Construct: header={"alg":"none"}, payload={"sub":"admin-1","roles":["admin"]}
# Base64url them, no signature. Send as Bearer.
# PyJWT rejects this by default because we passed algorithms=["HS256"] explicitly.
# Old libraries accepted alg=none — hence the mandate: NEVER use jwt.decode(token, key)
# without an algorithms= whitelist.Commit findings to your team's security-review checklist. Every backend PR should include: does it check auth? does it use middleware? does it accept alg=none? Timing safe on user checks?
(d) Production reality · 15 min
LinkedIn stored passwords as unsalted SHA-1 hashes. The 2012 breach dumped 6.5M hashes publicly; in 2016 the full 117M was released. Without salt, attackers could compute a rainbow table once and crack most passwords in seconds. Common passwords fell in microseconds.
jwt.decode(token, key, algorithms=["HS256"]) with an EXPLICIT allowlist of algorithms. Never call the two-arg form.Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
Teach these three, no notes:
- AuthN vs AuthZ — one sentence each, and the order in a request.
- Why bcrypt over SHA-256 — the 'slow on purpose' point.
- The frontend-only authz mistake — how to spot it in code review.
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.