Who Goes There · Nº 15

Who Goes There

A field guide to authentication and identity

No server has ever seen a human. It has seen a cookie that vouched for a session, a session that vouched for a login, a login that vouched for a credential, and a credential that vouched for you. This course walks that chain link by link — passwords, sessions, tokens, OAuth, OIDC, SAML, passkeys, and the recovery flows that quietly bypass all of them — until every acronym answers the same two questions: which link does it strengthen, and which party does it let you stop trusting?

Module 01 Two different questions

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.

Who are you, and what may you do

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.

Cross-reference

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.

The anti-pattern: the auth blob

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.

The chain of vouching

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.

The chain of trust: human, credential, login event, session, cookie or token, request — with the technology that secures each link above it and the attack that breaks each link below itsecured bythe chainbroken byHumanCredentialLogineventSessionCookie ortokenRequestpassword · MFApasskeyhash checksignature checksession storeor signed tokenSet-CookieHttpOnly · SecureTLS · SameSiteOAuth and OIDC splice another party's chain in herephishingstuffing · sprayingfixationsession hijacktoken theftaccount recovery — a parallel path that skips the first three links
Figure 1.1 — The chain of vouching. Each box vouches for the one to its left; the gold labels name the technology that secures a link, the red labels name the attack that breaks it. Security is the minimum over links, not the sum — which is why the dashed recovery path, jumping straight from human to session, can undo every hardening decision to its left.
The load-bearing idea

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.

From your other life

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.

Attacks are broken links

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.

LinkAttackWhat the attacker ends up holdingDefense that actually applies
Human → credentialPhishing, malware keyloggingThe credential itselfCredentials that cannot be relayed (passkeys); not user training alone
Credential → loginCredential stuffing, spraying, offline crackingA valid login for someone else's accountBreach-corpus checks, throttling, slow hashes, a second factor
Login → sessionSession fixationA session the victim authenticates intoRotating the session ID at every privilege change
Session → cookieSession hijacking, XSS exfiltrationA live session, no credential neededHttpOnly, Secure, short expiry, binding and re-auth for sensitive acts
Cookie → requestToken theft, CSRF ridingThe ability to send requests as the userSameSite, 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.

When it misleads

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.

Reading the chain in a real request

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:07Z

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

Flowchart of a request passing through an authentication layer returning 401 and an authorization layer returning 403, contrasted with a conflated middleware that returns 403 for everythingThe two layers, kept apartRequestAuthenticatewho is this?Authorizemay they do this?Handlerprincipal401retry with proof403stop, it will not helpThe auth blobrequireAuth()session + role in one boolean403 for everythingthe client can no longer tell "log in again" from "you may not"
Figure 1.2 — Where the two questions get answered. Authentication resolves a principal or emits 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.

Module 02 Passwords, sessions, and the baseline login

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.

Why passwords persist

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?

Note

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.

The three attacks

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.

Comparison grid of credential stuffing, password spraying, and phishing: what each exploits, its request signature, and the defense that stops itAttackWhat it exploitsSignature in your logsWhat stops itStuffingreuse across sitesthe pair is alreadyknown-good elsewheremany accounts,one attempt each,high success ratebreach-corpus screening+ a second factorlockout helps a littleSprayingcommon passwordsacross a directoryducks per-account limitsmany accounts,few distinct passwords,slow and patientcross-account rate limits+ common-password banlockout: no effectPhishingthe human, liveany proof that can berelayed can be takenno failures at all —correct on first try,invisible to rate limitsorigin-bound credentials(passkeys, m7)training: partial at best
Figure 2.1 — Three attacks, three economics. The three attacks differ in what they exploit, what they look like in logs, and therefore what defeats them. Read the bottom-right cells as the module's warning: the control most teams deploy first (per-account lockout) is ineffective against spraying and invisible to phishing, because phishing generates no failed attempts at all.
The anti-pattern: rate limiting as a cure-all

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.

What secure storage means

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.

