Adding a Second Lock on the Door: Implementing 2FA
Two-factor auth is table stakes for any product handling sensitive data. Here's how we implemented TOTP with QR codes, backup codes, and account recovery — and the encryption decisions that made it production-safe.
Two-Factor Authentication (2FA) adds a critical security layer by requiring users to provide two forms of identification: something they know (password) and something they have (a time-based code). This guide covers implementing TOTP-based 2FA with NestJS, generating QR codes, verifying tokens, and handling backup codes—following the same approach used by GitHub, Google, and AWS.
Prerequisites
Before implementing 2FA, ensure you have:
- NestJS application with authentication already set up
- Prisma (or another ORM) for database access
- Node.js 18+ for crypto module support
Install the required dependencies:
npm install otplib qrcode
npm install -D @types/qrcode
otplib generates and verifies TOTP codes. qrcode creates scannable QR codes for authenticator apps.
What is 2FA?
Two-Factor Authentication protects against password breaches by requiring a second factor that attackers typically don't have: a time-based code generated by an authenticator app on the user's phone.
Types of 2FA
| Method | How It Works | Security Level |
|---|---|---|
| SMS | Code sent via text message | Weak (SIM swapping attacks) |
| Code sent to email inbox | Moderate (email compromise risk) | |
| TOTP (Time-based) | Authenticator app generates code | Strong (offline, not interceptable) |
| Hardware Key | Physical USB/NFC device (YubiKey) | Strongest (phishing-resistant) |
We'll implement TOTP (Time-based One-Time Password) — the industry standard used by Google Authenticator, Authy, and 1Password.
How TOTP Works
SETUP PHASE LOGIN PHASE
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ Server │ │ User │ │ Server │ │ User │
└───┬────┘ └───┬────┘ └───┬────┘ └───┬────┘
│ │ │ │
│ 1. Generate │ │ 1. Password ✓ │
│ secret │ │ │
│ │ │ 2. Request code ─▶│
│ 2. Create QR ────▶│ │ │
│ │ │ 3. │ Open App
│ 3. │ Scan QR │ │ Code: 123456
│ │ │ 4. Submit ◀──────│
│ 4. Verify ◀──────│ │ │
│ Enable 2FA │ │ 5. Verify & │
└──────────────────┘ │ Grant access ─▶│
└──────────────────┘
TOTP = HMAC-SHA1(Secret, floor(UnixTime / 30s)) → 6-digit code
The TOTP Algorithm
TOTP is defined in RFC 6238. Both server and authenticator app share a secret and use the current time to generate matching codes:
- Time Counter: Divide current Unix timestamp by 30 seconds
- HMAC: Hash the counter with the shared secret using HMAC-SHA1
- Truncate: Extract a 6-digit code from the hash
Because both sides use the same secret and time, they generate identical codes. The 30-second window provides tolerance for clock drift.
Why TOTP is Secure
| Property | Why It Matters |
|---|---|
| Offline generation | No network needed — codes generated locally on device |
| Time-limited | Codes expire after 30 seconds, preventing replay attacks |
| Secret never transmitted | Only shared once during setup via QR code |
| Cryptographically strong | HMAC-SHA1 prevents code prediction without the secret |
Backend Implementation (NestJS)
Dependencies
npm install otplib qrcode
npm install -D @types/qrcode
Database Schema
model User {
id String @id @default(cuid())
email String @unique
password String
// 2FA fields
twoFactorEnabled Boolean @default(false)
twoFactorSecret String? // Encrypted base32 secret
backupCodes String[] // Encrypted array of backup codes
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
TwoFactorService
import { Injectable } from '@nestjs/common';
import { authenticator } from 'otplib';
import * as QRCode from 'qrcode';
import { PrismaService } from './prisma.service';
import { randomBytes } from 'crypto';
import { encrypt, decrypt } from './crypto.util';
@Injectable()
export class TwoFactorService {
constructor(private prisma: PrismaService) {
// Configure TOTP settings
authenticator.options = {
window: 1, // Allow 1 time step before/after (±30 seconds tolerance)
step: 30, // 30-second time step
};
}
generateSecret(): string {
return authenticator.generateSecret();
}
async generateQRCode(email: string, secret: string): Promise<string> {
const appName = 'MyApp';
const otpauthUrl = authenticator.keyuri(email, appName, secret);
return QRCode.toDataURL(otpauthUrl);
}
verifyToken(secret: string, token: string): boolean {
try {
return authenticator.verify({ token, secret });
} catch {
return false;
}
}
generateBackupCodes(count: number = 10): string[] {
return Array.from({ length: count }, () =>
randomBytes(4).toString('hex').toUpperCase()
);
}
async enableTwoFactor(userId: string, secret: string) {
const backupCodes = this.generateBackupCodes();
await this.prisma.user.update({
where: { id: userId },
data: {
twoFactorEnabled: true,
twoFactorSecret: encrypt(secret),
backupCodes: backupCodes.map(code => encrypt(code)),
},
});
return backupCodes; // Show user once
}
async disableTwoFactor(userId: string) {
await this.prisma.user.update({
where: { id: userId },
data: {
twoFactorEnabled: false,
twoFactorSecret: null,
backupCodes: [],
},
});
}
async verifyBackupCode(userId: string, code: string): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user?.backupCodes.length) return false;
const decryptedCodes = user.backupCodes.map(c => decrypt(c));
const codeIndex = decryptedCodes.indexOf(code.toUpperCase());
if (codeIndex === -1) return false;
// Remove used backup code
const updatedCodes = [...user.backupCodes];
updatedCodes.splice(codeIndex, 1);
await this.prisma.user.update({
where: { id: userId },
data: { backupCodes: updatedCodes },
});
return true;
}
}
TwoFactorController
import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common';
import { TwoFactorService } from './two-factor.service';
import { AuthGuard } from './auth.guard';
import { decrypt } from './crypto.util';
@Controller('2fa')
@UseGuards(AuthGuard)
export class TwoFactorController {
constructor(
private twoFactorService: TwoFactorService,
private prisma: PrismaService
) {}
@Get('setup')
async setupTwoFactor(@Req() req: any) {
const secret = this.twoFactorService.generateSecret();
const qrCode = await this.twoFactorService.generateQRCode(
req.user.email,
secret
);
// Store temporarily until verified
req.session.tempTwoFactorSecret = secret;
return { qrCode, secret };
}
@Post('enable')
async enableTwoFactor(@Req() req: any, @Body() body: { token: string }) {
const tempSecret = req.session.tempTwoFactorSecret;
if (!tempSecret) {
return { success: false, error: 'Setup not initiated' };
}
if (!this.twoFactorService.verifyToken(tempSecret, body.token)) {
return { success: false, error: 'Invalid token' };
}
const backupCodes = await this.twoFactorService.enableTwoFactor(
req.user.id,
tempSecret
);
delete req.session.tempTwoFactorSecret;
return { success: true, backupCodes };
}
@Post('disable')
async disableTwoFactor(@Req() req: any, @Body() body: { token: string }) {
const user = await this.prisma.user.findUnique({
where: { id: req.user.id },
});
if (!user?.twoFactorEnabled) {
return { success: false, error: '2FA not enabled' };
}
const secret = decrypt(user.twoFactorSecret);
if (!this.twoFactorService.verifyToken(secret, body.token)) {
return { success: false, error: 'Invalid token' };
}
await this.twoFactorService.disableTwoFactor(req.user.id);
return { success: true };
}
}
Authentication Flow with 2FA
@Post('login')
async login(@Body() body: { email: string; password: string }) {
const user = await this.authService.validateUser(body.email, body.password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// Check if 2FA is enabled
if (user.twoFactorEnabled) {
return {
requiresTwoFactor: true,
tempUserId: user.id, // Send encrypted/signed token in production
message: 'Enter your 2FA code',
};
}
// No 2FA - generate JWT and log in
const token = this.authService.generateJWT(user);
return { token, user };
}
@Post('verify-2fa')
async verifyTwoFactor(
@Body() body: { tempUserId: string; token: string; isBackupCode?: boolean }
) {
const user = await this.prisma.user.findUnique({
where: { id: body.tempUserId },
});
if (!user || !user.twoFactorEnabled) {
throw new UnauthorizedException('Invalid request');
}
let isValid = false;
if (body.isBackupCode) {
isValid = await this.twoFactorService.verifyBackupCode(user.id, body.token);
} else {
const secret = this.twoFactorService.decrypt(user.twoFactorSecret);
isValid = this.twoFactorService.verifyToken(secret, body.token);
}
if (!isValid) {
throw new UnauthorizedException('Invalid 2FA code');
}
const token = this.authService.generateJWT(user);
return { token, user };
}
Frontend Implementation (React)
2FA Setup Component
import { useState } from 'react';
export function TwoFactorSetup() {
const [qrCode, setQrCode] = useState<string | null>(null);
const [secret, setSecret] = useState<string | null>(null);
const [verificationCode, setVerificationCode] = useState('');
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [step, setStep] = useState<'init' | 'verify' | 'complete'>('init');
const initSetup = async () => {
const res = await fetch('/api/2fa/setup', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
setQrCode(data.qrCode);
setSecret(data.secret);
setStep('verify');
};
const verifyAndEnable = async () => {
const res = await fetch('/api/2fa/enable', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ token: verificationCode }),
});
const data = await res.json();
if (data.success) {
setBackupCodes(data.backupCodes);
setStep('complete');
} else {
alert(data.error);
}
};
if (step === 'init') {
return (
<div className="max-w-md mx-auto p-6">
<h2 className="text-2xl font-bold mb-4">Enable 2FA</h2>
<p className="mb-4">Add an extra layer of security to your account.</p>
<button onClick={initSetup} className="btn-primary">Get Started</button>
</div>
);
}
if (step === 'verify') {
return (
<div className="max-w-md mx-auto p-6">
<h2 className="text-2xl font-bold mb-4">Scan QR Code</h2>
<img src={qrCode!} alt="QR Code" className="mx-auto mb-4" />
<p className="text-sm text-gray-600 mb-2">Manual entry: <code>{secret}</code></p>
<input
type="text"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter 6-digit code"
className="input w-full mb-4"
maxLength={6}
/>
<button onClick={verifyAndEnable} className="btn-primary w-full">
Verify and Enable
</button>
</div>
);
}
return (
<div className="max-w-md mx-auto p-6">
<h2 className="text-2xl font-bold mb-4">Backup Codes</h2>
<p className="text-sm text-yellow-800 mb-4 p-3 bg-yellow-50 rounded">
Save these codes securely. Each can be used once if you lose your device.
</p>
<div className="grid grid-cols-2 gap-2 mb-4">
{backupCodes.map((code, i) => (
<code key={i} className="p-2 bg-gray-100 rounded text-center">{code}</code>
))}
</div>
<button
onClick={() => navigator.clipboard.writeText(backupCodes.join('\n'))}
className="btn-secondary w-full"
>
Copy All Codes
</button>
</div>
);
}
2FA Login Component
import { useState } from 'react';
export function TwoFactorLogin({ tempUserId }: { tempUserId: string }) {
const [code, setCode] = useState('');
const [useBackupCode, setUseBackupCode] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const response = await fetch('/api/auth/verify-2fa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tempUserId, token: code, isBackupCode: useBackupCode }),
});
const data = await response.json();
if (data.token) {
localStorage.setItem('token', data.token);
window.location.href = '/dashboard';
} else {
alert('Invalid code');
}
};
return (
<div className="max-w-sm mx-auto p-6">
<h2 className="text-2xl font-bold mb-4">Two-Factor Authentication</h2>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block mb-2">
{useBackupCode ? 'Backup Code' : 'Authenticator Code'}
</label>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder={useBackupCode ? 'ABCD1234' : '123456'}
className="input w-full"
maxLength={useBackupCode ? 8 : 6}
/>
</div>
<button type="submit" className="btn-primary w-full mb-2">Verify</button>
<button
type="button"
onClick={() => setUseBackupCode(!useBackupCode)}
className="text-sm text-blue-600 underline"
>
{useBackupCode ? 'Use authenticator app' : 'Use backup code'}
</button>
</form>
</div>
);
}
Conclusion
Two-Factor Authentication is essential for protecting user accounts. TOTP-based 2FA provides strong security without relying on SMS or email, making it resistant to SIM swapping and interception attacks.
Implementation Checklist
- Generate secrets: Use
otplibto create base32-encoded secrets - QR code setup: Generate scannable codes with the
otpauth://URI format - Verify before enabling: Require users to enter a valid code before activating 2FA
- Backup codes: Generate single-use recovery codes during setup
With 2FA enabled, even if an attacker obtains a user's password, they cannot access the account without the time-based code from the user's authenticator app.
Explore the Code
Check out the ft_transcendence repository on GitHub to see these patterns in a real-world multiplayer game.
Written by

Technical Lead and Full Stack Engineer leading a 5-engineer team at Fygurs (Paris, Remote) on Azure cloud-native SaaS. Graduate of 1337 Coding School (42 Network / UM6P). Writes about architecture, cloud infrastructure, and engineering leadership.