10 min readBy the DependsiT team

Implementing TOTP: a developer's guide from secret to six digits

A developer's guide to implementing TOTP: secret generation, the HMAC pipeline, otpauth enrollment, constant-time checks, rate limiting, and the libraries to use.

Cover card for Implementing TOTP: a developer's guide from secret to six digits

The ticket lands on a Thursday afternoon: "add authenticator-app 2FA before the enterprise demo." The algorithm at the center of that request is a few dozen lines of code and has been an open standard since 2011. Every real-world TOTP failure, though, comes from the parts around those lines — timing leaks in the comparison, unthrottled verification endpoints, secrets sitting in plaintext, enrollment flows that trust a QR code nobody has verified. What TOTP is explained the codes from the user's side. This is the implementation view: how the six digits get made, how to check them without giving anything away, and the short list of things you should never build yourself.

First, check whether you should write this at all

TOTP is table stakes for identity products, so most of them ship it. If your users already authenticate through a broker, the work is configuration, not code. Keycloak's server administration guide walks through authenticator enrollment and OTP policies per realm. Auth0 ships TOTP as one of its multifactor options. Microsoft tenants get it through Entra ID's software OATH tokens, which accept TOTP codes every 30 or 60 seconds from any compliant app. Delegating here is usually the right call — these teams have already survived the edge cases you are about to meet.

And if the actual goal is protecting services you host rather than building a product, self-hosted 2FA covers the settings screens. The rest of this post is for the people who own the login page.

The pipeline from first principles

Everything downstream of enrollment is deterministic, which is why it is testable. The chain has four links.

The shared secret. Generate it with a cryptographic random generator — never a language's default random() — and make it long. RFC 4226 says the secret MUST be at least 128 bits and recommends 160. In practice everyone issues 160 bits (32 Base32 characters) or 256. Encode it in Base32 so it survives being displayed, transcribed, and embedded in a QR code. This secret is the entire ballgame: whoever holds it generates valid codes forever, silently.

The counter. Both sides compute T = (current Unix time − T0) / X, discarding the remainder, where T0 = 0 and X = 30 seconds — the defaults of RFC 6238. The counter becomes an 8-byte big-endian value. Time is the only shared state, which is the whole trick: no synchronization channel, no per-use counter to track. (The counter-based cousin, and its trade-offs, are covered in HOTP vs TOTP.)

The HMAC. Feed the 8-byte counter to HMAC-SHA-1 keyed with the shared secret, producing a 20-byte digest. SHA-1's broken collision resistance does not apply here — HMAC security does not rest on it — and SHA-256/512 exist as optional parameters, but SHA-1 is what every authenticator on earth speaks by default.

Dynamic truncation. RFC 4226's odd little dance: take the low 4 bits of the last byte as an offset, read 4 bytes starting there, mask off the top bit, and you have a 31-bit integer. Modulo 10^6, zero-padded, and those are your six digits.

That is the entire generation story. No network, no server call, nothing expires server-side.

Enrollment is a product problem, not a math problem

The server hands the secret to the user's app exactly once, as an otpauth:// URI — the de facto format documented in Google's Key URI Format guide:

otpauth://totp/Acme:alice%40example.com?secret=...&issuer=Acme

Render that string as a QR code, and also show the Base32 secret for manual entry, because QR scanners fail on cracked screens and dark rooms more often than anyone plans for. Two rules keep enrollment honest:

Verify before you activate. Do not flip the 2FA flag when the user scans the code. Show the QR, then demand a current six-digit code, and only treat the secret as live after it verifies. This catches the mistyped secret, the wrong app entry, and the phone whose clock is three minutes off — before it becomes a support ticket from a locked-out user.

Show recovery codes exactly once. Generate eight or ten single-use codes at enrollment, display them, and store only hashes. They are the designed escape hatch for the lost-phone day, and why 2FA matters is worth remembering here: the second factor that cannot be recovered is the second factor that gets disabled by panic.

Verification is where implementations get breached

The math above is safe. The checking of it is where you can lose the store.

Compare in constant time. A plain === on strings short-circuits on the first mismatched character, so response timing leaks how many leading digits the attacker already has. Rotate through prefixes and you reconstruct the code without ever guessing it whole. Every platform ships a fix: Node has crypto.timingSafeEqual, Python hmac.compare_digest, and the OTP libraries below call them internally.

const a = Buffer.from(submittedCode.padStart(6, "0"));
const b = Buffer.from(expectedCode);
crypto.timingSafeEqual(a, b); // same length, constant time

Accept the neighboring time step. A code typed at second 29 arrives at second 31, and a phone a few seconds off lands one window away. RFC 6238 recommends at most one time step of acceptance beyond the current window, and its resynchronization section suggests recording the drift you observe per token so a persistently-late phone stays usable. Accepting ±1 step is the standard implementation. Accepting ±5 is an attack surface dressed as customer empathy.

Honor one-time use. RFC 6238 is blunt: the verifier MUST NOT accept a second attempt of an OTP after successful validation. Store the last accepted time step per secret and reject anything at or before it. A phished code ages out in seconds — but only if you refuse its replay.