Ladder of password storage schemes from plaintext to salted slow hash, annotated with what an attacker holding the database gets and the relative cost per guesswhat the attacker who steals the database getsPlaintextno cracking requiredevery password, instantly, plus every reused password elsewhereUnsalted fast hashMD5, SHA-256 alonethe whole table cracked in one pass; identical hashes reveal shared passwordsSalted fast hashper-row salt, still fastno precomputation, but billions of guesses per second per rowSalted slow hashbcrypt cost 12 · Argon2idthousands of guesses per second: the worst passwords fall, the rest holdbar length = attacker yield
Figure 2.2 — The storage ladder. Each rung changes what a stolen database is worth. The salt removes precomputation; the cost factor removes throughput. Only the bottom rung converts "crack every password by Tuesday" into "crack a handful by next year" — and note that even there, the users whose password was Autumn2026! are still lost.
Cross-reference

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.

The anti-pattern: rolling your own

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.

The login event and the session

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.

The anti-pattern: the immortal session

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 cookie that carries it

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=1209600

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

Swimlane sequence of the baseline password login: browser posts credentials, app server verifies against the password store, creates a session row, returns Set-Cookie, and a later request carrying the cookie is resolved to the session rowBrowserApp serverPassword storeSession storePOST /login · email + passwordfetch hash + salt for usr_8412bcrypt cost 12 · verify → matchinsert row: new 160-bit id, user, expires_at200 · Set-Cookie: wc_session=v1.rZ8q…Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=1209600later — any authenticated requestGET /api/cellar-entries · Cookie: wc_sessionlook up id → row → usr_8412, not expiredprincipal resolved — authorization runs next
Figure 2.3 — The baseline login. Credentials travel exactly once, at login; the hash comparison happens in the password store's lane; the session row is written before the response is sent; and every subsequent request presents only the cookie, which the server dereferences to a row. Every later flow in this course — OAuth in Figure 4.2, OIDC in Figure 5.1, passkeys in Figure 7.3 — is a modification of the left half of this diagram. The right half, session plus cookie, is unchanged in all of them.
The load-bearing idea

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 03 Tokens and the price of statelessness

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.

From reference to value

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.

The load-bearing idea

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.

JWT anatomy, byte by byte

Here is a real, complete JWT that cellarbook's API issues. Three base64url segments separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJpc3MiOiJodHRwczovL2NlbGxhcmJvb2suYXBwIiwic3ViIjoidXNyXzg0MTIiLCJhdWQiOiJjZWxsYXJib29rLWFwaSIsImV4cCI6MTc5NDc5MDgwMCwiaWF0IjoxNzk0Nzg3MjAwLCJwbGFuIjoicHJvIn0
.3sK1nVQ7bR2fXcM9dLpA0uTgYhZjE6wOsN4iC8vBqKk

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

Anatomy of a JWT: three base64url segments with the header and payload decoded beneath them and the signing input bracketed across the first two segmentseyJhbGciOiJIUzI1…eyJpc3MiOiJodHRwczovL2NlbGxhcmJvb2s…3sK1nVQ7bR2fXcM9…headerpayloadsignature..signing input — the two segments and the dot, exactly as received{"alg":"HS256","typ":"JWT"}{"iss": "https://cellarbook.app","sub": "usr_8412","aud": "cellarbook-api","exp": 1794790800,"iat": 1794787200,"plan": "pro"}opaque bytes —HMAC-SHA256 overthe signing inputanyone can read the payload: base64url is encoding, not encryptionedit one character and the recomputed signature no longer matches — tamper-evident, not confidential
Figure 3.1 — Anatomy of a JWT. The header says how to verify, the payload asserts the claims, and the signature is computed over the first two segments exactly as transmitted. Base64url is an encoding, so every claim is readable by anyone holding the token; the signature guarantees only that no one has changed them.
The anti-pattern: "JWTs are encrypted"

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.

Claims and expiry

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.

ClaimAssertsCheck the verifier must runBug when skipped
issWho signed thisMatches an expected issuer; selects the keyAccepting tokens signed by any issuer whose key you happen to hold
subWhich principalResolves to a live accountIdentity keyed on a mutable email; reassignment becomes account takeover
audWhich service it is forEquals this service's identifierA token for service A replayed against service B
expWhen it stops being validNow is before exp, with small clock skewA token that works forever — the only revocation you had
iatWhen it was mintedNot implausibly old or future-datedNo basis for "reject tokens issued before the password change"
jtiThis token's unique IDNot present in the denylistNothing 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.

The revocation problem

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.

