NestJS course Β· Module 4: Authentication
Two-Factor Authentication (2FA) - double guard of the Empire
In this lesson7
A legionary typed his password into a fake page pretending to be the fort's gate. From that moment the enemy knows it, and no amount of hashing in the database helps, because the attacker simply logs in. Architect Vitruvius decided to post a second guard at the gate, one who checks something entirely different from the password. That is how Two-Factor Authentication (2FA) works.
What is 2FA?
Two-Factor Authentication requires two independent verification factors:
- Something you know - the password (first factor)
- Something you have - a phone with a code-generating app (second factor)
The third category is "something you are", meaning biometrics. Even if the enemy intercepts a legionary's password, without the second factor he will not enter the fort!
TOTP - Time-based One-Time Password
TOTP (standard RFC 6238) derives a code from two ingredients: a shared secret and the current time. The phone and the server compute the same code independently, so the app works even offline:
1// How TOTP works:
2// 1. Server generates a random secret (key)
3// 2. User scans the QR code with the secret in the authenticator app (Google Authenticator)
4// 3. The app generates a 6-digit code every 30 seconds
5// 4. Server verifies the code using the same secret and timeThe secret travels to the phone only once, inside the QR code. Beware of a common myth: a TOTP code is not single-use by itself. Within its time window it works a second time unless the server remembers that it has already accepted it.
Installing the libraries
You need two packages: otplib for the codes and qrcode for the image shown to the app:
1# otplib (v13+) - TOTP and HOTP library
2npm install otplib
3# qrcode - generating QR codes
4npm install qrcode
5npm install -D @types/qrcodeVersion 13 of otplib (January 2026) rewrote the API from scratch. The authenticator object from version 12, which you will find in older tutorials, is gone, replaced by the functions generateSecret, generateURI and an asynchronous verify.
TwoFactor Service
The service starts with imports and configuration. We import generateSecret as generateTotpSecret so that it cannot be confused with the service's own method:
1// auth/two-factor.service.ts
2import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
3import { generateSecret as generateTotpSecret, generateURI, verify } from 'otplib';
4import * as qrcode from 'qrcode';
5import { randomInt } from 'node:crypto';
6import { InjectRepository } from '@nestjs/typeorm';
7import { Repository } from 'typeorm';
8import { User } from '../entities/user.entity';
9
10@Injectable()
11export class TwoFactorService {
12 constructor(
13 @InjectRepository(User)
14 private usersRepository: Repository<User>,
15 ) {}
16
17 // TOTP configuration: 6 digits and a new code every 30 seconds are the otplib
18 // defaults; we accept a code from +/- 30 s, that is one step each way
19 private readonly totpOptions = { epochTolerance: 30 };epochTolerance: 30 is the equivalent of the old window: 1: the server also accepts the code from the previous and the next 30-second step, which forgives small clock differences.
Step one generates the secret and the QR code:
1 // Step 1: Generate secret and QR code
2 async generateSecret(userId: number): Promise<{
3 secret: string;
4 qrCodeUrl: string;
5 backupCodes: string[];
6 }> {
7 const user = await this.usersRepository.findOne({ where: { id: userId } });
8
9 if (!user) {
10 throw new BadRequestException('Legionary not found!');
11 }
12
13 if (user.twoFactorEnabled) {
14 throw new BadRequestException('2FA is already active!');
15 }
16
17 // Generate random secret
18 const secret = generateTotpSecret();
19
20 // Generate URI for the authenticator app
21 const otpauthUrl = generateURI({
22 issuer: 'Imperium Romanum', // Application name
23 label: user.email,
24 secret,
25 });
26
27 // Generate QR code as data URL
28 const qrCodeUrl = await qrcode.toDataURL(otpauthUrl);
29
30 // Generate backup codes
31 const backupCodes = this.generateBackupCodes(8);
32
33 // Save secret temporarily (don't activate yet!)
34 await this.usersRepository.update(userId, {
35 twoFactorSecret: secret,
36 twoFactorBackupCodes: JSON.stringify(
37 backupCodes.map(code => ({ code, used: false }))
38 ),
39 });
40
41 return { secret, qrCodeUrl, backupCodes };
42 }generateURI() builds an otpauth://totp/... address, and qrcode.toDataURL() turns it into an image. We store the secret, but we do not enable 2FA yet. In production, encrypt the secret in the database and hash the backup codes like passwords.
Step two confirms that scanning worked:
1 // Step 2: Verify and activate 2FA
2 async enableTwoFactor(userId: number, code: string): Promise<boolean> {
3 const user = await this.usersRepository.findOne({ where: { id: userId } });
4
5 if (!user || !user.twoFactorSecret) {
6 throw new BadRequestException('Generate the secret first!');
7 }
8
9 // Verify the code from the app
10 const { valid } = await verify({
11 token: code,
12 secret: user.twoFactorSecret,
13 ...this.totpOptions,
14 });
15
16 if (!valid) {
17 throw new UnauthorizedException('Invalid 2FA code!');
18 }
19
20 // Activate 2FA
21 await this.usersRepository.update(userId, {
22 twoFactorEnabled: true,
23 });
24
25 return true;
26 }Only a correct code from the app turns 2FA on. If scanning had failed, the legionary is not locked out of his own account.
Step three runs at every login:
1 // Step 3: Verify code during login
2 async verifyCode(userId: number, code: string): Promise<boolean> {
3 const user = await this.usersRepository.findOne({ where: { id: userId } });
4
5 if (!user || !user.twoFactorSecret) {
6 throw new UnauthorizedException('2FA is not configured!');
7 }
8
9 // Check if it's a backup code
10 if (code.length === 8) {
11 return this.verifyBackupCode(user, code);
12 }
13
14 // Verify TOTP code
15 const { valid } = await verify({
16 token: code,
17 secret: user.twoFactorSecret,
18 ...this.totpOptions,
19 });
20 return valid;
21 }
22
23 // Backup code verification
24 private async verifyBackupCode(user: User, code: string): Promise<boolean> {
25 const backupCodes = JSON.parse(user.twoFactorBackupCodes || '[]');
26 const codeEntry = backupCodes.find(
27 (bc: any) => bc.code === code && !bc.used
28 );
29
30 if (!codeEntry) return false;
31
32 // Mark as used
33 codeEntry.used = true;
34 await this.usersRepository.update(user.id, {
35 twoFactorBackupCodes: JSON.stringify(backupCodes),
36 });
37
38 return true;
39 }An eight-character code is a backup code, marked as used once consumed, and a six-digit one goes to verify(). The result also carries a timeStep field: store it and pass it as afterTimeStep in the next verification, and the same code will not pass twice.
Finally, the backup codes and turning 2FA off:
1 // Generate backup codes
2 private generateBackupCodes(count: number): string[] {
3 const codes: string[] = [];
4 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
5
6 for (let i = 0; i < count; i++) {
7 let code = '';
8 for (let j = 0; j < 8; j++) {
9 code += chars.charAt(randomInt(chars.length));
10 }
11 codes.push(code);
12 }
13
14 return codes;
15 }
16
17 // Deactivate 2FA
18 async disableTwoFactor(userId: number, code: string): Promise<void> {
19 const isValid = await this.verifyCode(userId, code);
20
21 if (!isValid) {
22 throw new UnauthorizedException('Invalid code - cannot disable 2FA!');
23 }
24
25 await this.usersRepository.update(userId, {
26 twoFactorEnabled: false,
27 twoFactorSecret: null,
28 twoFactorBackupCodes: null,
29 });
30 }
31}randomInt() from the node:crypto module uses a cryptographically secure random number generator. Math.random() is predictable and unfit for secrets. Disabling 2FA requires a valid code, so a hijacked session alone is not enough to remove the second guard.
2FA Controller
The controller exposes three routes, all behind a JwtAuthGuard set on the class:
1// auth/two-factor.controller.ts
2import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common';
3import { TwoFactorService } from './two-factor.service';
4import { JwtAuthGuard } from './guards/jwt-auth.guard';
5
6@Controller('auth/2fa')
7@UseGuards(JwtAuthGuard)
8export class TwoFactorController {
9 constructor(private twoFactorService: TwoFactorService) {}
10
11 // Step 1: Start 2FA configuration
12 @Post('setup')
13 async setup(@Req() req) {
14 const result = await this.twoFactorService.generateSecret(req.user.userId);
15
16 return {
17 message: 'Scan the QR code in the authenticator app',
18 qrCodeUrl: result.qrCodeUrl,
19 backupCodes: result.backupCodes,
20 warning: 'Save the backup codes in a safe place!',
21 };
22 }
23
24 // Step 2: Confirm configuration with a code from the app
25 @Post('verify')
26 async verify(@Req() req, @Body('code') code: string) {
27 await this.twoFactorService.enableTwoFactor(req.user.userId, code);
28
29 return {
30 message: '2FA has been activated! Double guard protects your account.',
31 enabled: true,
32 };
33 }
34
35 // Disable 2FA
36 @Post('disable')
37 async disable(@Req() req, @Body('code') code: string) {
38 await this.twoFactorService.disableTwoFactor(req.user.userId, code);
39
40 return {
41 message: '2FA has been deactivated.',
42 enabled: false,
43 };
44 }
45}req.user.userId comes from JwtStrategy. setup returns the QR code and the backup codes only once, so the client app should ask the user to save them.
2FA Guard - guardian of the second gate
The guard lets a request through when 2FA is disabled or the session has already passed it:
1// auth/guards/two-factor.guard.ts
2import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
3import { UnauthorizedException } from '@nestjs/common';
4import { TwoFactorService } from '../two-factor.service';
5
6@Injectable()
7export class TwoFactorGuard implements CanActivate {
8 constructor(private twoFactorService: TwoFactorService) {}
9
10 async canActivate(context: ExecutionContext): Promise<boolean> {
11 const request = context.switchToHttp().getRequest();
12 const user = request.user;
13
14 // If 2FA is not enabled, let through
15 if (!user.twoFactorEnabled) {
16 return true;
17 }
18
19 // Check if the user already passed 2FA in this session
20 if (user.twoFactorVerified) {
21 return true;
22 }
23
24 throw new UnauthorizedException(
25 '2FA verification required! Enter the code from your authenticator app.'
26 );
27 }
28}The trap: JwtStrategy from the JWT lesson returns only userId, username and role. Without the twoFactorEnabled and twoFactorVerified fields in req.user, the check !user.twoFactorEnabled is always true and the guard lets everyone in. Add both fields to the payload of the full token issued after a successful 2FA, and return them from validate().
Login flow with 2FA
The complete login process with 2FA looks like this:
- The legionary provides username + password (first factor)
- The server verifies the credentials and checks whether 2FA is active
- If so - it returns a temporary token and asks for the 2FA code
- The legionary opens the authenticator app and enters the 6-digit code
- The server verifies the TOTP code and issues a full access token
- The legionary has access to the fort!
1// Example flow in AuthService
2async loginWith2FA(loginDto: LoginDto): Promise<any> {
3 // Step 1: Verify password
4 const user = await this.validateUser(loginDto.username, loginDto.password);
5
6 if (!user) {
7 throw new UnauthorizedException('Invalid credentials!');
8 }
9
10 // Step 2: Check if 2FA is active
11 if (user.twoFactorEnabled) {
12 if (!loginDto.twoFactorCode) {
13 // Return a temporary token requiring 2FA, signed with a separate secret
14 // so that JwtAuthGuard never accepts it as a full access token
15 const tempToken = this.jwtService.sign(
16 { sub: user.id, requiresTwoFactor: true },
17 { secret: this.configService.get('JWT_2FA_SECRET'), expiresIn: '5m' },
18 );
19 return { requiresTwoFactor: true, tempToken };
20 }
21
22 // Step 3: Verify 2FA code
23 const is2FAValid = await this.twoFactorService.verifyCode(
24 user.id,
25 loginDto.twoFactorCode,
26 );
27
28 if (!is2FAValid) {
29 throw new UnauthorizedException('Invalid 2FA code!');
30 }
31 }
32
33 // Step 4: Issue full token
34 return this.generateTokenPair(user);
35}We sign the temporary token with a separate secret, JWT_2FA_SECRET. If it shared the access token's secret, JwtAuthGuard would let its holder into protected routes and the second guard would have nothing to do. You know generateTokenPair() from the refresh tokens lesson. As a second factor, choose a TOTP app over SMS messages, which can be intercepted through SIM swapping - that is my recommendation.
In the next module you will carry JWT tokens over into WebSocket communication.
Remember: a single trick can steal a password, but the TOTP secret stays in the legionary's phone, so the enemy has to defeat two independent guards at once.
Code for this lesson: src/auth/two-factor.service.ts
1// Two-Factor Authentication (2FA) w NestJS
2import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common';
3import { authenticator } from 'otplib';
4import * as qrcode from 'qrcode';
5
6// ============================================
7// 1. TwoFactorService - the heart of 2FA
8// ============================================
9@Injectable()
10class TwoFactorService {
11 constructor() {
12 // TOTP configuration
13 authenticator.options = {
14 digits: 6, // 6-digit code
15 step: 30, // New code every 30 seconds
16 window: 1, // Tolerance +/- 1 step
17 };
18 }
19
20 // TODO: Implement secret generation
21 async generateSecret(userId: number, email: string) {
22 // TODO: Use authenticator.generateSecret()
23 const secret = authenticator.generateSecret();
24
25 // TODO: Generate URI for the authenticator app
26 const otpauthUrl = authenticator.keyuri(
27 email,
28 'Imperium Romanum',
29 secret,
30 );
31
32 // TODO: Generate a QR code
33 const qrCodeUrl = await qrcode.toDataURL(otpauthUrl);
34
35 // TODO: Generate backup codes
36 const backupCodes = this.generateBackupCodes(8);
37
38 return { secret, qrCodeUrl, backupCodes };
39 }
40
41 // TODO: Implement TOTP code verification
42 verifyCode(secret: string, code: string): boolean {
43 // TODO: Use authenticator.verify()
44 return authenticator.verify({
45 token: code,
46 secret: secret,
47 });
48 }
49
50 // Generating backup codes
51 private generateBackupCodes(count: number): string[] {
52 const codes: string[] = [];
53 const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
54
55 for (let i = 0; i < count; i++) {
56 let code = '';
57 for (let j = 0; j < 8; j++) {
58 code += chars.charAt(Math.floor(Math.random() * chars.length));
59 }
60 codes.push(code);
61 }
62 return codes;
63 }
64}
65
66// ============================================
67// 2. Demonstration of the 2FA flow
68// ============================================
69console.log('=== Two-Factor Authentication (2FA) ===');
70console.log('');
71console.log('Setup flow:');
72console.log('1. POST /auth/2fa/setup -> QR code + backup codes');
73console.log('2. User scans QR in Google Authenticator');
74console.log('3. POST /auth/2fa/verify -> confirm with code from the app');
75console.log('');
76console.log('Login flow with 2FA:');
77console.log('1. POST /auth/login -> { requiresTwoFactor: true, tempToken }');
78console.log('2. POST /auth/2fa/authenticate -> full JWT token');
79console.log('');
80console.log('TOTP: Time-based One-Time Password');
81console.log('The code changes every 30 seconds');
82console.log('Backup codes: one-time backup codes');
83Spotted a mistake in this lesson?