You shipped a login form, added bcrypt for passwords, and called it a day. Then your users started getting their accounts hijacked through credential stuffing — because someone dumped a database from a different site and 60% of your users reused passwords. TOTP-based 2FA is the fastest, most widely-supported fix for this, and it's far simpler to implement than most devs expect.
How TOTP Actually Works
TOTP (RFC 6238) is HMAC-based. The algorithm takes your shared secret and the current Unix timestamp divided by 30, runs HMAC-SHA1 on it, and extracts a 6-digit code. Both the server and the authenticator app do this independently — if they match, auth succeeds.
The critical thing to understand: the secret is shared once (during setup) and never transmitted again. After setup, authentication is purely computational on both sides.
TOTP — time-based one-time code
Step 1: Generate the Secret
Each user gets their own secret. It should be at least 160 bits (20 bytes) of cryptographically random data, base32-encoded. Don't use a UUID, don't derive it from the user ID.
Store the secret encrypted at rest. If your database leaks and secrets are plaintext, attackers can generate valid codes forever. Use AES-256 with a key stored outside the database (environment variable or secrets manager).
Step 2: Build the QR Code URI
Authenticator apps expect a specific URI format:
otpauth://totp/{issuer}:{account}?secret={SECRET}&issuer={issuer}&algorithm=SHA1&digits=6&period=30
Step 3: Verify the Code on Login
After the user sets up 2FA, every login requires: password check (as normal) → TOTP code check. Never skip the password step.
The Gotchas That Will Bite You
Code replay attacks. A valid TOTP code is good for 30 seconds. If you don't track used codes, an attacker who intercepts the code can reuse it within that window. Store the last successfully used code (or timestamp+counter) in your session or database and reject a second use within the same period.
Clock skew on your server. TOTP is time-based. If your server clock drifts even 60 seconds, all your users' codes will fail. Run NTP. This is embarrassing to debug in production at 2am.
Displaying the raw secret. Some developers show the base32 secret as text alongside the QR code (good — users need it for manual entry). But that screen is a gold mine if screenshotted. Add a warning, consider hiding it after initial setup, and absolutely don't log it.
Storing the secret in the same column as the password. If you're using per-column encryption, the TOTP secret and password hash should be separate secrets. A misconfiguration that exposes one shouldn't expose the other.
Not verifying setup before enabling. Always require the user to enter a valid code before you flip the totp_enabled = true flag on their account. If you enable it before they've scanned (e.g., you store the secret on "next" button click), they get locked out immediately.
Backup Codes
Generate 8–10 single-use backup codes at setup time. Hash them with bcrypt before storing (treat them like passwords — they're high-entropy but still sensitive). When a user enters one, check it, then delete it. Display them only once, and prompt the user to save them.
TOTP 2FA is a weekend project that meaningfully raises the cost of account takeover. The implementation is well-understood, the libraries are mature, and the UX is familiar to most users. The parts that go wrong in production are almost always: unencrypted secrets in the DB, missing code replay protection, and clock drift. Fix those three things and you've got a solid implementation.
Generate your TOTP secret keys using a cryptographically secure source — the EncryptCodec Secret Generator gives you properly formatted random secrets without any server-side storage.