Search Tech Journey

Find topics, journeys and posts

6-month learning plan81 / 130
back to blog
systemsintermediate 55m read

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.

🔒SecurityM10 · Security· Session 081 of 130 90 min

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

You will be able to
  • 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

A hotel with a key card
🌍 Real world

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.

💻 Code world

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

Learn these definitions verbatim
  • 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

  1. 1961
    First password stored (MIT CTSS)
    Fernando Corbató's time-sharing system introduces passwords. Also introduces the first password breach (they were stored in plaintext).
  2. 1979
    Unix crypt() with salt
    First mainstream salted password hash. Salt prevents rainbow tables.
  3. 1999
    bcrypt released
    Niels Provos + David Mazières design a tunable-cost password hash. Still the industry recommendation 26 years later.
  4. 2012
    LinkedIn breach: 117M SHA-1 passwords
    SHA-1 with no salt. Crackable in seconds. Wake-up call for the industry.
  5. 2015
    argon2 wins Password Hashing Competition
    GPU-resistant, memory-hard. New OWASP-recommended default for new systems.
  6. 2018
    WebAuthn / FIDO2 standardised
    Passwordless authentication with hardware keys. Passkeys built on this.
  7. 2023
    Passkeys mainstream
    Apple, 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

1input
1 · User signs up with password

Client sends over TLS. Never log, never store plaintext, not even for a millisecond.

2salt
2 · Generate per-user random salt

16-32 random bytes. Different for every user. Prevents identical passwords from having identical hashes.

3hash
3 · Hash with bcrypt / argon2

bcrypt(pw + salt, cost=12) OR argon2id(pw, salt, m=64MB, t=3, p=4). Cost tuned so a single hash takes ~250ms.

4store
4 · Store hash + salt + cost

Modern hash libraries encode all three in a single string like `$2b$12$saltandthehash...`. Store this in the users table.

5verify
5 · At login, re-hash and compare

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

None (chaos)
Any authenticated user can do anything. Common in early-stage apps and MVP prototypes. Ticking time bomb.
L0
Role-based (RBAC)
Users have roles (admin, editor, viewer). Endpoints check role. Simple, works for 80% of apps.
L1
Resource-scoped RBAC
Roles per resource. 'Alice is admin of workspace-123, viewer of workspace-456'. Multi-tenant SaaS default.
L2
Attribute-based (ABAC)
Rules based on user + resource + context attributes. 'HR can view salaries in their department, only during business hours'. Powerful, complex.
L3
Policy engines (OPA, Cedar, Zanzibar)
External policy service. Decouples authz from app code. Google's Zanzibar underpins Drive/YouTube/Cloud IAM.
L4

Sessions vs JWTs at a glance

Session cookies

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

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
Hybrid (opaque + JWT)

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


Common misconception
✗ What most people think

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

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 = ?
From first principles
Start with the question

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?

  1. 1
    An 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
  2. 2
    That 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
  3. 3
    If 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
  4. 4
    But 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
  5. 5
    The 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

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.

Mental modelPassport and visa

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_user cannot 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.
🔔 Fires when you see

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.

The tradeoff

How do you represent a session: a stateless signed token (JWT), a server-side session with an opaque ID, or a hybrid?

Stateless JWT
+ you gain verification is a local signature check with a public key — no auth-server round trip, no shared session store, so any service in any region validates independently. This is what makes it scale across a large service mesh.
− you pay you cannot revoke. A stolen or mis-issued token is valid until 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.
pick when many independent services must validate, cross-region or cross-team, and a revocation lag equal to the token TTL is acceptable
Server-side session, opaque ID
+ you gain instant revocation, instant permission changes, no data exposed in the cookie, and the ability to enumerate and inspect live sessions — which is what makes "log out all devices" and session-hijack detection possible at all
− you pay every authenticated request touches the session store, so that store is now on the critical path of your entire system: it needs replication, it becomes a latency floor, and its failure is a total outage rather than a degraded one
pick when a single application or small service group, or any system where instant revocation is a hard requirement — banking, healthcare, admin consoles
Hybrid: short JWT + revocable refresh + a revocation list
+ you gain fast stateless validation in the common path, bounded revocation lag from the short TTL, and true revocation of the long-lived credential; a small denylist of revoked token IDs handles the emergency case without checking state on every request
− you pay the most moving parts — rotation, denylist propagation, clock-skew handling, and two expiry policies to reason about. More places to get it subtly wrong.
pick when you need both scale and revocation, which for any real multi-service product with a security team is the actual requirement
What a senior engineer actually does

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