Timeline comparing a 24-hour access token with a 10-minute access token plus stateful refresh, showing the window during which a terminated employee's token is still honored24-hour access token, no refresht₀ issuedt₀+2h terminatedt₀+24h exp22 hours: every service still honors the token, correctly10-minute access token + stateful refresht₀ issuedt₀+2h terminatedrefresh token deleted here≤10 minutes: the next refresh fails and access endsthe state you removed from verification reappears at the refresh endpoint — deliberately, in one place
Figure 3.2 — The revocation gap. With a 24-hour access token, withdrawing authority does nothing for 22 hours. Shortening the access token to 10 minutes and putting the long-lived credential behind a stateful refresh endpoint shrinks the gap to the token lifetime — because the refresh call is a database read, and that is the one place you kept state.

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.

Note

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.

Sessions vs. JWTs, honestly

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?

Comparison panel of session-based and JWT-based authentication across where state lives, what logout does, what revocation costs, scaling, and client-side storageServer-side sessionStateless JWTwhere state livesa row you own and can editin the token, in the client's handswhat logout doesdeletes the row — dead everywheredeletes the client's copy —any other copy still verifiescost of revocationone DELETE, effective immediatelyshort exp + refresh rotation,or denylist infrastructurescaling storyevery service needs the store;a read on every requestverify anywhere with the public keyclient-side homeHttpOnly cookie — scripts cannot read itHttpOnly cookie also works —localStorage does not
Figure 3.3 — Sessions vs. JWTs. The trade is not security against convenience; it is late binding against early binding. Sessions resolve authority at request time, so revocation is immediate and every service needs the store. JWTs fix authority at issuance, so verification is local and revocation must be manufactured. Note the last row: a JWT in an 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.

The anti-pattern: tokens in localStorage

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.

Module 04 OAuth 2.0 is delegation, not login

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

The valet key

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.

The four OAuth roles: the resource owner's password reaches only the authorization server, a scoped token flows to the client and on to the resource server, and the path handing the password to the client is drawn as a forbidden dashed lineResource ownerthe human with the wineClientcellarbook.appAuthorization serverauthenticates · consents · mintsResource servermerchant inventory APIpassword + MFA — only ever on this legshared trust in tokensscoped access tokentoken presented · scopes enforcedthe passwordnever travels herethe client is the party being constrained; the resource server never sees a credential, only a token and its scopes
Figure 4.1 — The valet key. The resource owner's password reaches the authorization server and nothing else. The client receives a scoped, expiring token it can present but cannot escalate, and the resource server enforces scopes without ever holding a credential. The dashed red line is the pre-OAuth practice the protocol exists to abolish.
From your other life

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 authorization-code flow, step by step

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.

Swimlane sequence of the OAuth 2.0 authorization-code flow with PKCE across browser, cellarbook client, authorization server, and resource server, distinguishing front-channel redirects from the back-channel token exchangeBrowserCellarbookAuthorization serverResource serverfront channel — visible in the URL bar, never carries a secret1 · click Connect inventorymint state + code_verifier → store in session302 redirect2 · GET /authorize?client_id&redirect_uri&scope=inventory.read&state&code_challenge3 · user logs in at the AS · consents to inventory.read4 · 302 → /callback?code=Sx9f…&state=7c41ea08b2d95 · callback hits cellarbookstate mismatch → abort hereback channel — server to server, authenticated, carries secrets6 · POST /token · code + client_secret + code_verifier7 · access_token · scope=inventory.read · expires_in=36008 · GET /inventory · Authorization: Bearer …200 — scope inventory.read enforced per request
Figure 4.2 — Authorization-code flow with PKCE. The shaded upper region is the front channel: everything there is visible in the URL bar, which is why it carries only a one-time code. The lower region is the back channel, where the client authenticates itself and proves possession of the 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.

PKCE: closing the interception gap

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.

Two-lane comparison of authorization-code interception with and without PKCE, showing the attacker's token exchange succeeding without PKCE and failing at the authorization server with PKCEwithout PKCEcode interceptedattacker POSTs /tokencode onlytoken issued to attackerwith PKCEcode interceptedverifier never sent hereattacker POSTs /tokencode + guessed verifierrejected ✕SHA-256(verifier) ≠ challengethe challenge was published on the front channel; the verifier that produces it never left the client that started the flow
Figure 4.3 — Code interception, with and without PKCE. Without PKCE, possession of the code is sufficient and the attacker completes the exchange. With PKCE, the authorization server demands the preimage of a hash it stored at the start of the flow — a value that only the initiating client ever held — so the intercepted code exchanges for nothing.
The anti-pattern: "PKCE is a mobile thing"

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.

