Who Goes There · Nº 15
A field guide to authentication and identity
Start with the uncomfortable fact this whole course is built on: no server has ever authenticated a human. A server receives bytes. Some of those bytes — a cookie, an Authorization header, a signed assertion — are a claim that some earlier event established who is on the other end. The server trusts that earlier event, which trusted an earlier one, and so on down a chain that terminates, several links away, in something a person did with a keyboard or a fingerprint sensor.
Reading that chain is the core skill of this domain, and it is what makes the acronyms tractable. OAuth, OIDC, SAML, JWT, WebAuthn are not a zoo of competing products; each one strengthens a specific link, and most of them exist to let you stop trusting a specific party. This module builds the chain, distinguishes it sharply from the question authorization answers, and then reads one real request backwards from the wire to the human.
Authentication answers one question: which principal is making this request? Authorization answers a different one: is this principal permitted to do this particular thing to this particular resource? The questions are answered at different moments, by different systems, using different evidence, and they fail in different ways — which is why HTTP gives them different status codes. A 401 Unauthorized means the identity question is unanswered: no credential, expired credential, bad credential; the honest reading is "unauthenticated, try again with proof." A 403 Forbidden means the identity question is answered and the answer does not help you: we know exactly who you are, and you may not do this.
The distinction is not pedantry, because the two codes tell a client to do opposite things. A 401 is an instruction to re-authenticate — refresh the token, redirect to login, prompt for the password. A 403 is an instruction to stop: retrying with a fresh credential will produce the same answer, because the credential was never the problem. An API that returns 403 for a missing token trains every integrator to treat permission errors as permanent, and their clients will happily sit in a broken state rather than doing the one thing that would fix it.
This course answers only the first question. The second — policy models, roles, attribute-based rules, delegation to agents — is the subject of Guide Nº 05 (Authorization for Agents). Where this guide hands you a verified principal, that one takes over.
You have seen this separation before under other names. In GRC work, an access review is an authorization control — it asks whether the entitlements attached to a person are still appropriate. Authentication logging is a different control with a different audience: it asks whether the login events themselves look like the person they claim to be. Auditors keep the two in separate control families for the same reason engineers should keep them in separate middleware: they fail independently, and a strong answer to one tells you nothing about the other.
Symptom: one middleware function named requireAuth that loads the session, checks the user exists, checks a role, and returns 403 on every failure path. Why it hurts: the two questions become one boolean, so you cannot answer "was this request unauthenticated or under-privileged?" during an incident, and clients cannot tell a retryable failure from a terminal one. Corrective: two layers. Authentication resolves a principal or returns 401 and never consults permissions; authorization receives a principal and returns 403 and never consults credentials.
Here is the chain, stated once and used for the rest of the course. A credential vouches for a human: the password they know, the device they hold, the finger they present. A login event vouches for the credential: at 09:14 on Tuesday, this credential was verified by this method against this account. A session vouches for the login event: a row in a store, or a signed token, that says the verification happened and has not yet expired. A cookie or an Authorization header vouches for the session: an unguessable string that names it. And the request vouches for nothing at all — it simply carries that string, which is why the string is the crown jewel.
Every authentication technology answers two questions: which link does it strengthen, and which party does it let you stop trusting? Passkeys strengthen human → credential. Session stores strengthen login → session. OAuth lets you stop trusting an app with a password. OIDC lets you stop trusting your own login page. Ask both questions of any acronym and it stops being magic.
The chain is an evidentiary foundation problem. A cookie is hearsay about a login event; the session row is the business record that makes it admissible; the credential check is the authentication of the exhibit under Rule 901. Litigators already know the instinct that matters here — walk every exhibit back to a witness who can be cross-examined, and be suspicious of the link where nobody can.
The attack literature reads like a bestiary until you file each entry by the link it breaks. Then it collapses into five families, and the mapping tells you which defense could possibly help.
| Link | Attack | What the attacker ends up holding | Defense that actually applies |
|---|---|---|---|
| Human → credential | Phishing, malware keylogging | The credential itself | Credentials that cannot be relayed (passkeys); not user training alone |
| Credential → login | Credential stuffing, spraying, offline cracking | A valid login for someone else's account | Breach-corpus checks, throttling, slow hashes, a second factor |
| Login → session | Session fixation | A session the victim authenticates into | Rotating the session ID at every privilege change |
| Session → cookie | Session hijacking, XSS exfiltration | A live session, no credential needed | HttpOnly, Secure, short expiry, binding and re-auth for sensitive acts |
| Cookie → request | Token theft, CSRF riding | The ability to send requests as the user | SameSite, TLS everywhere, anti-CSRF tokens |
Two consequences follow immediately. First, defenses do not stack across links: hardware MFA on the human → credential link does nothing about a stolen session cookie, because by then the credential is no longer part of the story. Second, an incident response that hardens the wrong link is worse than useless, because it consumes the political capital you would need to fix the actual break. If sessions were hijacked through an XSS, forcing a password reset is theater — the attacker never had a password.
The chain is a model, not a topology. Real systems have several parallel chains reaching the same account — a mobile app with its own token, an API key issued to a partner, a support tool with impersonation, and the recovery flow. The security of the account is the weakest of all of them. Module 8 is about the one everybody forgets.
Take one request against cellarbook.app, the wine-cellar product this course builds identity for. A user opens their cellar list; the browser sends this:
GET /api/cellar-entries HTTP/1.1
Host: cellarbook.app
Cookie: wc_session=v1.rZ8qXm41pT7wKd93bN2c
User-Agent: Mozilla/5.0 ...Now walk it backwards. The request vouches for nothing; it carries a string. The string names a row in the session store, and that row is the whole of what the server knows:
session_id v1.rZ8qXm41pT7wKd93bN2c
user_id usr_8412
created_at 2026-11-04T18:22:07Z (the login event)
last_seen 2026-11-16T09:41:55Z
auth_method password+totp
expires_at 2026-11-18T18:22:07ZThat row vouches for a login event twelve days ago, in which a password and a TOTP code were verified against account usr_8412. Those credentials vouched — with a confidence you should now be able to state precisely — for a human. Nothing in this request re-establishes any of it. The server is trusting a twelve-day-old event on the strength of a random string, which is exactly why the string's unguessability, its cookie attributes, and the row's expiry are load-bearing rather than housekeeping.
The middleware stack makes the boundary between the two questions concrete. The first layer resolves the cookie to a session row and then to a principal, or returns 401: it never looks at what is being requested. The second layer receives the principal and asks whether usr_8412 may read this cellar — a question about ownership and roles that has nothing to do with cookies — and returns 403 if not. The dividing line is a fact you should be able to point to in your own codebase.
401 and never inspects the resource; authorization receives that principal and emits 403 and never inspects credentials. The dashed lower path is the conflated middleware: it collapses both failures into one code, and the client's retry logic loses the only signal that told it what to do next.Passwords have been declared obsolete for twenty years by people who were right about the technology and wrong about the economics. They persist because they require no enrollment infrastructure, work on a borrowed laptop in an airport, cost nothing to deploy, and are understood by every human on earth. Nothing else in this course clears all four bars, which is why the password login remains the baseline — the flow that every later mechanism is a modification of, and the flow you will still be maintaining when the passkey migration is three years old.
This module builds that baseline end to end: why passwords fail systemically rather than individually, the three attacks that follow from that failure and the different defenses each one requires, what "secure storage" means at the level a practitioner must be able to defend in a design review, and how a single verified credential becomes durable server-side state carried by a cookie. By the end you should be able to read a Set-Cookie header the way you read a contract clause: knowing what each term does, and what its absence permits.
The standard critique of passwords is that humans choose bad ones. That critique is true and mostly irrelevant. The systemic failure is reuse: the average person holds accounts in the low hundreds and passwords in the low dozens, so a credential is not a secret shared between one human and one service — it is a secret shared between one human and every service that human has ever signed up for, including the forum that stored it in plaintext and got breached in 2019.
This reframing changes what policy is worth writing. Complexity rules (an uppercase, a digit, a symbol) target individual weakness and produce predictable mutations — Winter2026! satisfies every rule and appears in every cracking dictionary. Forced 90-day rotation targets a threat model in which the attacker sits on a password for months, and empirically produces incremented suffixes. Current NIST guidance reflects the shift: length over composition, screening candidate passwords against known-breached corpora, and no mandatory expiry absent evidence of compromise. Screening is the only one of the three that engages the actual failure, because it asks the question that matters — is this password already in an attacker's list?
The economics also explain why attackers rarely bother with anything sophisticated. A billion-row credential corpus costs an attacker almost nothing and converts at a fraction of a percent — which, at a billion rows, is a very good business. You are not defending against someone who wants your user; you are defending against someone processing a spreadsheet.
Credential stuffing replays known pairs: the attacker takes alice@example.com / Sunset!2021 from someone else's breach and tries it on your login. It is high-precision and low-volume per account — often exactly one attempt — because the attacker already believes the password is right. Password spraying inverts the shape: pick two or three passwords everybody uses (Autumn2026!, Company123) and try them across every account in the directory, one attempt per account per day. Phishing skips the guessing entirely and asks the human to type the credential into a site the attacker controls.
They exploit different weaknesses and therefore fall to different defenses, and this is where practitioners lose weeks. Per-account lockout — the reflex control — stops stuffing reasonably well and does almost nothing against spraying, because spraying never trips a per-account threshold: forty thousand accounts times one attempt each is forty thousand first attempts. Detecting spraying requires looking across accounts: failure rate per source, per time window, distinct usernames touched by one IP or ASN, and the signature that gives it away — a large number of accounts failing with a small number of distinct passwords. And neither control touches phishing, because in a phish there are no failed attempts at all; the credential arrives correct, on the first try, from a plausible location.
Symptom: a login endpoint with a five-strikes lockout and a dashboard that shows very few blocked attempts, treated as evidence that credential attacks are handled. Why it hurts: the control is measuring the attack it stops and is blind to the two it does not; meanwhile per-account lockout hands an attacker a denial-of-service primitive against any named user. Corrective: keep throttling, but add breach-corpus screening at signup and password change, cross-account anomaly detection keyed on distinct-usernames-per-source, and a second factor — and stop counting blocked attempts as coverage.
The threat model for password storage has exactly one scenario: your database is now in the attacker's hands and they have unlimited offline time with it. Every design decision follows from that. You are not trying to make cracking impossible — the attacker holds the hashes and the guesses, so given enough compute, weak passwords fall. You are trying to make cracking expensive per guess, so the attacker gets a handful of the worst passwords instead of the whole table.
Two mechanisms do the work. A salt — a unique random value stored alongside each hash — ensures that no two identical passwords produce identical hashes, which destroys the precomputation economy: rainbow tables become useless, and the attacker must attack each row separately rather than cracking the whole table with one pass. A deliberately slow, memory-hard hash — bcrypt with a cost factor, scrypt, or Argon2id — sets the price of a single guess. Fast cryptographic hashes are the trap here: SHA-256 is an excellent hash and a terrible password hash, precisely because it is designed to be fast. A single commodity GPU does tens of billions of SHA-256 guesses per second; that same GPU manages a few thousand bcrypt-at-cost-12 guesses per second. Same attacker, same hardware, six or seven orders of magnitude difference in what they get out of your table.
Autumn2026! are still lost.The cryptographic mechanics — why memory-hardness resists GPUs and ASICs, how key-derivation functions are constructed, what the cost parameters actually tune — belong to Guide Nº 19. At this level the practitioner obligations are: use a named, current algorithm from your platform's library; set a cost factor calibrated to roughly 100–250 ms on your production hardware; store the algorithm and parameters alongside the hash so you can raise the cost later; and re-hash on next successful login when you do.
Symptom: a hashPassword() helper containing SHA-256, a hand-written salt, and a loop that runs it "a thousand times for extra security." Why it hurts: the iteration count is orders of magnitude below what a real KDF applies, the construction has never been analyzed, and the code inevitably lacks the parameter-versioning that would let you fix it. Corrective: one library call to Argon2id or bcrypt, parameters stored in the hash string, and a migration path that re-hashes on login. There is no scenario in which a bespoke scheme is the defensible choice in a design review.
A successful password verification is an event: at 18:22:07 on 4 November, credential material for usr_8412 was verified by password plus TOTP. Events do not persist. HTTP is stateless and the next request arrives with no memory of it, so something must convert the event into durable state the server can consult cheaply. That something is the session.
The session is two artifacts that must not be confused. The session record lives on the server and carries everything meaningful: which user, when the login happened, by what method, when it expires, what device and IP first appeared. The session identifier is a random string with exactly one property — it is unguessable — and no meaning whatsoever. It is a name, not a claim. Everything security-relevant hangs off the record, which is precisely what makes the design powerful: because the state is yours, you can end it. Logout deletes the row. "Log out everywhere" deletes every row for that user. Suspending an account invalidates every session in one statement, and the next request from any device drops to 401.
session_id v1.rZ8qXm41pT7wKd93bN2c -- 160 bits from a CSPRNG, base32
user_id usr_8412
created_at 2026-11-04T18:22:07Z -- the login event
auth_method password+totp -- what vouched, and how strongly
last_seen 2026-11-16T09:41:55Z
expires_at 2026-11-18T18:22:07Z -- absolute cap, not just idle timeout
ip_created 203.0.113.44
ua_created Mozilla/5.0 (Macintosh...)Two fields there are doing more work than they look. auth_method records how strongly this session was established, which is what lets you demand step-up authentication later: a session created by password alone may browse the cellar but must re-authenticate to change the account email. And expires_at as an absolute cap — distinct from an idle timeout that keeps sliding forward — is what guarantees a stolen session eventually dies even if the attacker keeps it warm with traffic.
Symptom: sessions with no expires_at, or an idle timeout that renews indefinitely, defended as a UX decision ("users hate logging in"). Why it hurts: a session stolen once is a permanent credential that no password change or MFA rollout revokes, and your incident response has no natural upper bound on exposure. Corrective: an absolute lifetime (14 days is defensible for a consumer product; hours for an admin console) plus an idle timeout plus mandatory re-authentication for sensitive operations, and a real "sign out of all sessions" control the user can reach in two clicks.
The session ID has to ride on every request, and in a browser that means a cookie. Guide Nº 14 covers cookie mechanics in full — scoping, the Domain and Path rules, how SameSite interacts with navigation and cross-site requests. Here we take that vocabulary as given and apply it to the one cookie whose theft is equivalent to account takeover. Here is cellarbook's, in full:
HTTP/1.1 200 OK
Set-Cookie: wc_session=v1.rZ8qXm41pT7wKd93bN2c; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=1209600Read it as a set of denials. HttpOnly denies JavaScript access to the value, which is what converts an XSS from "instant account takeover for every logged-in user" into "the attacker can act within the page but cannot walk away with the session." Secure denies transmission over plaintext HTTP, closing the coffee-shop interception path. SameSite=Lax denies the cookie to cross-site subrequests — a form POST from evil.example to your endpoint arrives without it — which blunts CSRF while still allowing top-level navigation, so a link from an email still lands the user logged in. Max-Age=1209600 (14 days) sets the browser-side expiry, and must be paired with the server-side expires_at: the cookie's lifetime is a hint the client can ignore, while the record's expiry is the enforcement.
Then there is the attribute that is not an attribute: rotation. If the same session ID exists before and after login, an attacker who can plant a value — through a subdomain, an XSS on a sibling app, or a crafted link where the app accepts session IDs from the URL — obtains a session that becomes authenticated the moment the victim logs in. That is session fixation, and the fix is a one-liner with an unforgiving requirement: issue a brand new session ID on every privilege transition — login, step-up authentication, password change, role change — and delete the old record rather than updating it.
Federation and passkeys change how the login event is established. They almost never change what happens afterward: a session row and a cookie. When you read a new authentication protocol, find where it terminates in Figure 2.3 — it is nearly always at the moment the app server writes the session row.
Module 2 ended with a session ID that means nothing on its own: the server looks it up, finds a row, and learns who you are. This module changes one thing — the artifact carries its own meaning and a signature proving nobody edited it — and then follows the consequences, which are larger than they look. Verification becomes a local computation instead of a database read. Any service holding the issuer's public key can authenticate a request without sharing a session store. A request can cross a service boundary carrying its own identity.
And one capability quietly disappears. A session is state you own, so you can delete it; a token is a statement you already published, and publications cannot be recalled. Every technique for revoking tokens — denylists, short expiry with refresh rotation, introspection endpoints — is a way of reintroducing the state you removed, at a point in the architecture where you would rather have it. The honest framing is not sessions versus tokens but where does the state live, and what does putting it there cost.
Two designs, one difference. A session ID is a reference: it points at authority the server holds, and the server dereferences it on every request. A token is a value: it carries the authority in its own bytes and the server verifies rather than looks up. Everything else — good and bad — follows mechanically from that swap.
Verification is local, so any service holding the issuer's public key can authenticate a request without a shared datastore or a network hop; that is the real reason tokens took over microservice and multi-region architectures, not fashion. Verification is also cheap and constant-time-ish, so you shed a database read from the hot path of every request. But the authority is now a copy in the wild, and you cannot edit a copy. If the session row said plan: pro and the customer downgrades, the next request sees the new row; if the token says plan: pro, the token keeps saying it until it expires. A token is a photograph of the truth at issuance time, and it never updates.
Reference versus value is the whole module. Sessions are late-binding: authority is resolved at request time, so changes take effect immediately and revocation is a delete. Tokens are early-binding: authority is fixed at issuance, so verification needs no shared state and changes — including "this person no longer works here" — cannot take effect until expiry.
Here is a real, complete JWT that cellarbook's API issues. Three base64url segments separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJpc3MiOiJodHRwczovL2NlbGxhcmJvb2suYXBwIiwic3ViIjoidXNyXzg0MTIiLCJhdWQiOiJjZWxsYXJib29rLWFwaSIsImV4cCI6MTc5NDc5MDgwMCwiaWF0IjoxNzk0Nzg3MjAwLCJwbGFuIjoicHJvIn0
.3sK1nVQ7bR2fXcM9dLpA0uTgYhZjE6wOsN4iC8vBqKkDecode the first segment from base64url and you get the header, which describes how to verify: {"alg":"HS256","typ":"JWT"}. Decode the second and you get the payload, a JSON object of claims: {"iss":"https://cellarbook.app","sub":"usr_8412","aud":"cellarbook-api","exp":1794790800,"iat":1794787200,"plan":"pro"}. The third segment is not text at all — it is the signature bytes, base64url-encoded, computed over the exact string header_segment + "." + payload_segment.
That signing input is the detail that makes the tamper demonstration work. Suppose an attacker wants to change "plan":"pro" to something else. They edit the JSON, re-encode, and the payload segment's tail changes from LCJwbGFuIjoicHJvIn0 to LCJwbGFuIjoicHJYIn0 — one character, in the middle of a string nobody reads. The signature segment, however, was computed over the old payload segment. The verifier recomputes over what it actually received, gets different bytes, and the comparison fails. There is no partial credit and no way to patch the signature without the key: the attacker can produce a valid-looking token or a token saying what they want, never both.
Symptom: a token payload containing an internal customer risk score, a support note, or an account balance, defended with "it is signed, so it is safe." Why it hurts: base64url is trivially reversible — a browser console, or any of a hundred websites, prints the claims — so every claim is effectively public to the token holder and to anyone who intercepts it. Corrective: put only what the client may see in claims (identity, expiry, coarse role), keep sensitive attributes server-side behind an authenticated lookup, and reach for JWE only when you genuinely need an encrypted token and can accept its key management.
The registered claims are a small vocabulary, and each exists because skipping it produced a real class of bug. iss (issuer) says who minted the token, which is what lets a verifier pick the right public key and refuse tokens from an issuer it does not trust. sub (subject) identifies the principal — and should be an opaque, stable identifier rather than an email, because emails change and get reassigned. aud (audience) names who the token is for; a verifier that skips it will happily accept a token minted for a different service, which turns any service that receives your tokens into an impersonator. exp and iat bound the token in time, and jti (a unique token ID) is what a denylist keys on when you eventually need one.
| Claim | Asserts | Check the verifier must run | Bug when skipped |
|---|---|---|---|
iss | Who signed this | Matches an expected issuer; selects the key | Accepting tokens signed by any issuer whose key you happen to hold |
sub | Which principal | Resolves to a live account | Identity keyed on a mutable email; reassignment becomes account takeover |
aud | Which service it is for | Equals this service's identifier | A token for service A replayed against service B |
exp | When it stops being valid | Now is before exp, with small clock skew | A token that works forever — the only revocation you had |
iat | When it was minted | Not implausibly old or future-dated | No basis for "reject tokens issued before the password change" |
jti | This token's unique ID | Not present in the denylist | Nothing to key targeted revocation on |
Note what exp really is in a stateless design: not a convenience, but the entire revocation mechanism. A stateless verifier's only way to stop honoring a token is to wait for the clock. That is why access-token lifetimes in serious systems are measured in minutes rather than days, and why the next section is about the gap that remains even then.
Three events demand the sentence "no, not anymore": a user logs out, an employee is terminated, or a token is known to be stolen. A session store answers all three with a DELETE. A stateless verifier cannot answer them at all. It holds a public key and a clock; it has no channel through which the news could reach it. Between the moment authority is withdrawn and the moment exp arrives, every service in your fleet continues to honor the token and is behaving correctly by doing so.
Three remedies exist and all three are the same remedy wearing different clothes. A denylist keyed on jti means every verifier checks a shared store — you have reinvented the session lookup, minus the benefits. Introspection means asking the issuer whether a token is still good, which is a session lookup over HTTP. Short-lived access tokens plus a stateful refresh token is the design that actually gets deployed: verification stays local and stateless in the hot path, and the state lives in exactly one place — the refresh endpoint — which every client must visit every few minutes anyway. Revocation deletes the refresh token, and the gap is bounded by the access token's lifetime.
Refresh tokens introduce their own requirement: rotation with reuse detection. Each refresh returns a new refresh token and invalidates the old one; if an old one is ever presented again, that is evidence a copy is in circulation and the entire token family should be revoked. Without this, a stolen refresh token is a permanent credential — the exact failure the design was built to avoid.
The comparison is decided by two questions, and neither of them is "which is modern." First: how fast must revocation take effect, and who will be angry if it is slow — a user, a security team, or a regulator? Second: what is the topology — one application with one database, or many services across regions that would otherwise need to share a session store?
HttpOnly cookie keeps the session design's best property, and nothing about tokens requires giving it up.That last row deserves its own paragraph, because it is the most common self-inflicted wound in the domain. Tokens are frequently stored in localStorage, on the reasoning that a token is an API credential and cookies are for sessions. But localStorage is readable by any JavaScript running on the origin, which means any XSS — including one in a third-party analytics script or a compromised npm dependency — exfiltrates every user's token to an attacker-controlled endpoint. The HttpOnly cookie exists specifically to make that impossible, and a JWT can ride in one perfectly well; you then handle CSRF with SameSite plus a token-in-header pattern, which is a solved and bounded problem, rather than handing your credentials to every script on the page.
Symptom: localStorage.setItem("access_token", …) in the login handler, and an Authorization header attached by an HTTP client interceptor. Why it hurts: one XSS anywhere on the origin — a dependency, a tag manager, a marketing widget — reads every logged-in user's token and exfiltrates it; the token then works from the attacker's machine until it expires, with no session row to delete. Corrective: put the token in an HttpOnly; Secure; SameSite cookie, keep access-token lifetimes short, and defend CSRF explicitly. If a cross-origin API genuinely forces a header, keep the token in memory only (never persisted) and re-acquire it from a cookie-authenticated refresh endpoint on page load.
Everything so far has involved two parties: a human and the service they are talking to. OAuth exists for the case with three. You want a photo-printing service to reach the photos in your cloud drive; you want an accounting tool to read the transactions in your bank; you want an inventory app to post to a merchant's catalog. Before OAuth, the answer was genuinely this: give the third party your password. That is not a caricature — it was standard practice, it was what account-aggregation services did for a decade, and it meant every integration held credentials that opened everything you owned, forever.
OAuth replaces the password handoff with a scoped, revocable grant issued by an authorization server that the third party never gets to impersonate. This module builds the authorization-code flow with PKCE step by step, because it is the flow you will actually encounter, and then spends its last section on the mistake that defines the next module: OAuth answers "may this app touch my stuff?", and an entire industry read the answer as "and therefore, this is Alice."
A valet key opens the door and starts the engine and does not open the trunk or the glovebox. It is the whole of OAuth in one object: a credential that is derived from your authority, narrower than your authority, revocable without changing your own key, and attributable — you know which valet you gave it to.
Four roles carry the flow, and the discipline is to keep them straight. The resource owner is the human who owns the data. The client is the application requesting access — note that the client is the party being constrained, not the party being served. The authorization server authenticates the resource owner, presents the consent decision, and mints tokens; it is the only party that ever sees the owner's password. The resource server holds the data and honors tokens; it never sees a credential of any kind, only a bearer token whose scopes it enforces.
This is a limited power of attorney, and the drafting instincts transfer exactly: enumerate the powers granted, bound them in time, name the agent, make revocation unilateral and effective without the agent's cooperation, and keep the principal's own signature out of the agent's hands. A scope list is the enumerated-powers clause; the token's expiry is the durability provision; the authorization server's grant list is the registry where you go to revoke.
The flow has a shape, and the shape is two channels. The front channel is the browser: redirects, URL parameters, things the user can see and modify and things that end up in history and server logs. The back channel is a direct HTTPS call from the client's server to the authorization server: authenticated, invisible to the user, capable of carrying secrets. The entire design follows from one rule — the front channel cannot keep a secret, so it carries a one-time code, and the code is exchanged for the actual token on the back channel.
Trace it with cellarbook connecting to a merchant's inventory API. Step 1: the user clicks "Connect inventory." Cellarbook generates a random state and a PKCE code_verifier, stores both in the user's session, and redirects the browser to the authorization server with client_id, redirect_uri, response_type=code, scope=inventory.read, state, and code_challenge (the SHA-256 of the verifier). Step 2: the authorization server authenticates the user — its own login, its own MFA, cellarbook sees none of it — and shows a consent screen naming cellarbook and the requested scope. Step 3: on approval, it redirects the browser back to redirect_uri with code and the same state. Cellarbook compares that state against the session copy and aborts if it differs; this is what stops an attacker from injecting their own code into your session. Step 4: cellarbook's server POSTs to the token endpoint with the code, its client_id and client secret, and the code_verifier. Step 5: the authorization server checks that the verifier hashes to the challenge it stored, burns the code, and returns an access token with the granted scope, an expiry, and usually a refresh token. Step 6: cellarbook calls the inventory API with Authorization: Bearer …, and the resource server enforces inventory.read on every request.
code_verifier before any token is issued. Compare lanes with Figure 2.3 — the difference is that authentication has moved into a lane cellarbook does not control.The code exchange assumed something that used to be false: that only the client who started the flow can present the code. On mobile, redirect URIs are custom schemes, and any app on the device could register cellarbook://callback. A malicious app that did so would receive the authorization code, and — since public clients cannot hold a secret in a binary anyone can decompile — could exchange it for a real token. The user did everything right; the credential went to the wrong app.
PKCE (Proof Key for Code Exchange) closes it with one round of cryptographic binding. The client generates a high-entropy random code_verifier, sends only its SHA-256 hash — the code_challenge — on the front channel, and presents the raw verifier at the token exchange. The authorization server hashes what it receives and compares. An attacker who intercepts the code has a value that is worthless without the verifier, and the verifier never appeared on the front channel where it could be intercepted.
Symptom: a server-side web app skips PKCE because it has a client secret, citing guidance written before 2019. Why it hurts: the client secret authenticates the application, not the flow instance; codes still leak through referrer headers, proxy logs, browser history, open-redirect chains on the client's own domain, and mixed-up authorization servers. PKCE binds this specific code to this specific flow, which no client secret does. Corrective: apply PKCE to every client type — the current OAuth 2.1 guidance makes it mandatory — and treat it as complementary to the client secret rather than a substitute for one.
A scope is the enumerated-powers clause of the delegation: inventory.read is a different grant from inventory.write, which is a different grant from account.admin. Scopes are the only place in OAuth where the content of the delegation is expressed, which makes their design a product decision as much as a security one.
Two forces pull against each other, and both are real. Requesting broad scopes is easier to build (one grant covers every future feature) and produces a consent screen that makes users hesitate — "cellarbook wants to manage your entire account" measurably reduces conversion, and rightly so, since it is an accurate description. Requesting narrow scopes converts better and limits blast radius when the client is breached, at the cost of incremental authorization: you must handle the case where the user granted read last year and you need write today. The mature pattern is incremental — request the minimum at connection time, request more at the moment the user asks for the feature that needs it, and explain the ask in context where it is obviously reasonable.
On the receiving side, scope enforcement is the resource server's job on every request, not a one-time check at grant time. A token with inventory.read presented to a write endpoint must fail with 403 — and note that this is the module-1 distinction reappearing: the token authenticated the caller successfully, and the scope check is authorization.
Scopes describe what a client may do, not what a user may do. A token with inventory.write does not mean this user may write to every merchant's inventory — the resource server must still apply its own object-level authorization on top. Teams that treat the scope as the whole authorization decision build exactly the cross-tenant bug from Module 1, with an OAuth-shaped alibi.
Around 2010, developers noticed that an OAuth flow ends with the user having authenticated at a provider they trust, and reasoned: if I get a token back, the user must have logged in, so I can call this "login with Facebook." The reasoning has a hole precisely where it matters. An access token proves that some grant was issued to some client for some user at some point. It is a bearer credential, and it says nothing about who is presenting it right now, or that it was issued to you.
The exploit that followed is worth stating concretely, because it is the argument for the entire next module. Suppose a small app — call it a wine-review site — implements "login with Provider" by accepting an access token from the client, calling the provider's /me endpoint with it, and logging the user in as whatever account comes back. Now suppose an attacker builds any unrelated app, gets users to connect it (a game, a quiz, anything), and thereby collects legitimate access tokens for those users. The attacker takes one of those tokens, submits it to the wine-review site's login endpoint, and the site dutifully calls /me, receives the victim's profile, and creates a session for the victim. No credential was stolen and no protocol was broken. The site simply asked a question the token was never designed to answer.
An access token is a capability, not an assertion. It authorizes the bearer to do something; it does not state who the bearer is, who it was issued to, or when the human authenticated. Identity requires an artifact that is about the authentication event, audience-restricted to the party consuming it, and bound to the specific request that asked for it. That artifact is the ID token — and it is the whole subject of Module 5.
Symptom: a login endpoint that accepts an access_token parameter from a client and calls the provider's userinfo endpoint to decide who is logging in. Why it hurts: tokens are bearer capabilities with no audience binding, so any application that can obtain a token for a user can impersonate that user on your site. Corrective: use OIDC. Take an ID token you received yourself through a flow you initiated, verify its signature, and check that aud is your client ID and nonce matches the one you generated for this request. Never accept an identity artifact handed to you by the client.
Module 4 ended on a hole: OAuth delivers a capability, applications wanted an identity, and the gap between the two was an impersonation bug. OpenID Connect fills it with one additional artifact and a short list of checks. That is genuinely the whole idea — OIDC is a profile on top of OAuth, not a replacement for it, and if you can narrate Figure 4.2 you are one overlay away from narrating "Sign in with Google."
The second half of this module is the same idea sold to enterprises, where it arrived a decade earlier under a different acronym and carries different commercial weight. SAML and OIDC solve one problem — the identity provider vouches to the application — with different envelopes. And the reason a founder should care is not protocol aesthetics: an enterprise buyer cannot deploy software their identity team cannot control and their offboarding process cannot reach, which makes "do you support SSO?" a gating question on deals rather than a feature request.
OIDC adds the ID token: a signed JWT that is about the authentication event rather than about access. Its claims answer the questions an access token could not — who authenticated (sub), who says so (iss), when (auth_time, iat), for which application (aud), and in response to which specific request (nonce). Three additions do the load-bearing work.
First, aud is the relying party's own client ID, which makes the token useless to anyone else. The Module 4 attack — obtain a token through an unrelated app, replay it at a victim site — dies here, because the victim site checks that the token names it and rejects everything else. Second, nonce is a random value the relying party generates before the redirect, stores in the user's session, and requires to appear in the returned ID token; a token captured from another sign-in cannot satisfy it, because it was minted with a different nonce. Third, the relying party obtains the ID token itself, through the back channel, from an issuer it verified — never as a parameter a client handed it.
The openid scope is what requests all of this. Add it to an authorization request and the token response contains an id_token alongside the access token; omit it and you are doing plain OAuth. Additional scopes — email, profile — determine which optional claims the token carries.
The access token is a capability addressed to a resource server: "the bearer may read this." The ID token is an assertion addressed to you: "at 09:14, I authenticated this specific person, in response to the request you started." They travel together and are never interchangeable. Sending an ID token to an API as authorization, or accepting an access token as identity, are the same mistake in opposite directions.
Cellarbook stage 2: adding "Sign in with Google" so users stop inventing another password. Our registered client ID is 732190584416-cellarbook.apps.googleusercontent.com and our registered redirect URI is https://cellarbook.app/auth/google/callback. Here is the entire flow with the values it actually carries.
1. The redirect. The user clicks the button. Our server mints state=af3b1c92d4e7 and nonce=n-8q7Zp3xKd41Vt9, stores both in the pre-login session, and redirects to:
https://accounts.google.com/o/oauth2/v2/auth
?client_id=732190584416-cellarbook.apps.googleusercontent.com
&redirect_uri=https://cellarbook.app/auth/google/callback
&response_type=code
&scope=openid%20email%20profile
&state=af3b1c92d4e7
&nonce=n-8q7Zp3xKd41Vt9
&code_challenge=hK4pQ2wD9vXn…
&code_challenge_method=S2562. Google's lane. Google authenticates the user by whatever means it and the user have agreed on — password, passkey, hardware key, a phone prompt. We see none of it, and that is the point: we have outsourced the hardest, highest-risk link in the chain to an organization with a dedicated abuse team. If the user has an active Google session, this step may be invisible.
3. The callback. Google redirects to https://cellarbook.app/auth/google/callback?code=4/0AY0e-g7…&state=af3b1c92d4e7. We compare state against the session before touching the code.
4. The exchange. Our server POSTs to https://oauth2.googleapis.com/token with the code, our client ID and secret, the redirect URI, and the code_verifier. The response contains an access_token, an expires_in, and — because we asked for openid — an id_token.
5. The ID token, decoded. Its payload:
{
"iss": "https://accounts.google.com",
"azp": "732190584416-cellarbook.apps.googleusercontent.com",
"aud": "732190584416-cellarbook.apps.googleusercontent.com",
"sub": "108234567890123456789",
"email": "marie@fournier-wholesale.example",
"email_verified": true,
"nonce": "n-8q7Zp3xKd41Vt9",
"iat": 1794787200,
"exp": 1794790800
}6. What we do with it. Verify (next section), then look up a cellarbook account by (iss, sub) — not by email. sub is Google's permanent, opaque identifier for this person and never changes; the email can change, and at a workspace domain it can be reassigned to a new employee entirely. Keying on email means that when marie@ leaves Fournier and her address is reassigned, her replacement signs in and lands inside Marie's account. Finally, we do exactly what Figure 2.3 did: create a session row, set wc_session, and hand the browser a cookie. Federation replaced how the login event was established and changed nothing after it.
openid scope and a nonce on the way out, an id_token in the token response, and a verification step before any session exists. Step 9 is Figure 2.3's ending — federation changes how the login event is established and nothing about what happens afterward.An ID token is only as good as the checks you run on it, and each check maps to a specific way applications have been broken. This is the list, and it belongs in code review as a list.
| Check | What you compare | Attack it forecloses |
|---|---|---|
| Signature | Against the issuer's published JWKS, with the algorithm pinned by configuration | Forged tokens; alg: none; algorithm-confusion attacks that verify an RSA token with the public key as an HMAC secret |
iss | Equals a value the issuer documents — for Google, either accounts.google.com or https://accounts.google.com | A token from an issuer you never intended to trust, verified with a key that happened to be in your keyring |
aud | Equals your client ID | Token substitution: a token minted for a different application replayed at yours — the Module 4 bug, resurrected by omission |
exp / iat | Now is within range, small skew allowance | Replay of an old sign-in, hours or days later |
nonce | Equals the value you stored for this request | Replay of a token legitimately obtained from a different sign-in of the same user at your own site |
email_verified | Is true before you trust the email at all | Account takeover by registering an unverified address that collides with an existing account |
Two of those deserve emphasis because they pass silently when wrong. Skipping aud leaves no trace: the signature is valid, the issuer is right, the user is real, and your application is a token-relay impersonation service. Skipping nonce is subtler still, since state covers a related but different attack — state binds the callback to the session, nonce binds the token to the request. An attacker who can capture a token but not manipulate the redirect defeats one and not the other.
Use your platform's certified OIDC library and turn on all of its checks rather than assembling this by hand from a JWT decoder. Every item in that table is a bug someone shipped, usually by reaching for a general-purpose JWT parser that verifies signatures and validates nothing else, because it has no way to know what your client ID is.
aud or nonce produces an application that works perfectly and can be impersonated into.Single sign-on moves authentication out of your product and into the customer's identity provider — Okta, Entra ID, Google Workspace, Ping. Your login page stops asking for a password and starts redirecting to the customer's IdP, which authenticates the employee under the customer's own policy and vouches back to you. Structurally it is the flow you just learned, with the corporate IdP in Google's lane.
What makes it commercial rather than technical is what the customer buys with it. Their security team gets one place to enforce MFA policy, conditional access, and device posture, instead of auditing password policies across two hundred vendors. Their IT team gets one place to grant access when someone joins. And their compliance team gets the thing that actually closes the deal: when someone leaves, disabling one account in the IdP removes access to every SSO-connected application at once — no ticket to your support team, no hoping someone remembered your product exists. An enterprise buyer with an access-review obligation cannot deploy a tool that sits outside that control, which is why "do you support SSO?" appears in security questionnaires before anyone discusses your features.
This is why access reviews are tractable at all. Reviewing entitlements across two hundred applications with local accounts is a spreadsheet exercise that is out of date on the day it is produced; reviewing them through an IdP that provisions and deprovisions centrally turns the control into a query. When you evaluate an application for an enterprise, the question behind "does it support SSO?" is really "can this application be brought inside our identity perimeter, or will it become a permanent exception in every audit?"
Symptom: a sales-driven commitment to ship SSO "in two sprints" for an application whose accounts are keyed on email addresses, where one person means one account, and where account creation is a self-service signup with a password. Why it hurts: SSO is not a login button — it forces an identity model change. You now need an organization concept that owns a domain, accounts that can exist with no password at all, a rule for what happens when an SSO user was already a self-service user, just-in-time provisioning on first login, a way to prevent a former employee from signing in with the local password after SSO is enforced, and an admin-level break-glass path for when the IdP is down. Each of those touches the data model, not the login page. Corrective: if enterprise customers are on the roadmap, model the organization as a first-class entity and separate the identity from the account before you have thousands of email-keyed rows to migrate.
SAML implements the same sentence as OIDC — the identity provider vouches to the application — with an envelope designed in 2005. The vocabulary you need to follow an integration call is short. The IdP is the customer's identity provider. The SP (service provider) is your application. The assertion is the signed XML document asserting who authenticated, the analogue of the ID token. The ACS URL (Assertion Consumer Service) is the endpoint on your side that receives it, the analogue of the redirect URI. Metadata is an XML document each side publishes containing its endpoints and signing certificates, the analogue of OIDC's discovery document and JWKS.
The mechanical difference that matters: SAML delivers the assertion by an auto-submitting HTML form POST from the browser to your ACS URL, rather than by a redirect with a code that you exchange on a back channel. So the security artifact travels through the browser, and everything rests on XML signature validation — which is genuinely harder to implement correctly than JWT verification, and has produced a long line of signature-wrapping vulnerabilities in libraries that parsed the document and validated the signature over subtly different things.
Practically, you support both, and you do not implement either from scratch. If your enterprise prospects run older IdPs or their identity team standardized on SAML years ago, SAML is what they will send you, and "we only support OIDC" is a lost deal rather than a principled stand.
SSO answers "who is this, right now?" It does not answer "does this person still work here, and what should they have?" Those are questions about the directory, and keeping your application's account list in agreement with it is a separate protocol: SCIM, the provisioning standard. The IdP calls your API to create an account when someone joins, update attributes when they change teams, and — the operation everyone forgets until an auditor asks — deactivate the account when they leave.
Without provisioning, SSO produces a subtle and common failure. Login goes through the IdP, so disabling the IdP account does stop new logins. But the account in your application still exists, still holds its data and its API keys, and — depending on how carefully SSO was enforced — may still be reachable by a local password or a live session. "Deprovisioned" and "cannot log in via SSO right now" are different states, and only the first one survives an access review.
Delete the human from the chain and the whole shape changes. There is no one to phish, no one to send a push prompt to, and no one to answer a recovery question — but also no one to notice that something is wrong, and no natural reason for a credential to ever change. Machine credentials are long-lived by default, copied into environments by construction, and read by the twenty engineers who have access to the deployment pipeline.
So the threat model shifts from deception to leakage. The question is no longer "can an attacker trick someone into handing this over?" but "how many places does this string exist, who can read them, and what happens on the day one of those places becomes public?" This module covers the two credential shapes you will actually build with — API keys and client-credentials tokens — and treats rotation as the design problem it is, because a credential you cannot rotate calmly is a credential you will never rotate at all.
In machine authentication the credential is the identity. There is no second factor because there is no one to hold it, no session because a batch job does not browse, and no login event separate from the credential's presentation. Every request re-presents the whole proof.
That collapses the chain to one link, and it moves the entire risk surface onto that link's storage. A machine credential lives in a secrets manager, in a CI pipeline's environment, in a container image layer if someone was careless, in a developer's .env file, in a support ticket where an engineer pasted it, and in your logs if a request-logging middleware records headers. Each copy is a place the credential can leak, and unlike a password, a leaked machine credential produces no anomaly a human would notice: the traffic looks like the integration it always was.
Compensating controls change accordingly. Detection shifts to telemetry — last-used timestamps, source IPs, request-shape anomalies, and secret scanning on every repository push. Containment shifts to blast-radius limits: per-key scopes, per-key rate limits, and IP allowlists where the client has stable egress. And because there is no human to inconvenience, expiry becomes cheap — nobody is woken up when a token rotates at 3 a.m., which is exactly why short-lived machine credentials are easier to adopt than short-lived human sessions.
Human credentials fail through deception; machine credentials fail through leakage. Everything else follows: you cannot train a service, so you shrink the credential's lifetime, narrow its powers, count the copies, watch its usage, and make replacing it boring.
Cellarbook stage 3: partner integrations need programmatic access to the API from Guide Nº 07, so we issue API keys. A key is a bearer credential, so the design questions are the same ones from Module 3 with the human removed. Here is the scheme, with each decision's reason attached.
Format. wck_live_8Kd2mQ7xR4pT9vN3bY6wZ1sJkP4 — a static prefix, an environment marker, then 160 bits of CSPRNG output in base62. The prefix is not decoration: it makes keys greppable by secret scanners (GitHub's push protection and every commercial scanner match on registered prefixes and can notify us within seconds of a public commit), it prevents the classic support-ticket confusion between a test and live key, and it gives us a migration path to wck_live_v2_ if the format ever changes.
Storage at rest. Hash it. The stolen-database threat model from Module 2 does not care whether the credential belongs to a human, so the key is stored as a SHA-256 digest — and here a fast hash is correct, because a 160-bit random key has no guessable structure to grind against, so the slow-hash reasoning that applies to human-chosen passwords does not apply. We store the digest, the prefix, and the last four characters for display (wck_live_…JkP4) so a customer can identify a key in a list.
Show once. The full key is displayed exactly once at creation, with a copy button and a warning. Support cannot read it back, which is a feature, not a gap — the entire social-engineering path "call support and have them read you the key" simply does not exist.
Scope and telemetry. Each key carries scopes (inventory.read, orders.write), an optional IP allowlist, a per-key rate limit, and last_used_at plus last_used_ip updated asynchronously. The telemetry is what makes rotation and cleanup possible: without it you cannot tell whether the three-year-old key is load-bearing or forgotten, so nobody dares delete it.
Symptom: an admin console that displays a partner's full API key, or a support runbook step "look up the customer's key and read it to them." It is usually justified by support convenience. Why it hurts: the key is stored reversibly, so a database breach yields live credentials; and any social-engineering attack that gets past your help desk yields a working key with no login, no MFA, and no session to detect. Corrective: store the digest, show the key once, and make the support path "issue a new key and revoke the old one" — which takes the same thirty seconds and leaves an audit trail.
Symptom: a credential in a committed config file, a notebook, a test fixture, or a CI workflow file — most often in a private repo that later goes public, or in a fork nobody tracked. Why it hurts: git history is forever and public history is harvested within seconds; deleting the file in a new commit removes nothing. Corrective: secret scanning with push protection on every repository, a prefix that scanners recognize, and one immovable incident rule — a leaked key is rotated, never merely deleted from the working tree. Force-pushing history is cleanup, not remediation.
The client-credentials flow is OAuth with the resource owner deleted. There is no user, no consent screen, and no redirect: the service authenticates as itself at the token endpoint with its client ID and secret (or, better, a private key assertion or a workload identity federated from the platform), and receives a short-lived, scoped access token. Every API call then presents that token rather than the long-lived secret.
What you buy is real. Expiry becomes automatic rather than a rotation project — the credential in flight lives fifteen minutes, so a token captured from a log is nearly worthless. Issuance is centralized, so one place knows which services exist, what they may do, and when each last authenticated. Verification is uniform: your resource servers validate tokens the same way whether the caller is a user's session-derived token or a batch job. And on modern platforms the client secret itself can disappear — a workload assumes a role and exchanges a platform-signed identity document for a token, so there is no static credential to leak at all.
What you pay is an authorization server, a token endpoint your partners must call before every burst of work, and an operational dependency: when the token endpoint is unhealthy, every integration stops, including the ones that were only reading. For a single partner posting a nightly inventory file, a well-designed API key with narrow scopes and clean rotation is the honest choice, and pretending otherwise is architecture as costume. For a fleet of internal services, or any environment where credentials cross a trust boundary frequently, client credentials pay for themselves the first time you need to answer "which services can read customer data?" from one place.
Every credential policy says keys are rotated periodically. Most organizations have a key nobody has touched in three years, and the reason is always the same: the first rotation caused an outage, so the second one was never scheduled. Rotation is not a policy problem. It is a design problem, and the design requirement is overlap.
The workable lifecycle has four states. Issued: key B is created alongside the still-active key A, both valid. Migrating: the partner deploys B at their own pace, while you watch last_used_at on A. Drained: A's last-used timestamp has been quiet for a full business cycle — for a nightly job, several days, not several hours — and you have confirmed no scheduled task still holds it. Revoked: A is deleted, and any straggler receives a 401 with an error code that says the key was rotated, so the failure is self-diagnosing rather than a mystery.
Note what makes each step possible: two credentials valid at once (so migration is not a cutover), per-key telemetry (so "is anything still using this?" is a query and not a guess), and self-service issuance (so the partner does not need a support ticket to start). Remove any one and rotation becomes an event that requires a maintenance window, which is the same as saying it will not happen.
These keys authenticate calls to the API from Guide Nº 07, and the two designs should agree: the key identifies the caller, and the API's own authorization rules decide what that caller may touch. Scopes on the key narrow the grant; they do not replace per-resource checks — the Module 1 distinction, one more time.
Every module so far has hardened links to the right of the human. This one goes back to the leftmost link, the one where a person proves they are themselves, and asks a question that sounds simple and is not: what makes one factor stronger than another? Not the textbook taxonomy — something you know, have, or are — which puts a mailed one-time code and a hardware key in the same box while an attacker treats them as different worlds. The operative ranking is by named attack: what specifically defeats this factor, how much does that cost the attacker, and does it scale?
Ranked that way the list stops being a list of options and becomes a ladder with a discontinuity near the top. SMS, TOTP, and push all fail to the same attack — a live proxy that relays whatever proof the user produces — differing mainly in how much work the attacker does. Passkeys do not fail to it, and not because they are newer or the cryptography is stronger. They fail differently because the proof is bound to the origin that requested it, so a proof produced for the wrong site is worthless. That mechanism is the subject of this module's second half, and it is worth understanding in detail, because "structurally cannot be phished" is a claim you should be able to defend rather than repeat.
The classical taxonomy sorts factors into knowledge, possession, and inherence. It is useful for one purpose — noticing that two passwords are not two factors — and useless for the decision you actually face, which is choosing among possession factors that differ by orders of magnitude in real resistance. Rank by the attack instead.
SMS is defeated by SIM swapping: the attacker convinces a carrier's retail or support channel to port the victim's number, and every code follows them. It is defeated more cheaply still by SS7-level interception in some networks, and by any malware with notification access on the handset. The critical property is that the attack targets a third party you do not control and cannot audit — your security now depends on a carrier employee's training.
TOTP removes the carrier: a shared secret provisioned at enrollment generates six digits every thirty seconds, entirely on-device. That kills SIM swapping and every network interception. What it does not touch is a real-time relay — the code is valid for thirty seconds, and a proxy site needs about four.
Push approval improves usability and adds context (device, location, app), and it introduces a new attack surface that no code-based factor has: the approval is a single tap, so the attacker's goal shifts from stealing a secret to obtaining a reflex. Push with number matching — the site displays a two-digit number the user must type into the authenticator — closes most of that gap by requiring the user to be looking at the real login screen.
Hardware security keys and passkeys sit above all of these for one reason, which is not that they are newer: the proof they produce is bound to the origin that requested it, so it cannot be replayed anywhere else. There is no relay attack because there is nothing relayable.
Symptom: an MFA rollout that ships SMS because "everyone has a phone" and treats a compliance checkbox as satisfied. Why it hurts: the account's security now rests on a carrier's retail support process, which you cannot audit, cannot test, and cannot improve; SIM-swap takeovers of high-value accounts are routine and cheap. Corrective: keep SMS as a fallback for users with no alternative if you must, and make sure the strongest enrolled factor is required for sensitive operations — but never let SMS be the strongest available option on an account that holds money, data, or administrative power. A factor you cannot audit should never be the ceiling.
Push bombing needs no cryptography and no phishing page. The attacker has the password already — from a breach corpus, a stuffing hit, a prior phish — and simply logs in repeatedly, firing a push prompt each time. Forty prompts arrive at 2 a.m. Sooner or later a half-asleep person taps approve, or taps it to make the noise stop, or genuinely believes a sync is stuck.
The instructive part is that this is not a user failure in any useful sense. The system asked a human to make a security decision with no information — the prompt says only "someone is signing in" — while the cost of approving is one tap and the cost of investigating is getting out of bed. When you build a control whose safe path is expensive and whose unsafe path is free, you have engineered the outcome, and blaming the user is a diagnosis that produces no fix.
Number matching re-prices the decision at the mechanism level. The login screen displays a two-digit number, and the authenticator asks the user to type it. An attacker who triggered the prompt cannot supply the number, because they are not looking at the victim's screen — and, crucially, the user cannot approve by reflex, because approval now requires information that only exists on the real login screen. Adding context — the app being signed into, the geographic location, the device — helps for the same reason: it converts a content-free interruption into a decision with evidence.
Pair number matching with the operational controls that make the attack visible and expensive: rate-limit push prompts per account per hour, alert on repeated denials, and give the user a "this wasn't me" button that both denies and locks the account. Repeated denials are one of the highest-signal events in an authentication log — a real user with a stuck login retries their own password; they do not deny eleven prompts in a row.
"We rolled out MFA" is often said as though it closed the phishing chapter. It did not, and the reason is mechanical rather than statistical. Modern phishing kits do not present a fake form and harvest what you type; they operate a reverse proxy. The victim's browser talks to the attacker's server, which relays every request to the real site and every response back, in real time.
Follow what the victim experiences. They land on cellarb00k.app, which shows the real cellarbook login page — because it is the real page, fetched live. They enter their password; the proxy forwards it and the real site accepts it. The real site asks for a TOTP code; the proxy shows that prompt; the victim types the code; the proxy forwards it within its validity window. The real site issues a session cookie; the proxy captures it and keeps it. The victim is redirected to their real logged-in dashboard and notices nothing wrong, because nothing visible went wrong. The attacker now holds an authenticated session that the victim's MFA helped create.
Reframe the question. Not "does the user have MFA?" but "can the proof this factor produces be replayed somewhere it was not created?" Passwords: yes. One-time codes: yes, briefly, which is all an online relay needs. Push approvals: yes. Origin-bound signatures: no. That single question sorts the entire factor landscape, and it explains why the next section is about a mechanism rather than a product.
A passkey is a WebAuthn credential, and the mechanism is four moves. Understand them and the phishing-resistance claim becomes a derivation rather than a slogan.
1. Registration mints a key pair, per site. When you enroll, the site sends a challenge and its identifier (the relying party ID, which is the domain). The authenticator — a security key, or the secure element in a phone or laptop — generates a fresh public/private key pair for that domain specifically, keeps the private key, and returns the public key plus a signature over the challenge. The site stores the public key against your account. There is no shared secret: the site holds nothing that could be stolen and replayed, which also means a breach of the site's database yields public keys and nothing more.
2. The private key stays put. It lives in the authenticator's secure hardware and is never transmitted, never displayed, and cannot be typed into anything. There is no value for a user to be tricked into revealing, because there is no value the user could reveal even under coercion.
3. Login is signing a fresh challenge. The site sends a random challenge; the authenticator signs it after a local user gesture (biometric or PIN — which authorizes use of the key locally and never travels to the site); the site verifies the signature against the stored public key. The challenge is new each time, so a captured signature cannot be reused.
4. The signature covers the origin. This is the move that kills phishing. The browser — not the page, not the script, the browser itself — supplies the origin it is actually talking to, and that origin is part of the signed data. The authenticator will not even use a key registered for cellarbook.app when the browser reports the origin as cellarb00k.app; and if it did, the signature would attest to the wrong origin and the real site would reject it. The proxy from Figure 7.2 relays faithfully and the relayed proof is invalid, because it says "I authenticated to cellarb00k.app" — which is true, and useless.
You will not delete passwords on rollout day. Cellarbook's plan is the realistic one, and its final paragraph is the honest one.
Enrollment. After a successful password login, prompt: "Sign in faster next time — and make phishing impossible." One tap, biometric confirmation, done. Do not require it, do not interrupt a purchase flow with it, and do not prompt more than twice — enrollment rates are driven by timing, not by insistence. Support multiple passkeys per account from the start, because a person with a phone and a laptop will register both, and an account with exactly one passkey and no fallback is a lockout waiting to happen.
Login. Passkey becomes the default path with password as a visible alternative. Track the ratio: when passkey logins pass 90% for a cohort, you can begin retiring the password for that cohort, and not before.
Sync, and the objection you will hear. Platform passkeys sync through the vendor's keychain, so the private key exists in a cloud fabric rather than only on one device. Security teams object to this, and the objection is not silly — it is a real change in the trust model, and it makes the platform account a critical dependency. But weigh it correctly: syncing trades a bounded risk (the vendor's end-to-end encrypted keychain, protected by the platform account's own strong authentication) against an unbounded one (an entire class of attack, phishing, ceasing to work). For workforce accounts with high stakes, device-bound hardware keys remain the stricter choice; for consumer accounts, refusing synced passkeys means most users keep their password, which is worse on every axis.
As long as the password path remains and email recovery can mint a session, the account's strength is not the passkey's. An attacker facing a passkey does not attack the passkey — they click "sign in another way," or "forgot password," and walk through a door built in 2019. Your phishing-resistant login is a ceiling; recovery is the floor, and the floor is what an attacker stands on. That is Module 8, and it is the reason this course does not end here.
Seven modules of hardening, and an attacker who has read them all will do none of the work they describe. They will click "forgot password," or call the help desk, or use the account of someone who left in March. The front door is where the engineering went; the side doors are where the incidents come from, and they are side doors precisely because nobody thinks of them as authentication.
This module covers the three edges of an account's life. Signup, where you establish something much narrower than you probably assume. Recovery, which is a complete alternate authentication path that most organizations design as a support feature. And offboarding, where every credential you have issued across seven modules must be found and killed, in systems with different owners. It ends by placing every technology from this course back on the chain from Module 1, with the audit checklist that makes the whole thing operational.
A verified email establishes exactly one fact: at 14:02 on this date, whoever completed this signup could read a message delivered to this address. That is worth having — it makes bulk fake-account creation slightly costly and gives you a channel — and it is far narrower than what product teams routinely build on top of it.
It does not establish personhood: a script with a disposable mailbox clears the bar. It does not establish uniqueness: the same human can hold twenty addresses, and plus-addressing and dot-insensitivity mean they may not even need twenty. It does not establish that the name on the form is theirs. And it does not establish continuing control: mailboxes are abandoned, corporate addresses are reassigned to new hires, and domains lapse and are re-registered by strangers. Every one of those becomes an account-takeover path in a system that treats "verified email" as a durable identity.
Identity proofing — establishing that an account corresponds to a specific real person — is a different and expensive product: document verification, liveness checks, database matching, or an in-person check. You buy it when the stakes require it (financial services, healthcare, anything with a regulatory identity obligation) and you skip it otherwise. The failure to avoid is the middle position: building a system whose consequences assume proofing while its signup performed only email verification.
This is authentication of an exhibit versus proof of its contents. A verified email authenticates the channel — this address exists and someone read it — the way a certified copy authenticates a document without establishing that anything in it is true. Systems get into trouble when a foundational fact quietly widens over time: what was logged as "controls this mailbox" is relied upon three features later as "is this specific person," and no one ever wrote down the substitution.
Here is the sentence to keep: a recovery flow is a complete authentication path, and the account's strength is the weakest path, not the strongest. An attacker choosing between a passkey-protected login and a recovery flow that emails a reset link is not making a hard choice.
So recovery must be designed as authentication — with the same rigor, the same threat model, and ideally the same factors. Concretely: enumerate every path that can produce an authenticated session or reset a credential (password reset, magic link, SMS recovery, backup codes, help-desk assistance, support impersonation tooling, account-merge flows, the "sign in another way" link); rank each against the login it bypasses; and either raise it or accept the account's real strength as being that of the weakest path. Recovery links must be single-use, short-lived, invalidated by any subsequent reset request, and — the step teams skip — a completed recovery must terminate every existing session and rotate every long-lived credential the account holds, because the most common reason for recovery is that someone else is inside.
Three anti-patterns recur, each with a name, a symptom, and a corrective.
Symptom: an account protected by a hardware key that can be recovered by clicking a link emailed to the address on file. Why it hurts: the effective factor is control of a mailbox, and the hardware key is decorative. Corrective: require a second enrolled factor during recovery, or make recovery slow and loud — a delay of 24–72 hours with notifications to every registered channel and the ability to cancel, which converts silent takeover into an event the real owner can stop.
Symptom: mother's maiden name, first school, first car. Why it hurts: these are public facts, not secrets — they appear in public records, social profiles, and prior breaches, and unlike a password they can never be changed after exposure. They are also the one credential a user is encouraged to answer honestly. Corrective: remove them. If a legacy flow requires them, treat the answers as low-entropy passwords: hash them, never use them as a sole factor, and retire them on a schedule.
Symptom: a runbook in which a support agent, satisfied by a caller's knowledge of a birth date and the last four digits of a card, disables MFA or issues a reset. Why it hurts: it is an authentication path whose factor is an agent's judgment under social pressure and a queue-time metric — the documented entry method in several of the largest intrusions of recent years. Corrective: procedure as authentication: dual-channel verification not initiated by the caller, manager confirmation for workforce accounts, a hard prohibition on disabling factors, mandatory logging and alerting on every assisted recovery, and a monthly review of the volume.
Draw every path that can create an authenticated session. Rank them. Whichever is cheapest for an attacker is your account security — everything above it is a ceiling nobody will ever have to reach.
Termination is where the whole course comes due, because by now the account holds artifacts from every module, each living in a different system with a different owner and a different revocation mechanism.
| Artifact | Where it lives | What kills it | Why it gets missed |
|---|---|---|---|
| Password | Your user store or the IdP | Disable the account | Rarely missed — and often the only step taken |
| Live sessions | Session store, per device | Delete all rows for the user | Disabling an account does not cascade unless you built it to |
| Refresh tokens | Token store | Revoke the token family | Access tokens keep working until exp regardless (Module 3) |
| API keys | Your key table, plus their laptop and CI | Revoke by owner | Keys are usually attributed to a team, not a person |
| OAuth grants | Third-party authorization servers | Revoke each grant | Invisible from your admin console entirely |
| Passkeys / device enrollments | The IdP and the user's devices | Delete credentials at the IdP | A personal device keeps a valid credential if you do not |
| SSO grants at vendors | Each SaaS application | SCIM deprovisioning | Only reaches applications actually connected (Figure 5.4) |
| Shared credentials | A team password vault | Rotate them all | Nobody wants to rotate the shared credential, so nobody does |
The row that swallows organizations is the last one. A departing engineer who knew the shared database password has not been offboarded until that password changes, and the change is disruptive, so it is deferred, and deferred, and then it is not on anyone's list. The structural fix is to stop having shared credentials — individual accounts and short-lived, brokered access — because a credential that cannot be rotated on departure is a credential that outlives everyone who ever knew it.
Access reviews exist because offboarding fails silently. Nothing breaks when a departed employee's API key keeps working — there is no error, no alert, no user complaint — so the only mechanism that finds it is a periodic control that asks "who has access, and should they?" and compares the answer to the HR roster. If you have run those reviews, you have seen the failure modes in this table as findings. The engineering lesson is the inverse of the audit lesson: every finding a review produces is a deprovisioning step that should have been automatic, and the review's real output is a list of integrations to build.
Return to Figure 1.1 with everything the course has added. Each link now has a named technology and a named attack, and one path runs around the outside of all of them.
Which leaves the artifact to take away. Ten questions, answerable about any system in an afternoon; each one maps to a module, and each has a right answer you now know.
| # | Question | What a good answer looks like |
|---|---|---|
| 1 | Are authentication and authorization separate layers, with 401 and 403 used correctly? | Two middlewares; the first never reads permissions, the second never reads credentials |
| 2 | What algorithm and cost factor store passwords, and when were they last recalibrated? | Argon2id or bcrypt ≥ cost 12, parameters in the hash, measured within the last year |
| 3 | Quote the session cookie header. Does the ID rotate at every privilege change? | HttpOnly; Secure; SameSite, an absolute cap, and rotation at login and password change |
| 4 | How long until a revoked credential stops working everywhere? | A number, measured, defensible against the strictest obligation the business has |
| 5 | Which checks run on every incoming token — signature, iss, aud, exp, nonce? | All of them, in a certified library, with the algorithm pinned by configuration |
| 6 | Where do machine credentials live, how are they scoped, and when was the last rotation? | Hashed at rest, per-integration scopes, rotated within policy with an overlap window |
| 7 | What is the strongest factor a user can enroll, and the weakest they can log in with? | The gap is small and shrinking; SMS is not the ceiling anywhere |
| 8 | List every path that can produce an authenticated session. Which is cheapest to attack? | The list is complete and the cheapest path is one you chose deliberately |
| 9 | What does a completed recovery invalidate? | All sessions, all refresh tokens, and every long-lived credential the account holds |
| 10 | When someone leaves, what stops working — and how do you know? | Eight artifact classes, automated where possible, with a review that catches the rest |
You can now walk any authenticated request backwards to a human, name what vouches for what at each hop, place any acronym on the link it strengthens, and — the part most teams never get to — find the path around the whole chain. "Sign in with Google" is now a sequence with named parameters, verifiable checks, and a failure mode you can describe. The remaining question, what a verified principal is permitted to do, is Guide Nº 05's.
This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.