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 and a counter . 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 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 , which is . Modulo 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 , the largest a 31-bit value can be. Modulo 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 everywhere.
Adding a clock
TOTP replaces the counter with time. Let be an epoch, conventionally 0, and a step, conventionally 30 seconds. The counter is
where 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, falls in step 1 and must give 94287082. falls in step 37037036 and gives 07081804, and 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. 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 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=30Three 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 , and 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 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 possibilities, which is 19.9 bits, and three of them are valid at any moment, so a blind guess succeeds with probability . That sounds small until you divide: even odds need about 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 , 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 while the server accepts through . 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 , 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 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 bits, and any of the ten works, so the effective target is . Against an attacker with a stolen database, hashing those with SHA-256 at per second gives 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, 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 bits, so the space is , and eight codes match, giving an effective . At 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: 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.