Adding TOTP-based two-factor authentication to your app is one of the most impactful security improvements you can make. This guide covers everything from generating secret keys to verifying codes and handling edge cases โ with a focus on doing it correctly the first time.
In This Guide
How TOTP Works (What You're Building)
TOTP generates 6-digit codes by combining a shared secret key with the current Unix timestamp, divided into 30-second windows. Both your server and the user's authenticator app independently calculate the code for the current window using the same key โ if they match, authentication succeeds.
The flow: (1) you generate a secret key and store it for the user, (2) the user scans a QR code to add it to their authenticator app, (3) on every login, you ask for their current code and verify it against the key. Simple in concept; a few important details in practice.
Step 1: Generate a Secret Key
Each user needs a unique, cryptographically random secret key in base32 format. Generate 20 random bytes (160 bits) and base32-encode them โ this gives you a 32-character key. Never derive keys from user data or timestamps.
In Python with pyotp: secret = pyotp.random_base32(). In Node.js: const secret = speakeasy.generateSecret({length:20}).base32. Or use our TOTP Secret Key Generator for testing.
Store this key encrypted in your database, associated with the user's account. Treat it with the same sensitivity as a password. Don't log it.
Step 2: Display the Setup QR Code
Users set up TOTP by scanning a QR code with their authenticator app. The QR code encodes an otpauth:// URI in this format: otpauth://totp/YourApp:user@example.com?secret=BASE32SECRET&issuer=YourApp.
Key parameters: secret is the base32 key. issuer is your app name (shown in the authenticator). The label (YourApp:user@example.com) identifies the account. Always include both issuer and a descriptive label โ without them, users won't know which entry in their authenticator belongs to your app.
You can preview and test URI generation with our OTPAuth URI Builder. For production: generate the QR server-side using a library like qrcode (Python) or qrcode (npm), or use a trusted client-side library.
Important: After the user has scanned the code, require them to enter their first valid TOTP code before marking 2FA as enabled. This confirms setup succeeded.
Step 3: Verify the Code
On each login, after the user enters their 2FA code, look up their stored secret key and verify the code. Most TOTP libraries accept a window parameter โ allow ยฑ1 window (30 seconds either side) to account for clock differences between server and client.
Python: pyotp.TOTP(secret).verify(user_code, valid_window=1). Node: speakeasy.totp.verify({secret, encoding:'base32', token:userCode, window:1}).
Rate limiting is essential. TOTP codes have 10^6 possibilities but only 3 valid codes at any moment (previous, current, next window). Without rate limiting, an attacker can try all possible codes in seconds. Limit to 5 attempts per 15 minutes per account.
Prevent code replay. Once a code has been used successfully, store it (with its timestamp) and reject it if used again within the same window. This prevents an attacker who intercepts a valid code from using it a second time.
Step 4: Store Everything Securely
The TOTP secret key must be stored encrypted at rest. If an attacker gets your database and the keys are unencrypted, they can generate valid codes indefinitely. Encrypt with AES-256-GCM using a key derived from your application secret โ not the user's password, since users can change passwords.
Store a flag indicating whether 2FA is enabled for each user, and the timestamp when it was enabled. Also store the last used code (for replay prevention) and the user's recovery/backup codes as bcrypt hashes โ never in plain text.
Step 5: Backup Codes
Users will lose their phones. You must provide backup codes โ one-time codes they can use to access their account if they can't use their authenticator. Generate 8โ10 random codes, each 8โ10 characters, when the user enables 2FA. Show them once and instruct the user to save them securely.
Store the backup codes as bcrypt hashes (not plain text). On use, mark the code as used. Once all backup codes are used, prompt the user to generate a new set.
See: 2FA backup codes explained for the user-facing perspective on this.
Common Implementation Mistakes
Skipping clock tolerance: Not allowing ยฑ1 window will cause legitimate failures when user or server clocks are slightly off. Always use a 1-window tolerance.
No rate limiting: A fundamental security hole. Implement it before launch.
No replay protection: A code used once should not be usable again.
Plain text backup codes: If your database leaks, plain text backup codes are immediately usable. Always hash them.
Storing secrets unencrypted: Encrypt TOTP secrets at rest. If your database leaks, unencrypted secrets let attackers generate codes forever.
No confirmation step: Requiring users to enter their first code before enabling 2FA catches setup failures before the user is locked in.
Threat Model: What TOTP Protects Against โ and What It Doesn't
TOTP 2FA raises the bar for a specific set of attacks: credential stuffing, where attackers replay leaked email/password pairs across hundreds of services; password spraying, which tries common passwords against many accounts; and phishing that collects only passwords. In all three cases the attacker ends up with a password but no code, and the login fails. Telemetry from Microsoft and Google shows that accounts with any second factor block the overwhelming majority of automated takeover attempts.
The gaps matter too. A real-time phishing proxy can relay your TOTP code to the real site within the 30-second window, so a targeted phish can still defeat app-based 2FA. Malware running on the user's device can read the code as it is typed. And an attacker who already controls the user's session cookie does not need your 2FA at all โ they simply stay logged in.
None of this is an argument against TOTP; it is an argument for knowing what you are buying. TOTP is a strong, low-cost layer that stops the attacks most likely to hit your users.
The Complete Login Flow, Step by Step
Walk through the flow once, from the user's perspective, so the pieces fit together. The user opens your login page, enters email and password, and your server verifies the password and loads the user's stored TOTP secret. If 2FA is enabled, the server responds with a prompt for a 6-digit code instead of creating a session.
The user opens their authenticator app, reads the current code, and submits it. Your server computes the expected codes for the current, previous, and next 30-second windows using the stored secret and compares them with a constant-time comparison. On a match, you mark the code as used, create the session, and optionally set a trusted-device cookie so the user is not prompted again on that browser for 30 days.
On a mismatch, you increment a per-account failure counter and return a generic error. Whether the password or the code was wrong, the message should be identical, so attackers cannot use the error text to confirm they guessed the password correctly. After five failed attempts, block further attempts for 15 minutes and notify the account owner by email.
Testing Your TOTP Implementation Before You Ship
A 2FA feature that works in staging can fail for real users in production, so build a small test matrix before launch. Test at least three authenticator apps โ Google Authenticator, Authy, and 1Password โ because they handle the otpauth URI and code display slightly differently. Include one device with a deliberately wrong clock to confirm your ยฑ1 window tolerance actually tolerates.
Test the edge cases, not just the happy path: entering the same code twice (must be rejected as replay), using a backup code after some have been consumed, disabling 2FA and re-enabling with a new secret, and what happens when a user with 2FA enabled has forgotten their password. Each of these paths is a support ticket waiting to happen if it is not exercised before launch.
Automate the cryptographic core with unit tests: generate a secret, produce codes with a reference implementation, and verify that your code accepts the current window and rejects codes from ten minutes ago. The math behind TOTP is simple enough to cover fully in an afternoon, and it is the part that cannot be fixed later after a bad deployment.
Handling Lockouts: The Support Workflow
However carefully you implement 2FA, some users will lose their authenticator. Decide the recovery policy before launch: the most common approach is requiring a backup code, with a manual verification path for users who lost those too. If you allow support agents to bypass 2FA, require at least two independent proofs of identity (for example, email confirmation plus a recent transaction or government ID) and log every bypass with an audit trail.
Give users a way to see when 2FA was enabled and which device last logged in, and send an email notification whenever 2FA settings change. Most lockouts are noticed within the first hours, and a well-timed "2FA was disabled on your account" email is often what saves an account while a takeover is still in progress.
Finally, make re-enrolment cheap. After recovery, force a new secret and a fresh set of backup codes before the user leaves the page, and show the backup codes again even if the user previously saved them. Users who skip this step are the ones who return a month later with the same problem.