What the six digits of a TOTP code actually are

Take a shared secret and the current time divided by thirty. Compute an HMAC-SHA1 of that counter with that secret. Take four bytes from a position the last byte points at, keep the low thirty-one bits, and read the last six digits in decimal. That is the whole of RFC 6238.

Every question you will face about TOTP follows from that description.

Why clock drift matters, and how much to allow

The counter comes from the clock. If the user’s phone is forty seconds off, it computes the code for a different window and nothing matches. Servers therefore accept a window on each side.

One step either side, so ninety seconds in total, is the common compromise. Wider is friendlier and gives an attacker more valid codes at any moment. It is a choice, so make it consciously rather than by copying a snippet.

Six digits is not the security parameter

Six digits give a million possibilities, and a valid code lives for thirty seconds. That sounds weak, and it would be, without the one thing that makes it work: rate limiting.

The strength of TOTP does not come from the code length. It comes from how few attempts you allow. Without a limit, a million guesses against a rested server is not a serious obstacle. With five attempts, the same six digits are perfectly adequate.

Replay, the forgotten half

A code stays valid for its whole window. If somebody reads it over the user’s shoulder, or it appears in a support chat, it can be used again within the same window. Record the last accepted counter per user and refuse it a second time. It costs one column.

The secret is the whole game

The shared secret is symmetric: whoever holds your database can generate valid codes for every user, forever. Encrypt that column, keep the key elsewhere, and treat a leak as a full compromise of the second factor.

OTPHP implements HOTP and TOTP, with the provisioning URI that authenticator applications read from a QR code.