Building 2FA Into Your Web App: Beyond the Basics

Implementing TOTP technically (see our TOTP implementation guide) is the easy part. The harder challenges are the UX flows, the edge cases, and the security architecture that makes the feature bulletproof in production. This guide covers the full picture.
The Authentication Flow Architecture
Standard Login with 2FA
1. User enters username + password
2. Server validates password
3. If 2FA is enabled: create a short-lived session with flag "needs_2fa"
4. Redirect to 2FA challenge page (do NOT fully authenticate yet)
5. User enters 6-digit code
6. Server validates code
7. If valid: upgrade session to fully authenticated
8. If invalid: increment attempt counter, return error
Critical: Never grant full access after password verification if 2FA is pending. Use a two-phase session approach.
Database Schema
-- User 2FA settings
ALTER TABLE users ADD COLUMN totp_secret VARCHAR(64) NULL; -- encrypted
ALTER TABLE users ADD COLUMN totp_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN totp_confirmed_at TIMESTAMP NULL;
-- Backup codes
CREATE TABLE backup_codes (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id),
code_hash VARCHAR(255) NOT NULL, -- bcrypt hash
used_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT NOW()
);
-- Rate limiting
CREATE TABLE totp_attempts (
user_id INTEGER REFERENCES users(id),
attempted_at TIMESTAMP DEFAULT NOW(),
ip_address INET
);
The 2FA Setup Flow
- Generate secret — server-side, never client-side
- Store temporarily — in the session or a pending_totp table, NOT in the users table yet
- Show QR code + manual entry option — always provide both
- Require code confirmation — user must enter a valid code before 2FA is activated
- Move secret to permanent storage — only after successful confirmation
- Generate and display backup codes — show once, require acknowledgment
- Invalidate any existing sessions — force re-login to ensure 2FA is applied
Security: Rate Limiting and Replay Prevention
Rate Limiting
// Node.js example — Redis-based rate limiting
async function checkTotpRateLimit(userId) {
const key = `totp_attempts:${userId}`;
const attempts = await redis.incr(key);
if (attempts === 1) {
await redis.expire(key, 300); // 5-minute window
}
if (attempts > 5) {
throw new Error('Too many attempts. Try again in 5 minutes.');
}
}
Replay Attack Prevention
A valid TOTP code can be used multiple times within its 30-second window unless you prevent it. Track used codes:
// Track recently used codes (Redis with TTL)
async function isCodeAlreadyUsed(userId, code, timeStep) {
const key = `totp_used:${userId}:${timeStep}:${code}`;
const exists = await redis.exists(key);
if (exists) return true;
// Mark as used for 2 time steps (60 seconds)
await redis.setex(key, 60, '1');
return false;
}
UX Design for 2FA
Set Up Page Best Practices
- Show both the QR code and the manual entry code side by side
- Include app download links (Google Authenticator, Authy) with platform detection
- Make the confirmation code field auto-focus
- Show a countdown timer for the current code's remaining validity
- Clearly explain what backup codes are before generating them
Login Challenge Page
- Auto-focus the code input field
- Accept 6 digits with spaces stripped; users often copy-paste with spaces
- Show which account/service they're logging into (important in SSO flows)
- Provide a "Use backup code instead" link
- Show attempt counter on failures: "2 of 5 attempts remaining"
Handling Expired Codes Gracefully
TOTP codes expire every 30 seconds. If a user submits too slowly:
- Accept ±1 time window (verify 3 consecutive time steps)
- Show clear error: "Code expired, enter the new code shown in your app"
- Never say "invalid code." Be specific about whether it's expired or wrong
Account Recovery Design
Your recovery flow is as important as the 2FA implementation itself. Design for three scenarios:
- Backup code available: Simple form, verify hash, one-time use, mark as used
- No backup code, user still has email access: Email recovery with delay (24–48h for high-value accounts) and notification to the account email
- No backup code, no email access: Manual identity verification (government ID), admin override for B2B apps, or out of luck for self-service apps
Testing Your 2FA Implementation
Use our 2FA Secret Generator and TOTP code generator to test your implementation during development. Enter your test secret and verify your server's generated codes match. This is faster than using a phone during development.
Test Cases Checklist
- Valid code at time step T
- Valid code at time step T-1 and T+1 (window tolerance)
- Code rejected at T-2 and T+2
- Same code rejected twice within the same window (replay prevention)
- Rate limiting triggers after N failures
- Backup code works once, fails on second use
- 2FA setup requires code confirmation before activating
- Session requires 2FA even after a valid password
Frequently Asked Questions
Should I encrypt the TOTP secret in the database?
Yes. TOTP secrets are long-lived credentials; if your database is breached, exposed secrets allow attackers to generate valid codes indefinitely until users re-enroll. Use AES-256-GCM with a key stored outside the database (environment variable, HSM, or secrets manager like AWS Secrets Manager).
What should I do when a user loses their 2FA device before confirming setup?
If 2FA was never confirmed (the user saw the QR code but didn't complete setup), there's no security loss, just clean up the pending secret and let them start the setup again. Only confirmed 2FA enrolments protect accounts.
Should 2FA be mandatory for all users?
For B2B SaaS: yes, allow account admins to mandate 2FA for their team. For consumer apps: strongly recommend via prompts, but consider that user friction forced 2FA in consumer apps increases support burden. A grace period with escalating reminders works well.
How do I handle 2FA for automated/service accounts?
Service accounts shouldn't use TOTP (no human to enter codes). Use API keys or OAuth service accounts with IP allowlisting instead. If TOTP is required (e.g., by a third-party service), store the secret in a secrets manager and generate codes programmatically.