Two-factor authentication implementation is the difference between a login system that falls to a single leaked password and one that actually resists attack. With 92% of developers using AI tools daily and 45% of AI-generated code containing OWASP vulnerabilities, your password-only auth is a liability. Adding a second factor turns credential theft from a game-over event into a minor inconvenience.
This guide covers TOTP-based 2FA with authenticator apps like Google Authenticator, Authy, and 1Password. You will generate QR codes, verify tokens, create backup codes, and build an enrollment flow that does not frustrate your users.
How TOTP Actually Works
TOTP stands for Time-Based One-Time Password, defined in RFC 6238. Your server and the user's authenticator app share a secret key. Every 30 seconds, both sides run the same algorithm against that shared secret and the current time, producing the same six-digit code independently without communicating.
Think of it like two synchronized clocks that generate a new combination every 30 seconds. An attacker who does not have the shared secret cannot predict the next code, even if they watched every previous code.
The algorithm is HMAC-SHA1 applied to the Unix timestamp divided by 30 (the time step), truncated to six digits. Most implementations accept the previous and next time windows too, accounting for minor clock drift between the server and the user's phone.
This is fundamentally different from SMS-based verification. SMS sends a code over a channel that can be intercepted via SIM swaps. TOTP never transmits the code. It is generated locally on the user's device from a secret shared once during enrollment.
Generating the QR Code for Enrollment
When a user enables 2FA, your server generates a random secret and presents it as a QR code. The QR code encodes an otpauth:// URI that authenticator apps understand. The format looks like this:
otpauth://totp/YourApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=YourApp&algorithm=SHA1&digits=6&period=30
The secret is a base32-encoded random key (at least 160 bits). The issuer is your app name, which appears in the authenticator app. The digits and period parameters are almost always 6 and 30, but including them explicitly avoids ambiguity.
In Node.js, the otplib library handles all of this:
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
// Generate a random secret for the user
const secret = authenticator.generateSecret();
// Build the otpauth URI
const otpauthUrl = authenticator.keyuri(
user.email,
'YourApp',
secret
);
// Generate a QR code as a data URL
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
Store the secret in your database associated with the user, but do not activate 2FA yet. The user needs to scan the QR code and prove they can generate a valid code before you lock them into 2FA. Otherwise they get locked out.