Scopes: the vocabulary of the grant

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.

When it misleads

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.

The category error

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.

The load-bearing idea

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.

The anti-pattern: access tokens as identity proof

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 05 OIDC, SAML, and outsourced identity

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.

One artifact turns delegation into login

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 load-bearing idea

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.

"Sign in with Google," narrated

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=S256

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

The OAuth authorization-code swimlane from Figure 4.2 overlaid with the OpenID Connect additions: the openid scope and nonce on the initial redirect, the ID token in the token response, and the relying party's verification step before the session is createdBrowserCellarbookGoogle (AS + IdP)Session storeaccent = added by OIDCgrey = inherited from Figure 4.21 · click Sign in with Googlemint state + nonce=n-8q7Zp3xKd41Vt92 · GET /authorize?client_id=7321…&state&code_challenge&scope=openid email profile &nonce=n-8q7Zp3xKd41Vt93 · Google authenticates the user — we never see how4 · 302 → /auth/google/callback?code=4/0AY0e-g7…&state=af3b1c92d4e75 · callback · state checked6 · POST /token · code + secret + verifier7 · access_token+ id_token (signed JWT about the login)8 · verify: JWKS signature · iss · aud · exp · noncethen look up the account by (iss, sub) — never by email9 · create session row → Set-Cookie: wc_session (as in Figure 2.3)
Figure 5.1 — OIDC over OAuth: the overlay. Every grey element is Figure 4.2 unchanged. OIDC adds exactly three things, drawn in accent: the 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.

What the relying party must verify

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.

CheckWhat you compareAttack it forecloses
SignatureAgainst the issuer's published JWKS, with the algorithm pinned by configurationForged tokens; alg: none; algorithm-confusion attacks that verify an RSA token with the public key as an HMAC secret
issEquals a value the issuer documents — for Google, either accounts.google.com or https://accounts.google.comA token from an issuer you never intended to trust, verified with a key that happened to be in your keyring
audEquals your client IDToken substitution: a token minted for a different application replayed at yours — the Module 4 bug, resurrected by omission
exp / iatNow is within range, small skew allowanceReplay of an old sign-in, hours or days later
nonceEquals the value you stored for this requestReplay of a token legitimately obtained from a different sign-in of the same user at your own site
email_verifiedIs true before you trust the email at allAccount 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.

Note

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.

The Google ID token decoded, with each claim annotated by the verification check it feeds and the attack that check preventseyJhbGci…eyJpc3MiOiJodHRwczovL2FjY291bnRz…RS256 signatureverify against JWKS{"iss": "https://accounts.google.com","aud": "732190584416-cellarbook…","sub": "108234567890123456789","email": "marie@fournier-…","email_verified": true,"nonce": "n-8q7Zp3xKd41Vt9","iat": 1794787200,"exp": 1794790800}must equal the configured issuermust equal OUR client_id — else impersonationthe account key, with iss — never the emailtrust the email only if this is truemust equal the nonce we stored — else replaynow must be inside iat…expnote what is absent: nothing here grants access to any API
Figure 5.2 — The ID token, decoded. The same token from the walkthrough, with each claim tied to the check it feeds. The two red annotations are the checks that fail silently: with a valid signature, a real user, and a live issuer, omitting aud or nonce produces an application that works perfectly and can be impersonated into.

Enterprise SSO is a revenue question

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.

From your other life

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

The anti-pattern: SSO as a bolt-on

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: the same shape, an earlier era

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.

Comparison panel of SAML and OIDC across artifact, transport, key discovery, typical ecosystem, and what they shareSAML 2.0OIDCthe artifactsigned XML assertionsigned JWT (ID token)transportbrowser form POST to the ACS URLredirect + back-channel exchangekey discoverymetadata XML, certs often pasted by handdiscovery doc + JWKS, rotated automaticallywhere you meet itworkforce SSO, older enterprise IdPsconsumer login, APIs, mobile, modern IdPswhat they sharean IdP vouches to an app · a signed artifact · the browser as courier · the app must validate audience, freshness, and signature
Figure 5.3 — SAML vs. OIDC. Different envelopes around the same letter. The bottom band is the part that transfers: whichever protocol you implement, an identity provider signs an assertion, the browser carries it, and your application is responsible for checking that the signature is valid, the assertion is addressed to you, and it is fresh.

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.

