Skip to content
← Back to blog
NestJS2FAAuthenticationSecurity

How to Add Two-Factor Authentication to NestJS

Firas Sayah·June 6, 2026·5 min read

Why 2FA Is Non-Negotiable for SaaS

Passwords alone are not enough. Credential stuffing attacks hit SaaS applications daily, and a single compromised admin account can expose every tenant's data. Two-factor authentication (2FA) is the most effective defense, and implementing it in NestJS is straightforward.

How TOTP Works

Time-based One-Time Passwords (TOTP) generate a six-digit code that changes every 30 seconds. The user scans a QR code with an authenticator app like Google Authenticator or Authy. The server and app share a secret, so both can independently compute the same code at any given moment.

Setting Up the Dependencies

npm install otplib qrcode
npm install -D @types/qrcode

Generating the Secret and QR Code

When a user enables 2FA, generate a secret and present a QR code:

import { authenticator } from 'otplib';
import * as QRCode from 'qrcode';

@Injectable()
export class TwoFactorService {
  async generate(user: User) {
    const secret = authenticator.generateSecret();
    const otpAuthUrl = authenticator.keyuri(
      user.email,
      'YourSaaS',
      secret,
    );
    const qrCode = await QRCode.toDataURL(otpAuthUrl);
    return { secret, qrCode };
  }
}

Store the secret encrypted in your database. Never store it in plain text.

Verifying the Code

async verify(user: User, token: string): Promise<boolean> {
  const secret = await this.decrypt(user.twoFactorSecret);
  return authenticator.verify({ token, secret });
}

Cloudrix SaaS Starter ships with this pre-configured and tested.

Skip weeks of boilerplate — auth, payments, multi-tenancy, and deployment included out of the box.

Try the live demo →

Integrating with the Auth Flow

Modify your login guard to check for 2FA after password validation:

@Injectable()
export class AuthService {
  async login(email: string, password: string, twoFactorCode?: string) {
    const user = await this.validateCredentials(email, password);

    if (user.isTwoFactorEnabled) {
      if (!twoFactorCode) {
        return { requiresTwoFactor: true };
      }
      const isValid = await this.twoFactorService.verify(user, twoFactorCode);
      if (!isValid) {
        throw new UnauthorizedException('Invalid 2FA code');
      }
    }

    return { accessToken: this.generateJwt(user) };
  }
}

Backup Codes

Users lose phones. Generate ten single-use backup codes when 2FA is enabled:

async generateBackupCodes(): Promise<string[]> {
  const codes = Array.from({ length: 10 }, () =>
    randomBytes(4).toString('hex'),
  );
  // Hash each code before storing
  const hashed = await Promise.all(
    codes.map((c) => bcrypt.hash(c, 10)),
  );
  return codes; // Return plain codes once, store hashed
}

Each backup code can only be used once. After verification, mark it as consumed in the database.

Frontend Integration

On your Angular frontend, the login flow becomes a two-step process. After the initial email and password submission, if the API responds with requiresTwoFactor: true, show a second form asking for the six-digit code. This keeps the UX clean while maintaining security.

Rate Limiting 2FA Attempts

Protect against brute-force attacks on the six-digit code space:

@UseGuards(ThrottlerGuard)
@Throttle({ default: { limit: 5, ttl: 300000 } })
@Post('2fa/verify')
async verifyTwoFactor(@Body() dto: VerifyTwoFactorDto) {
  return this.authService.verifyTwoFactor(dto);
}

Five attempts per five minutes is a reasonable limit. After that, require the user to restart the login flow.

SaaS Starter includes 2FA out of the box. See the authentication feature.

Get Started Today

All of these patterns are fully implemented in SaaS Starter. Skip 2-6 weeks of setup.

F

Firas Sayah

Senior Software Engineer

Full-stack developer with 5+ years building production SaaS applications with NestJS and Angular.