Verifying TOTP Codes
Once the user has the secret in their authenticator app, verification is straightforward. The user submits a six-digit code, and your server checks it against the expected value:
import { authenticator } from 'otplib';
// Allow a window of 1 step in each direction (90 seconds total)
authenticator.options = { window: 1 };
const isValid = authenticator.verify({
token: userSubmittedCode,
secret: storedUserSecret
});
if (!isValid) {
return { error: 'Invalid authentication code' };
}
The window option is important. Setting it to 1 means the server accepts codes from the previous, current, and next 30-second window. This accounts for the few seconds of delay between when the user reads the code and when your server receives it. Without this window, users whose phone clocks are slightly off will fail verification consistently.
A code valid for 90 seconds could theoretically be replayed within that window. For most applications, this is an acceptable tradeoff. If you need replay protection, store the last used timestamp per user and reject any code from the same or earlier time step.
Always verify the user can generate a valid TOTP code before activating 2FA on their account. The enrollment flow should be: generate secret, show QR code, require the user to enter a valid code, then and only then mark 2FA as active. Skipping the verification step is the number one cause of users getting permanently locked out of their own accounts.
Backup Codes That Actually Work
Phones break, get lost, and authenticator apps get deleted. You need a recovery path that does not require customer support intervention for every lost phone.
When the user enables 2FA, generate 8 to 10 one-time-use recovery codes. Each code is a random string (typically 8 hex characters, grouped for readability like a3b7-c9d2). Hash them with bcrypt before storing, the same way you handle passwords. Display them once, tell the user to save them, and never show them again.
import { randomBytes } from 'crypto';
import bcrypt from 'bcrypt';
function generateBackupCodes(count = 10): string[] {
return Array.from({ length: count }, () => {
const bytes = randomBytes(4);
const code = bytes.toString('hex');
return `${code.slice(0, 4)}-${code.slice(4, 8)}`;
});
}
// Store hashed versions in the database
async function storeBackupCodes(userId: string, codes: string[]) {
const hashedCodes = await Promise.all(
codes.map(code => bcrypt.hash(code, 12))
);
await db.backupCodes.createMany({
data: hashedCodes.map(hash => ({
userId,
codeHash: hash,
used: false
}))
});
}
When a user submits a backup code, compare it against all unused hashed codes. If one matches, mark it as used and let them in. When backup codes run low (fewer than 3 remaining), prompt the user to generate a new set.
Storing backup codes in plain text in your database. Backup codes are functionally equivalent to passwords. If an attacker gets your database, plain text backup codes let them bypass 2FA entirely, defeating the purpose of having a second factor. Hash them with bcrypt, the same way you hash passwords. The few extra milliseconds of verification time are worth it.
The Enrollment Flow UX
A confusing enrollment flow leads to users abandoning 2FA setup or getting locked out. Here is the flow that works:
Step 1: Require password confirmation. Before showing the QR code, ask the user to re-enter their password. This prevents an attacker who has a stolen session from enabling 2FA on someone else's account.
Step 2: Show the QR code and the plain text secret. Not everyone can scan QR codes. Always provide the base32 secret as copyable text alongside the QR code, labeled "Can't scan? Enter this code manually."
Step 3: Require a valid code. Do not let the user click "Enable" without entering a valid six-digit code. This proves the secret was transferred correctly. If the code is wrong, do not activate 2FA. Let them try again.
Step 4: Show backup codes. After successful verification, display the backup codes. Require an explicit acknowledgment before closing the dialog. Provide a download option as a plain text file.
Step 5: Confirm activation. Show a clear confirmation that 2FA is now active. Send an email notification about the change.
Get every security tutorial for vibe-coded applications, from authentication basics to production hardening.
See all security guidesUsing Supabase MFA
If you are building on Supabase, you do not need to implement TOTP from scratch. Supabase has built-in MFA support that handles secret generation, QR codes, and verification through their SDK:
// Enroll the user in TOTP-based MFA
const { data, error } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'Authenticator App'
});
// data.totp.qr_code contains the QR code SVG
// data.totp.uri contains the otpauth:// URI
// data.id is the factor ID for verification
// Verify a code to complete enrollment
const { data: challenge } = await supabase.auth.mfa.challenge({
factorId: data.id
});
const { data: verify } = await supabase.auth.mfa.verify({
factorId: data.id,
challengeId: challenge.id,
code: userSubmittedCode
});
Supabase MFA integrates with their auth system, so your RLS policies can check the user's assurance level. Password-only login gives aal1. Completed 2FA gives aal2. You can write RLS policies that require aal2 for sensitive operations:
CREATE POLICY "Require 2FA for financial data"
ON transactions
FOR SELECT
USING (
auth.uid() = user_id
AND (SELECT auth.jwt() ->> 'aal') = 'aal2'
);
This lets you allow basic account access with a password but gate payment history, API keys, or admin functions behind 2FA verification.
When to Require 2FA
Not every action in your app needs 2FA. Requiring it everywhere drives users away. Requiring it nowhere leaves critical actions unprotected. The answer is risk-based enforcement.
Always require 2FA for admin accounts. Anyone with elevated privileges should be required to enable 2FA. This is non-negotiable. A compromised admin account can destroy everything.
Require 2FA for financial operations. Payment settings, subscription changes, and payout configurations should require a TOTP code before processing, even if the user is already logged in.
Encourage but do not force 2FA for regular users. A strong prompt during onboarding with the option to skip is reasonable. Forced 2FA for a free-tier note-taking app will tank your conversion rate. Forced 2FA for a banking app is expected.
Re-verify 2FA for sensitive account changes. Changing email, changing password, disabling 2FA itself, and generating new API keys should all require a fresh 2FA code, even within an authenticated session.

What This Means For You
Two-factor authentication is not optional for production applications handling user data. A single leaked password from a phishing attack or a breach at another service compromises every account relying on password-only auth. TOTP with authenticator apps is the practical, proven approach that balances security with usability.
- If you are a senior developer reviewing AI-generated auth code: Verify the 2FA implementation includes enrollment verification (user must enter a valid code before activation), hashed backup codes, and a time window of 1 for TOTP verification. Check that the shared secret is at least 160 bits. Push for
aal2enforcement on sensitive routes if you are using Supabase. - If you are an indie hacker shipping fast: Use Supabase MFA or a library like
otplibinstead of implementing the TOTP algorithm yourself. The enrollment flow matters as much as the cryptography. Focus on the UX: password confirmation before setup, manual secret entry as a fallback, backup codes displayed clearly, and email notification on activation. A user who gets locked out of their account because your 2FA flow was confusing will not become a paying customer.
Two-factor authentication is one layer. Get the full security series for vibe-coded applications.
Explore security guides