Two-Factor Authentication in NestJS: TOTP with otplib & QR Codes
Add 2FA to your NestJS app using otplib. Generate TOTP secrets with authenticator.generateSecret(), validate codes, render QR codes for Google Authenticator, and implement backup codes and account recovery — 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>
);
}
Frequently Asked Questions
Q: How do I generate a TOTP secret with otplib in Node.js?
Import authenticator from 'otplib' and call authenticator.generateSecret(). This returns a base32-encoded secret string you store per user. To generate a QR code URI, call authenticator.keyuri(userEmail, appName, secret).
Q: How does TOTP work with Google Authenticator and otplib?
TOTP generates a time-based 6-digit code by hashing the shared secret with the current 30-second time window. authenticator.verify({ token, secret }) validates the code. Google Authenticator scans a QR code to import the secret and generate matching codes.
Q: How do I implement 2FA backup codes in NestJS?
Generate 8–10 random codes using crypto.randomBytes, hash them with bcrypt, and store the hashes. During 2FA verification, check if the input matches any unhashed code using bcrypt.compare(), then mark that code as used to prevent reuse.
Q: What base32 format does otplib authenticator.generateSecret() use?
otplib uses standard base32 encoding (RFC 4648). The generated secret is compatible with Google Authenticator, Authy, and any TOTP-compliant app. Store it securely (encrypted at rest) since it's the key to all future codes for that user account.
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.
Related Reading
- Secure Auth in Next.js + NestJS + Prisma: JWT, Refresh Tokens & Guards — the full authentication layer that 2FA sits on top of.
- Securing User Sessions: How Modern Authentication Works — JWT token lifecycle management that complements the 2FA flow.
- Security from the Attacker's Perspective — understanding attack vectors that 2FA specifically defends against.
Working on something similar?
I'm a Technical Lead available for contracts & consulting.
Specializing in NestJS microservices, Azure cloud architecture, and distributed systems. If your team is tackling similar challenges, let's talk.
Get in touchWritten 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.