OTP Cryptography Internals · Chapter 1 of 5
Shared secrets and the one-time property
What a one-time password actually proves, why both sides must hold the same key, and where the “one-time” guarantee really comes from.
A one-time password is the visible end of a very small cryptographic protocol. Two parties — a verifier (your server) and a prover (an authenticator app, a hardware token, a secure element in a phone) — share a secret key. Each time the prover wants to authenticate, it combines that key with a value that moves forward, such as a counter or the current time, and compresses the result into a short decimal string. The verifier repeats the same computation and compares. If the strings match, the verifier concludes that whoever typed the code had access to the key at roughly the right moment.
That conclusion is narrower than most product specifications assume. A matching code does not prove who the person is, does not prove they are on the device that was enrolled, and does not prove they are typing into your website rather than a copy of it. It proves knowledge of an output that only the key holder could compute for this moving value. Everything else — identity, intent, origin — has to come from somewhere else in the system. Keeping that boundary sharp is the first habit this series tries to build.
Why a shared secret and not a key pair
HOTP (RFC 4226) and TOTP (RFC 6238) are symmetric schemes. Both sides hold the same bytes. The verifier therefore has the power to generate valid codes too, which means the verifier’s database is as sensitive as the users’ devices. If an attacker dumps your table of seeds, they can mint codes for every enrolled user forever, without touching a single phone. Asymmetric schemes such as WebAuthn, covered in series 2, avoid this by letting the server store only a public key.
The symmetric design was a deliberate trade-off in the mid-2000s. Hardware tokens had to be cheap, run on a coin cell for years and display a number on a tiny LCD. HMAC-SHA1 over eight bytes is trivial for such a device, and a six-digit display needs no input channel at all. Two decades later the same arithmetic runs in authenticator apps, and the trade-off still holds: no network, no enrolment server round trips after setup, and codes that work in airplane mode.
Where “one-time” comes from
Nothing in HMAC makes a code single-use. The output for a given key and moving factor is deterministic: compute it twice and you get the same digits twice. The one-time property is entirely a verifier policy. For HOTP, the server stores the last counter it accepted and refuses anything at or below it. For TOTP, the server must remember which time step it last accepted for that user and refuse a second login with the same step — otherwise a code shoulder-surfed at 09:14:05 is still perfectly valid at 09:14:25.
This is the most commonly skipped line in real implementations. Libraries ship a function shaped like verify(secret, code) that returns a boolean and holds no state. Calling it is not enough. Chapter 5 builds the replay cache that turns a stateless check into an actual one-time password, and it is worth reading that chapter before you ship anything.
The threat model in four lines
- Guessing: six digits give one million possibilities; online attempt limits, not the math, keep this safe.
- Seed theft: a leaked server table or a cloned device lets the attacker generate codes indefinitely.
- Replay: an observed code reused inside its validity window; defeated only by verifier state.
- Real-time phishing: the user types a valid code into an attacker’s page, which relays it within seconds. No OTP scheme stops this; series 2 explains why.
Guessing is the threat people worry about most and the one that matters least once rate limits exist. With five attempts per code and a fresh code per attempt cycle, a blind attacker wins with probability five in a million per cycle. Seed theft and phishing are the threats that actually produce incidents, and neither is addressed by making the code longer.
A worked example from the bench
Consider Amara Quist, a payments engineer at a fictional company called Larkspur Ledger. She enrols her phone on 2026-09-21 at 18:42:10 UTC. The server generates twenty random bytes, stores them encrypted under a key held in a separate service, and renders a QR code. Her app stores the same twenty bytes. From that moment on, neither side talks to the other about codes. When Amara signs in the next morning, both sides independently compute a code for the current thirty-second window; they agree because they share the same bytes and the same clock, give or take a few seconds.
The interesting engineering is everything around that agreement: how the bytes were generated, how they were encoded into the QR code, how much clock disagreement the server tolerates, and what it remembers after accepting a code. Each of those is a chapter in this series.
// The whole family, in one line of pseudocode:
otp = Truncate(HMAC(K, movingFactor)) mod 10^digits
// HOTP: movingFactor = counter (8 bytes, big-endian)
// TOTP: movingFactor = floor((unixTime - T0) / step)Before moving on, notice how little of this is about cryptography in the narrow sense. HMAC-SHA1 has no practical weaknesses in this construction; collision attacks on SHA-1 do not affect HMAC’s use as a pseudorandom function. The engineering risk lives in storage, encoding, clocks and state. That is where the rest of the series spends its time.
One more distinction is worth making now. HOTP and TOTP codes are derived by the user's own authenticator. Codes your server generates at random and sends to the user are a different family with different engineering problems, and this series does not cover them. Everything here assumes the code is computed on the user's side from a shared seed.
// bench check
0/3 correct · 0 answered
1. A stateless
verify(secret, code)returns true. What has been established?2. Why is a leaked table of TOTP seeds worse than a leaked table of password hashes?
3. Which threat does a longer code (8 digits instead of 6) meaningfully reduce?