Directory sync: identity's supply chain

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.

Hub topology of enterprise identity: an HR system feeding a corporate identity provider, which federates login to SaaS applications and provisions accounts via SCIM, with one application outside the perimeter where offboarding is manualHR systemjoiners · leaversCorporate IdPone policy · one MFAone place to disablesource of truthCellarbookExpense toolCode hostDesign toollocal accounts onlySAML / OIDC — loginSCIM — create, update, deactivateno federation, no provisioning:offboarding is a ticket someone must remember
Figure 5.4 — The enterprise identity topology. The HR system is the source of truth, the IdP is the hub, and each connected application receives both login federation and lifecycle provisioning. The dashed spoke is the application that never got connected: it works fine, until the day someone leaves and the only control that removes their access is a human remembering it exists.

Module 06 Machines authenticating

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.

No human in the loop

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.

The load-bearing idea

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.

API keys done properly

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.

The anti-pattern: keys you can read back

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.

The anti-pattern: the key in the repository

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.

Client credentials and service accounts

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.

Swimlane comparison of the client-credentials flow, where a service exchanges its credentials for a short-lived scoped token before calling the API, against a raw API key presented directly on every callclient credentialsBatch serviceAuthorization serverCellarbook APIgrant_type=client_credentials · scope=inventory.writeaccess_token · expires_in=900POST /v1/inventory · Authorization: Bearer …raw API keyPartner jobCellarbook APIPOST /v1/inventory · Authorization: Bearer wck_live_8Kd2…one hop, no dependency — and the credential in flight never expires on its own
Figure 6.1 — Client credentials vs. a raw API key. The extra hop buys automatic expiry, central issuance, and uniform verification; it costs an authorization server and a runtime dependency on the token endpoint. Below the line is the honest alternative for a single partner integration: fewer moving parts, at the price of a credential that only rotates when someone decides to rotate it.

Rotation as a first-class operation

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.

State machine of an API key lifecycle with an overlap window: issued, active, overlapping with a replacement while traffic drains, then revoked — contrasted with a revoke-at-issuance path that causes an outage and ends rotation foreverissuedactiveoverlapA and B both validwatch A.last_used_atA revokedissue Bquiet for a full cycleoverlap window ≥ the longest scheduled job's periodthe path that ends rotation foreveractiverevoked at issuancepartneroutageone 2 a.m. page later, the runbook reads "do not rotate" — and the key is still live three years on
Figure 6.2 — Key lifecycle with overlap. Two credentials valid at once turn a cutover into a migration the partner controls, and per-key last-used telemetry turns "is anything still using A?" into a query. The lower path is what produces three-year-old keys: rotation without overlap causes one outage, and the organization learns never to try again.
Cross-reference

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.

Module 07 Strengthening the human link

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.

Factors, and the ranking that matters

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.

Ladder of authentication factors from SMS to passkeys, each annotated with the named attack that defeats it and a bar showing relative attacker costfactorwhat defeats itattacker costSMS codedepends on a carrierSIM swap · SS7 interception ·notification-reading malwareone support callTOTP codeon-device secretreal-time relay through aproxy site (see Figure 7.2)a phishing kitPush approvalone tapfatigue bombing · relay ·2 a.m. judgmentpersistence, no skillPush + number matchtype what you seerelay still works —fatigue does notlive proxyPasskey / hardware keyorigin-bound signatureno relay attack exists —device theft or malware on the devicebar length = what the attacker must spend; the jump at the bottom rung is a change of kind, not of degree
Figure 7.1 — The factor ladder. Ranked by the named attack that defeats each factor rather than by the know/have/are taxonomy. Note that the first four rungs all fall to one attack — a live relay — with the differences amounting to how much effort the attacker spends. The bottom rung does not fall to it at all, which is why the gap between number-matched push and passkeys is larger than the gaps below it.
The anti-pattern: SMS as the only second factor

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.

Fatigue: attacking the approval, not the secret

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.

Note

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.

Why phishing survives MFA

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

