Implementing TOTP 2FA: A Developer's Complete Guide

Adding TOTP two-factor authentication to your application is one of the highest-value security improvements you can make for your users. This guide covers everything you need: the spec, secret generation, QR code setup, code verification, and recovery codes.
Understanding the TOTP Spec (RFC 6238)
TOTP is standardised in RFC 6238. The algorithm:
T = floor(current_unix_timestamp / time_step) // default time_step = 30
TOTP = HOTP(secret, T)
HOTP(secret, counter) = Truncate(HMAC-SHA1(secret, counter)) mod 10^digits
Parameters:
- Algorithm: HMAC-SHA1 (default), SHA-256, or SHA-512
- Digits: 6 (default) or 8
- Period: 30 seconds (default) or 60 seconds
- Secret: Base32-encoded random bytes (minimum 16 bytes = 128 bits)
Step 1: Generate a TOTP Secret
The secret is a random byte string encoded as Base32. Generate it when the user enables 2FA, store it encrypted in your database, and share it once via QR code.
Python
import base64, os
def generate_totp_secret(length=20):
random_bytes = os.urandom(length)
return base64.b32encode(random_bytes).decode('utf-8')
Node.js
const crypto = require('crypto');
function generateTotpSecret(length = 20) {
const bytes = crypto.randomBytes(length);
return bytes.toString('base64')
.replace(/[^A-Z2-7]/gi, '')
.toUpperCase()
.slice(0, 32); // 32 Base32 chars = 20 bytes
}
// Or use a library:
// npm install speakeasy
const speakeasy = require('speakeasy');
const secret = speakeasy.generateSecret({ length: 20 });
PHP
function generateTotpSecret(int $length = 20): string {
$bytes = random_bytes($length);
$base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$secret = '';
$bits = '';
foreach (str_split($bytes) as $byte) {
$bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT);
}
for ($i = 0; $i + 5 <= strlen($bits); $i += 5) {
$secret .= $base32chars[bindec(substr($bits, $i, 5))];
}
return $secret;
}
// Or use a library: composer require robthree/twofactorauth
You can also generate and verify secrets using our browser-based 2FA Secret Generator for quick testing.
Step 2: Create the Setup QR Code
Encode the secret in an otpauth:// URI and generate a QR code. The URI format:
otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30
Example URI
otpauth://totp/MyApp:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp&digits=6&period=30
URL-encode the account name and issuer. Then generate a QR code from this URI using any QR library. Show the QR code to the user once during setup they scan it with their authenticator app.
Also display the raw secret as text for users who can't scan a QR code ("enter manually"). Test your QR codes with our QR Code Decoder.
Step 3: Verify TOTP Codes
When a user submits a 6-digit code, verify it server-side. Accept a window of ±1 time step to allow for clock drift.
Python (using pyotp)
import pyotp
def verify_totp(secret: str, code: str) -> bool:
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1) # ±1 step tolerance
Node.js (using speakeasy)
const speakeasy = require('speakeasy');
function verifyTotp(secret, token) {
return speakeasy.totp.verify({
secret: secret,
encoding: 'base32',
token: token,
window: 1 // ±30 seconds tolerance
});
}
PHP (using RobThree/TwoFactorAuth)
use RobThree\Auth\TwoFactorAuth;
$tfa = new TwoFactorAuth('MyApp');
$isValid = $tfa->verifyCode($secret, $userInput); // returns true/false
Step 4: Generate Backup Codes
Always generate 8–10 single-use backup codes when a user enables 2FA. Store them as bcrypt hashes (like passwords), never in plain text.
// Node.js example
const crypto = require('crypto');
const bcrypt = require('bcrypt');
async function generateBackupCodes(count = 10) {
const codes = [];
const hashes = [];
for (let i = 0; i < count; i++) {
const code = crypto.randomBytes(5).toString('hex').toUpperCase(); // 10 hex chars
codes.push(code.match(/.{1,5}/g).join('-')); // Format: XXXXX-XXXXX
hashes.push(await bcrypt.hash(code, 10));
}
return { codes, hashes }; // Show codes to user, store hashes in DB
}
Step 5: Security Checklist
- Store TOTP secrets encrypted in your database (AES-256), not just hashed
- Use HTTPS exclusively, never transmit codes over HTTP
- Rate-limit TOTP verification attempts (max 5 per minute, lock after 10 failures)
- Prevent code replay: track used codes in a short-lived cache (30-second window)
- Verify the TOTP code on the server never trust client-side validation
- Delete pending TOTP secrets that were never confirmed
- Log all 2FA events (enable, disable, failed attempts) for audit
- Offer backup codes and document account recovery clearly
Recommended Libraries
| Language | Library | Notes |
|---|---|---|
| Python | pyotp | Simple, well-maintained |
| Node.js | speakeasy, otpauth | speakeasy popular; otpauth modern |
| PHP | RobThree/TwoFactorAuth | Most complete PHP implementation |
| Ruby | rotp | Standard Ruby TOTP library |
| Go | pquerna/otp | Full TOTP/HOTP support |
| Java | aerogear-otp-java | Android compatible |
Frequently Asked Questions
Should I implement TOTP myself or use a library?
Use a well-maintained library for the TOTP calculation itself. The algorithm seems simple, but has subtle edge cases (Base32 padding, endianness, timing). Roll your own only if you understand RFC 6238 thoroughly and need custom behaviour.
How do I handle users who lose their 2FA device?
Always implement: (1) backup codes, (2) a manual recovery flow that verifies identity via email + delay, (3) optionally, an admin override for B2B applications. Never allow immediate recovery without identity verification that defeats the purpose of 2FA.
How large should the TOTP verification window be?
A window of ±1 (accepting the previous and next 30-second codes) is standard. This handles typical clock drift. Don't use a window larger than ±2, as it significantly extends the attack window.
Should I force 2FA for all users?
For consumer apps: offer 2FA as opt-in with strong encouragement. For B2B/enterprise apps: mandatory 2FA is increasingly expected and often required by compliance frameworks. Allow admins to enforce it at the organisation level.