Rate-limit like the code space is small, because it is. Six digits is one million possibilities. An unthrottled endpoint permitting a modest 20 guesses per second gives an attacker even odds on a valid code in under ten hours — no secrets stolen, just arithmetic and patience. Cap attempts per account (a handful per 30-second window), back off or lock after consecutive failures, and enforce the limit before you spend any cycles computing HMACs. This is the difference between a 1-in-a-million shot per try and a certainty by the weekend.

Use a library for the math

The truncation, the Base32 handling, the URI parsing — this is solved code, maintained by people who enjoy it more than you do.

  • Node.js: otplib is the standard, TypeScript-first, actively released, and its authenticator covers generation, URIs, and verification.
  • Python: pyotp — generate, verify, and build provisioning URIs in a few calls.
  • C#: Otp.NET implements RFC 4226 and 6238 with windowed verification built in.
  • Java: a small library like otp-java for the arithmetic, with Spring Security 7's MFA support handling the hard part — @EnableMultiFactorAuthentication and per-factor authorities manage the redirects and enforcement between password and token stages.
  • Django: the django-otp framework provides the OTP plumbing, and django-two-factor-auth builds the full login flow on top of it.

The whole integration in pyotp:

import pyotp

secret = pyotp.random_base32()               # 160-bit, Base32
uri = pyotp.totp.TOTP(secret).provisioning_uri(
    name="alice@example.com", issuer_name="Acme")
ok = pyotp.TOTP(secret).verify(code, valid_window=1)

Five lines at the surface — the library owns truncation and comparison, while rate limiting, storage, and recovery remain your department. That division of labor is the point.

Storage, clocks, and logs

A TOTP secret at rest is worse than a password at rest. A leaked password gets changed and the leak closes. A leaked TOTP secret keeps minting valid codes forever, invisibly, because nothing about it ever touches the network again. Encrypt secrets with an application-level key — envelope encryption with a KMS or at minimum a key held outside the database — so a SQL injection dump yields ciphertext. Never write the secret, the URI, or a submitted code to any log. Every "temporary debug logging" of OTP inputs has eventually been someone's incident report.

Keep servers on NTP, and on the user side, treat "my codes stopped working" as a clock question first — a phone offline for weeks with automatic time off drifts past the acceptance window, and no amount of correct arithmetic fixes a wrong clock.

Testing: the RFC hands you the answers

RFC 6238's Appendix B publishes test vectors with the shared secret set to the ASCII string 12345678901234567890, X = 30, T0 = 0. The published values are eight digits: at Unix time 59 the SHA-1 mode code is 94287082, at 1111111109 it is 07081804, at 1234567890 it is 89005924. Wire those into a unit test with an injected clock — never sleep(30) in a test suite — and your implementation is provably interoperable with everything else that passed the same test.

Then test beyond the happy path: the replayed code after success, the code from two windows back, the user at the window boundary, the lockout after N failures, the enrollment that never verifies. And do one real-world pass — enroll with an actual authenticator app, because a URI that works in your test harness and confuses Google Authenticator is a bug your users will find first.

Frequently asked questions

How long should the TOTP secret be?

RFC 4226 requires at least 128 bits and recommends 160 — issued from a cryptographic random generator, not a general-purpose one. Base32 encoding inflates that to 32 characters for 160 bits, which is what you see in manual-entry forms. Going longer buys little, since the six-digit code itself caps the output entropy. Going shorter, or seeding from something predictable, hands attackers the permanent code-generation capability your whole design is protecting.

Is SHA-1 safe to use in TOTP?

Yes. SHA-1's collision attacks — the reason it was deprecated for certificates — do not threaten HMAC-SHA-1, and TOTP's security does not depend on collision resistance. The RFC's reference implementation supports SHA-256 and SHA-512 as options, and the otpauth URI carries an algorithm parameter, but SHA-1 remains the default every authenticator speaks. Choosing SHA-256 is fine if you control both ends. Requiring it against third-party apps is how you generate "invalid code" support tickets.

How many failed attempts should I allow?

A few per 30-second window, then escalating delays or a temporary lock — RFC 4226 explicitly advises throttling and lockout against online brute force. With a one-in-a-million chance per guess, three tries per window plus a lockout after five consecutive failures turns a sustained attack into a rounding error. The failure mode to avoid is the other one: an unthrottled endpoint where unlimited guessing converts patience into a valid login within hours.

What happens when a user loses their phone?

They use a recovery code from enrollment — one of the single-use codes you hashed and stored — to sign in and re-enroll. When those are gone too, you are into identity proofing: email verification, waiting periods, support contact. Design the recovery path before launch, because "lost authenticator" is your most common 2FA ticket, and a recovery process weaker than the second factor it bypasses cancels out everything above. Rate-limit recovery with the same discipline as verification.

Should I build TOTP from scratch to avoid dependencies?

Not for the cryptography. The pipeline is simple enough to write in an afternoon and subtle enough — dynamic truncation, constant-time comparison, window handling — to get wrong quietly, and the standard libraries are small, audited, and boring in the best way. Write from scratch only as an exercise, test it against the RFC vectors, and then use otplib or pyotp in production. Spend the saved effort on rate limiting and secret encryption, which no library can do for you.

When you do reach the enrollment screen, a second opinion is free: paste the otpauth URI your server just built into the guest generator and confirm the six digits match what your test harness expects — the code is computed entirely in the page, with zero network requests, so a mismatch means your URI, not the tool.

Keep reading