hash_password · bcrypt cost=12
cost=12 = 4096 iterations. On modern hardware ~250ms. Rule of thumb: tune so a single hash takes 0.2-1s — slow enough to make brute-force expensive, fast enough that legit login isn't awful.
hashing
verify_password · constant-time
bcrypt.checkpw does constant-time comparison internally. Never use `==` on hashes — that's timing-attack territory.
compare
issue_session · secrets.token_urlsafe(32)
256 bits of cryptographic entropy from the OS. Never use uuid4 for session ids (predictable enough in some contexts) and never use time-based tokens.
token
issue_jwt with exp=15min
Short expiry! Long-lived JWTs are the #1 JWT footgun. Pair with a refresh token flow for real apps.
jwt
require_auth middleware
One place, one decision. Every protected endpoint uses @require_auth. Default is DENY. This is the pattern that prevents 90% of authz mistakes.
middleware
require_role composition
Applied AFTER @require_auth. Composable. Real apps use richer policies (@require_permission('users.delete')) but the pattern is the same.
RBAC
dummy_hash on nonexistent user
Prevents timing-based user enumeration. If we short-circuited on 'user doesn't exist', an attacker times the response and learns which emails are registered.
timing
rate_limit_login
Even 5 attempts per 5 minutes stops most credential-stuffing. Real production: per-IP + per-email + progressively longer lockouts + captcha after N.
brute-force
Try itBreak your own login — then fix each break

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?

💡 Hint · Try three attacks: (1) Timing attack: time responses for existing vs nonexistent emails without the dummy hash. (2) Session fixation: manually set a session cookie to a known value before login. Does the server accept it? (Should not.) (3) JWT alg=none: craft a JWT with alg=none, send it. Some naive verifiers accept unsigned tokens. Verify your verify_jwt explicitly checks alg.

(d) Production reality · 15 min

War story LinkedIn· 2012117 million passwords leaked
🔥 What broke

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.

🧯 The fix
LinkedIn eventually moved to bcrypt with per-user salt. But the damage was already done — those hashed passwords were reused by users on other sites. The credential-stuffing attacks that followed hit hundreds of unrelated services.
🎓 Lesson to steal
Two mistakes: (1) unsalted, so rainbow tables work. (2) SHA-1, so cracking is fast. Correct: per-user random salt + slow function (bcrypt cost 12 or argon2id). If you're storing SHA-1 or MD5 or plain SHA-256 passwords, your database is a pre-cracked dump waiting to happen.
Post-mortem
War story Auth0 / broader industry'JWT alg=none' vulnerability circa 2015-2018
🔥 What broke
Several JWT libraries accepted tokens with `alg: none` in the header — meaning 'this token has no signature; trust the claims as-is'. Attackers crafted admin tokens with alg=none. Many implementations (including some in Node, Python, Ruby) verified successfully. Reports exist across dozens of production apps.
🧯 The fix
Modern libraries either default-reject alg=none, or require the caller to explicitly opt in. Best practice: always call jwt.decode(token, key, algorithms=["HS256"]) with an EXPLICIT allowlist of algorithms. Never call the two-arg form.
🎓 Lesson to steal
Cryptographic APIs must default to secure. When they don't, entire industries misuse them. If you write auth code, either use a well-maintained library (Auth0, Clerk, Firebase Auth) or read the security guide of the one you chose top-to-bottom. Reading skimming is what causes headlines.
Post-mortem
War story Snapchat· 20144.6M usernames + phone numbers
🔥 What broke
Snapchat's 'find friends' API allowed unlimited lookups of usernames-to-phone-numbers with no rate limit. Attackers scraped 4.6M records in days. This is an authorization bug — the API allowed a user to enumerate all users, not just their friends.
🧯 The fix
Rate limit + require friend relationship for phone lookups + captcha after suspicious patterns. Now standard, but often overlooked in new APIs.
🎓 Lesson to steal
Authorization isn't just 'admin vs user'. It's 'can THIS user see THIS resource?'. An endpoint that lets any authenticated user query any other user's data IS an authz bug even if it 'requires login'. Every list/search endpoint needs to scope results to the caller's permissions.
Post-mortem

Where this shows up in the rest of the plan

AuthN/AuthZ is the foundation of every future security topic
S082 · TLS & PKI
How auth tokens travel securely.
S083 · OWASP Top 10
Broken authentication and broken access control are 2 of the top 10.
S084 · OAuth 2.0 + OIDC
Delegated auth. Everything you learned here scales up.
S085 · MFA + WebAuthn / passkeys
Adding second factors to what you built.
S089 · Rate limiting
Login rate limits are your first defense against credential stuffing.
S128 · System design interviews
'How does auth work in your design?' is asked in every senior interview.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

Teach these three, no notes:

  1. AuthN vs AuthZ — one sentence each, and the order in a request.
  2. Why bcrypt over SHA-256 — the 'slow on purpose' point.
  3. 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.