Swimlane sequence of an attacker-in-the-middle proxy phish: the victim's password and one-time code are relayed live to the real site and the resulting session cookie is captured by the proxyVictimcellarb00k.app (proxy)cellarbook.app1 · clicks the link in the email2 · fetches the real login page and relays it3 · types the passwordrelayed — the real site accepts it4 · types the 6-digit TOTP coderelayed within 4 seconds — inside the 30-second window5 · Set-Cookie: wc_session=…6 · proxy keeps the cookie7 · victim lands on their real dashboard and notices nothingthe law: any proof that can be relayed can be stolen — the factor's strength is irrelevant if its output travels
Figure 7.2 — The proxy phish. Nothing is forged and nothing is guessed; every credential is real and every check on the real site passes. The attack succeeds because each proof — password, one-time code, push approval — is a value that means the same thing wherever it is presented, so forwarding it works. Note the true prize at step 6: the session cookie, which outlives the login and needs no factor at all.
The load-bearing idea

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.

Passkeys, mechanically

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.

Two-phase swimlane of the WebAuthn ceremony: registration where the authenticator mints a key pair and returns only the public key, and authentication where it signs a challenge together with the origin, plus a dashed lane showing a phishing attempt failing on origin mismatchAuthenticatorBrowsercellarbook.appphase 1 — registrationchallenge + rpId=cellarbook.appkey pair minted for this domainprivate key sealed in hardwareit never crosses this boundarypublic key + signed challenge → stored against the accountphase 2 — authenticationfresh random challengelocal gesture: biometric or PINauthorizes the key — never sentsign( challenge + origin=https://cellarbook.app )verified against the stored public key → session createdthe phishing attemptsign( challenge + origin=https://cellarb00k.app ) → rejected ✕the browser reports the true origin; the signature attests to the wrong site and is worthless
Figure 7.3 — The passkey ceremony. Registration produces a per-domain key pair whose private half never leaves the authenticator, so there is no secret for a site to lose or a user to reveal. Authentication signs a fresh challenge together with the origin the browser is actually talking to, so a relayed proof carries the attacker's domain inside it and fails verification at the real site. Phishing fails structurally, not because the user was careful.

Cellarbook stage 4: passkeys beside passwords

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.

The honest residue

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.

Module 08 The lifecycle nobody designs

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.

Signup: what you actually established

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.

State machine of an account lifecycle from created through verified and active, with a recovering state that returns to active, a suspended state, and a deprovisioned state reached by an edge that is frequently missingcreatedverifiedactiverecoveringsuspendeddeprovisionedemailrecovery: the attack entry point —a second door into activeabuse · non-paymentthe dashed edge is the one most systems never build: without it, accounts never leave active — they are simply forgotten
Figure 8.1 — The account lifecycle. Two transitions carry nearly all of the risk. The pair between active and recovering is a second entrance to the authenticated state, so it must be as strong as the first. The dashed edge to deprovisioned is the one teams never implement, because nothing breaks when it is missing — which is precisely how ghost accounts outlive the people who opened them.
From your other life

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.

Recovery is a parallel login

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.

The anti-pattern: recovery weaker than login

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.

The anti-pattern: security questions

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.

The anti-pattern: the help desk as a bypass

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.

The load-bearing idea

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.

Offboarding: everything must die

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.

ArtifactWhere it livesWhat kills itWhy it gets missed
PasswordYour user store or the IdPDisable the accountRarely missed — and often the only step taken
Live sessionsSession store, per deviceDelete all rows for the userDisabling an account does not cascade unless you built it to
Refresh tokensToken storeRevoke the token familyAccess tokens keep working until exp regardless (Module 3)
API keysYour key table, plus their laptop and CIRevoke by ownerKeys are usually attributed to a team, not a person
OAuth grantsThird-party authorization serversRevoke each grantInvisible from your admin console entirely
Passkeys / device enrollmentsThe IdP and the user's devicesDelete credentials at the IdPA personal device keeps a valid credential if you do not
SSO grants at vendorsEach SaaS applicationSCIM deprovisioningOnly reaches applications actually connected (Figure 5.4)
Shared credentialsA team password vaultRotate them allNobody 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.

From your other life

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.

The chain, complete

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.

The chain of trust from Figure 1.1 with each module's technology annotated on the link it secures, and the recovery flow drawn as a dashed path bypassing credential, MFA, and passkey to reach the session directlytaught inthe chainbroken byHumanCredentialLogineventSessionCookie ortokenRequestm2 passwordsm7 MFA · passkeysm5 OIDC · SAMLm6 machine credsm2 session storem3 JWT · refreshm2 cookie flagsm4 bearer tokensm1 authn then authzproxy phishingstuffing · sprayingfixation · no revocationXSS · localStorageCSRF · token replaym8 recovery · help-desk reset · the door around everything aboveand offboarding is this same chain, run in reverse, in eight systems at once
Figure 8.2 — The chain, with the back door. The same chain as Figure 1.1, now carrying every technology this course taught on the link it secures. The heavy dashed path is the course's thesis in one stroke: recovery reaches the session directly, so an account is exactly as strong as its cheapest path in — and the work of authentication design is finding that path before an attacker does.

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.

#QuestionWhat a good answer looks like
1Are authentication and authorization separate layers, with 401 and 403 used correctly?Two middlewares; the first never reads permissions, the second never reads credentials
2What 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
3Quote 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
4How long until a revoked credential stops working everywhere?A number, measured, defensible against the strictest obligation the business has
5Which checks run on every incoming token — signature, iss, aud, exp, nonce?All of them, in a certified library, with the algorithm pinned by configuration
6Where 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
7What 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
8List 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
9What does a completed recovery invalidate?All sessions, all refresh tokens, and every long-lived credential the account holds
10When someone leaves, what stops working — and how do you know?Eight artifact classes, automated where possible, with a review that catches the rest
Where this leaves you

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.

Concept index

Authentication
Establishing who is making a request — distinct from what they may do.
Authorization
Deciding what an authenticated principal may do; Guide Nº 05's subject.
Chain of trust
The sequence of delegations (human, credential, login, session, request) every authenticated request rests on.
Credential
The thing presented to prove identity — password, key, signed assertion, or possession of a device.
Credential stuffing
Replaying username and password pairs from other sites' breaches, exploiting reuse.
Password spraying
Trying a few common passwords across many accounts to stay under lockout thresholds.
Salted slow hash
Password storage that makes each stolen hash expensive to crack (bcrypt, scrypt, Argon2 with a cost factor).
Session
Server-side state created by a login event and referenced by an unguessable ID.
Session fixation
An attack where a pre-login session ID survives login, letting whoever planted it ride the authenticated session.
Session cookie
The HttpOnly, Secure cookie that carries the session ID; attributes per Guide Nº 14.
JWT
A signed, base64url-encoded token whose claims are readable by anyone and alterable by no one.
Claim
A single asserted fact inside a token (iss, sub, aud, exp), each feeding a specific verification check.
Bearer token
A token that authenticates whoever presents it — possession is the whole proof.
Revocation
Making an issued credential stop working before its natural expiry; statelessness's known price.
Refresh token
The long-lived, stateful credential exchanged for short-lived access tokens — where revocation actually lives.
OAuth 2.0
A delegation protocol: a user grants an app scoped access to their resources without sharing their password.
Authorization code
The one-time front-channel artifact exchanged on the back channel for tokens.
PKCE
A verifier and challenge pair binding the authorization code to the client that started the flow.
Scope
The vocabulary of a grant — what the delegation does and does not permit.
Front channel / back channel
Browser-visible redirects vs. server-to-server calls; secrets travel only on the back channel.
OIDC
The identity layer on OAuth: an ID token that makes the authentication event itself the deliverable.
ID token
A signed JWT about a login event — who, when, how, for which client — verified with iss, aud, exp, and nonce checks.
Nonce
A per-request value bound into the ID token so a captured token cannot be replayed into another session.
SAML
The enterprise-era federation protocol: signed XML assertions from an IdP, delivered by browser POST.
IdP / SP
The identity provider that vouches and the service provider that relies — federation's two roles.
SCIM
The provisioning protocol that keeps app accounts in sync with the directory — including deprovisioning.
Client-credentials flow
The OAuth grant with no human: a service authenticates as itself for a short-lived, scoped token.
TOTP
Time-based one-time codes from a shared secret — stronger than SMS, still relayable by a live phish.
Passkey
A WebAuthn credential: a per-site key pair whose private key never leaves the authenticator and whose signatures bind the origin.
Deprovisioning
Killing every credential and grant an identity holds — sessions, tokens, keys, SSO — when it offboards.

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.