← All posts
2026-09-14
SECURITY
4 min

Forty lines of TOTP, and everything around them

The algorithm fits on one screen. Encryption at rest, replay protection, attempt limits and recovery codes are where the real decisions are.

Both the passkey demo and this site's admin accept a six-digit code from an authenticator app, and in both, RFC 6238 is implemented directly rather than taken from a package. It comes to about forty lines. Those lines are not where the decisions are.

The forty lines

TOTP is HOTP with a clock. Divide the Unix time by 30 to get a step counter, HMAC it with the shared secret, use the low four bits of the last byte as an offset to pick four bytes out of the result, clear the top bit, and take the number modulo a million.

The danger in writing that yourself is not complexity. It is that a one-digit mistake in the truncation step still produces plausible six-digit codes. They are just not the codes the phone shows. So the implementation is checked against the published test vectors in RFC 6238 Appendix B, for SHA-1, SHA-256 and SHA-512, and the base32 decoder against RFC 4648 section 10. When those pass, the forty lines are right, and the rest of the work is everything around them.

Encrypt the secret, because you cannot hash it

A password is stored as a slow hash, because checking one only requires recognizing it. A TOTP secret cannot be treated that way. The server needs the original bytes at every sign-in to compute the expected code. So it is encrypted at rest with AES-256-GCM, under a key that lives in an environment variable rather than in the database next to what it protects.

Development uses a fixed key and prints a warning saying so. In production a missing key is an error, not a fallback. And the key cannot be rotated on a whim: the moment it changes, every enrolled secret becomes unreadable.

A code works once

A code belongs to a 30-second step, and verification accepts one step either side as well, to allow for clock drift and a code typed just as it rolled over. That makes a window of about a minute and a half in which a code read over someone's shoulder still works. So the server records the step of every code it accepts and refuses any code from that step or earlier. The same code cannot be used twice, and neither can an older one.

A million is not many

Six digits is one in a million, and every code stays valid for 90 seconds. Against an endpoint with no limit, that is guessable. In the demo, five wrong codes lock the account for fifteen minutes.

The passkey routes deliberately have no such limit. A signature cannot be guessed, so throttling there buys no security and gives anyone a way to lock a real user out.

Recovery codes are the opposite case

Recovery codes are for when the key and the phone are both gone. There are ten, each usable once, shown once. The alphabet leaves out I, L, O and U so nothing is misread when it is copied onto paper.

Unlike the TOTP secret, these are hashed, because checking one only requires recognizing it. And plain SHA-256 is the right hash here, not scrypt or Argon2. Slow hashes exist to protect guessable human passwords from a dictionary attack. These are 50-bit random strings, and there is no dictionary. A fast hash means a submitted code is found with one indexed lookup, instead of running a slow function against every stored code in turn. A spent code is refused with exactly the same message as a wrong one, so the endpoint never confirms that a guess was once real.

An alternative, not an extra step

In the demo, the app and the recovery codes are alternatives to the passkey, not a second factor stacked on top of it. A security key with a PIN already proves something you have and something you know, and a code on top would add friction without adding much security. In this site's admin, where the first factor is a password, the code is the second step. The same forty lines sit in a different place in the flow, because what comes before them is different.

Written by Martin Dahl. If you want to argue about any of it, say hello.