ZyVOP Logo
Content That Connects
SeriesAI NewsCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article
  • Newsletter

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeTutorialImplementing Two-Factor Authentication (TOTP) in NestJS: With Full Source Code
Tutorial
👍1

Implementing Two-Factor Authentication (TOTP) in NestJS: With Full Source Code

Build a real TOTP-based 2FA flow with NestJS, Passport, and PostgreSQL — secret generation, QR codes, backup codes, and the guard logic to tie it all together.

#two-factor authentication#2FA#TOTP#NestJS#otpauth#JWT authentication#Passport.js#postgresql#backup codes#authentication security
Z
ZyVOP

Senior Developer

June 27, 2026
9 min read
35 views
Implementing Two-Factor Authentication (TOTP) in NestJS: With Full Source Code

Password-only auth has one structural problem: a leaked password is a leaked account. Two-factor authentication (2FA) closes most of that gap by requiring a second, time-bound proof of identity — something the user has (an authenticator app) rather than just something they know (a password).

This post walks through a complete TOTP (Time-based One-Time Password) implementation in NestJS: generating secrets, rendering them as QR codes, verifying codes with clock-drift tolerance, issuing backup codes, and gating routes correctly based on 2FA status. The full project — tested end-to-end against a real Postgres instance — is linked at the end.

How TOTP actually works

TOTP is defined in RFC 6238. The mechanics are simple:

  1. The server generates a random secret and shares it with the user once, usually via QR code.

  2. Both the server and the authenticator app derive a 6-digit code from HMAC(secret, current_30s_time_window).

  3. Since both sides know the secret and the time, they should compute the same code — no network round-trip required.

The only real failure mode is clock drift between the server and the user's device, which is why a well-built verifier checks a small window of adjacent time steps rather than an exact match.

Architecture of the flow

This implementation uses two JWT states rather than a separate "2FA session" table:

  • A partial token (isSecondFactorAuthenticated: false) — issued right after password login, for accounts that already have 2FA enabled. It's accepted by exactly one route: /2fa/authenticate.

  • A full token (isSecondFactorAuthenticated: true) — issued once the second factor is verified, or immediately for accounts that don't have 2FA enabled at all.

Two guards enforce this:

// jwt-auth.guard.ts — accepts ANY valid token, partial or full.
// Should ONLY ever guard /2fa/authenticate — see why below.
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

// jwt-two-factor.guard.ts — requires a FULLY authenticated session
@Injectable()
export class JwtTwoFactorGuard extends AuthGuard('jwt') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const canActivate = (await super.canActivate(context)) as boolean;
    if (!canActivate) return false;

    const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
    if (!request.user.isSecondFactorAuthenticated) {
      throw new UnauthorizedException('Two-factor authentication required');
    }
    return true;
  }
}

Here's the part that's easy to get wrong: it's tempting to guard /2fa/generate and /2fa/turn-on with JwtAuthGuard too, on the reasoning that a brand-new user "isn't fully 2FA'd yet either." Don't — a partial token is only ever issued for accounts that already have 2FA enabled. If those two routes accepted one, anyone holding just a stolen password could log in, immediately call /2fa/generate to overwrite the account's TOTP secret with one they control, confirm it via /2fa/turn-on, and walk away having fully hijacked 2FA without ever knowing the real second factor. A brand-new user isn't affected by locking these routes down, either — isTwoFactorEnabled is still false for them at that point, so login already handed them a full token.

So the actual guard assignment is:

  • JwtTwoFactorGuard → /2fa/generate, /2fa/turn-on, /2fa/turn-off, and any other protected route.

  • JwtAuthGuard → /2fa/authenticate only. That's the single route a partial token should ever be able to reach.

Data model

@Entity()
export class User {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  email: string;

  @Column({ select: false })
  password: string;

  @Column({ default: false })
  isTwoFactorEnabled: boolean;

  // Unconfirmed until verified via /auth/2fa/turn-on
  @Column({ type: 'varchar', nullable: true, select: false })
  twoFactorSecret: string | null;

  // Bcrypt-hashed, single-use recovery codes
  @Column('text', { array: true, nullable: true, select: false })
  twoFactorBackupCodes: string[] | null;
}

select: false on the password, secret, and backup codes keeps them out of any default find() call — they only come back when explicitly requested via addSelect(). That's a cheap insurance policy against accidentally serializing a secret into an API response somewhere down the line.

Generating the secret and QR code

This uses otpauth rather than the more commonly referenced otplib/speakeasy — both of those are either deprecated or have had little recent maintenance. otpauth has no native dependencies and a clean, synchronous API.

async generateSecret(userId: string, email: string) {
  const secret = new Secret({ size: 20 });
  await this.usersService.setTwoFactorSecret(userId, secret.base32);

  const totp = this.buildTotp(secret.base32, email);
  const otpAuthUrl = totp.toString(); // otpauth://totp/...
  const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl);

  return { qrCodeDataUrl, otpAuthUrl };
}

The secret is persisted as soon as it's generated, before the user has confirmed anything. That's intentional — it lets /2fa/turn-on verify against it on the next request rather than threading the secret through the client and back, which would mean trusting the client to round-trip something sensitive correctly.

QRCode.toDataURL() turns the otpauth:// URI directly into a base64 PNG, which any authenticator app (Google Authenticator, Authy, 1Password, etc.) can scan without any custom parsing on your end.

This project is API-only, so it's worth being explicit about how a real frontend turns that response into something a user actually sees. Browsers render data:image/...;base64,... URLs natively, so there's no decoding step — the response body can go straight into an <img> tag:

const { qrCodeDataUrl } = await fetch('/auth/2fa/generate', {
  method: 'POST',
  headers: { Authorization: `Bearer ${accessToken}` },
}).then((res) => res.json());

return <img src={qrCodeDataUrl} alt="Scan with your authenticator app" />;

That's the entire client-side integration. No QR-rendering library needed on the frontend — the server already did that work.

Verifying a code

verifyTotpToken(token: string, base32Secret: string, email: string): boolean {
  const totp = this.buildTotp(base32Secret, email);
  const delta = totp.validate({ token, window: 1 });
  return delta !== null;
}

window: 1 checks one 30-second step before and after the current one — three valid codes at any given moment instead of one. That's enough slack for typical clock drift without meaningfully widening the brute-force surface (going from 1-in-a-million to 3-in-a-million per guess is not the part doing the security work here — rate-limiting /2fa/authenticate is, and it should be in place regardless of window size).

Turning 2FA on — and generating backup codes

A code generated from a freshly scanned QR code is the only proof that the secret actually reached the user's device correctly. Skip this check and you risk locking someone out the moment a QR code fails to scan properly.

@UseGuards(JwtTwoFactorGuard)
@Post('2fa/turn-on')
async turnOn(@Req() req: AuthenticatedRequest, @Body() dto: TwoFactorAuthCodeDto) {
  const user = await this.usersService.findByIdWithSecrets(req.user.sub);
  if (!user?.twoFactorSecret) {
    throw new BadRequestException('Call /auth/2fa/generate first');
  }

  const isValid = this.twoFactorAuthService.verifyTotpToken(
    dto.twoFactorAuthCode, user.twoFactorSecret, user.email,
  );
  if (!isValid) throw new UnauthorizedException('Invalid authenticator code');

  const backupCodes = this.twoFactorAuthService.generateBackupCodes();
  const hashedBackupCodes = await this.twoFactorAuthService.hashBackupCodes(backupCodes);
  await this.usersService.enableTwoFactor(user.id, hashedBackupCodes);

  return {
    message: '2FA enabled. Store these backup codes safely — they will not be shown again.',
    backupCodes, // plaintext, shown exactly once
  };
}

Backup codes solve the "I lost my phone" problem. They're generated once, hashed with bcryptjs before storage (the same way a password would be), and shown to the user in plaintext exactly one time. After this response, the server only ever holds the hashes. (bcryptjs rather than bcrypt deliberately — same hashing algorithm, but pure JavaScript with no native compilation step, which matters if you've ever had node-gyp fail a CI build over a missing system header.)

generateBackupCodes(count = 8): string[] {
  return Array.from({ length: count }, () => crypto.randomBytes(5).toString('hex'));
}

Disabling 2FA is the mirror image of turning it on, and deliberately just as strict — it also requires a full token plus a current valid code, not just a click:

@UseGuards(JwtTwoFactorGuard)
@Post('2fa/turn-off')
async turnOff(@Req() req: AuthenticatedRequest, @Body() dto: TwoFactorAuthCodeDto) {
  const user = await this.usersService.findByIdWithSecrets(req.user.sub);
  if (!user?.twoFactorSecret) {
    throw new BadRequestException('Two-factor authentication is not enabled');
  }

  const isValid = this.twoFactorAuthService.verifyTotpToken(
    dto.twoFactorAuthCode, user.twoFactorSecret, user.email,
  );
  if (!isValid) throw new UnauthorizedException('Invalid authenticator code');

  await this.usersService.disableTwoFactor(user.id);
  return { message: '2FA disabled' };
}

Authenticating with a TOTP or backup code

@UseGuards(JwtAuthGuard)
@Post('2fa/authenticate')
async authenticate(@Req() req: AuthenticatedRequest, @Body() dto: TwoFactorAuthCodeDto) {
  const user = await this.usersService.findByIdWithSecrets(req.user.sub);
  if (!user?.isTwoFactorEnabled || !user.twoFactorSecret) {
    throw new BadRequestException('Two-factor authentication is not enabled for this account');
  }

  let isValid = this.twoFactorAuthService.verifyTotpToken(
    dto.twoFactorAuthCode, user.twoFactorSecret, user.email,
  );

  if (!isValid && user.twoFactorBackupCodes?.length) {
    isValid = await this.twoFactorAuthService.verifyAndConsumeBackupCode(
      user.id, dto.twoFactorAuthCode, user.twoFactorBackupCodes,
    );
  }

  if (!isValid) throw new UnauthorizedException('Invalid authentication code');

  return { accessToken: this.authService.issueToken(user, true) };
}

The fallback to backup codes only fires if the TOTP check fails — a live code from the authenticator app is always tried first, since that's the common case and avoids an unnecessary bcrypt comparison loop. verifyAndConsumeBackupCode removes the matched code from the array immediately on use:

async verifyAndConsumeBackupCode(userId: string, code: string, hashedCodes: string[]) {
  for (let i = 0; i < hashedCodes.length; i++) {
    if (await bcrypt.compare(code, hashedCodes[i])) {
      const remaining = [...hashedCodes];
      remaining.splice(i, 1);
      await this.usersService.updateBackupCodes(userId, remaining);
      return true;
    }
  }
  return false;
}

Setting up and running the project

Clone the repo, install dependencies, and copy the environment template:

git clone <your-repo-url>
cd 2fa-nestjs-demo
npm install
cp .env.example .env

Open .env and set JWT_SECRET to a long random string, plus your Postgres credentials. If you don't already have Postgres running locally, the fastest path is Docker:

docker run --name twofa-postgres \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=twofa_demo \
  -p 5432:5432 -d postgres:16

Then start the API:

npm run start:dev

synchronize: true is enabled in app.module.ts for this demo, so the user table is created automatically on first boot — no manual migration step needed to follow along. (Turn that off and switch to real migrations before deploying anywhere real.)

With the server up on http://localhost:3000, you're ready to run through the flow below.

Testing it end-to-end

This is the actual sequence used to validate the implementation before publishing it — register, log in, enable 2FA, log in again, clear the second factor, hit a protected route, then disable 2FA. Every step below was run against a live server and a real Postgres instance.

# 1. Register
curl -X POST http://localhost:3000/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"correct-horse-battery"}'
# -> { "id": "...", "email": "alice@example.com" }

# 2. Log in — 2FA isn't enabled yet, so this returns a full access token
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"correct-horse-battery"}'
# -> { "accessToken": "<token>", "twoFactorRequired": false }
# 3. Generate a 2FA secret + QR code (pass the token from step 2)
curl -X POST http://localhost:3000/auth/2fa/generate \
  -H "Authorization: Bearer <accessToken>"
# -> { "qrCodeDataUrl": "data:image/png;base64,..." }
# Paste the data URL into a browser address bar, or render it in an <img> tag,
# and scan it with Google Authenticator / Authy / 1Password / etc.

# 4. Confirm setup with a live code from the authenticator app
curl -X POST http://localhost:3000/auth/2fa/turn-on \
  -H "Authorization: Bearer <accessToken>" \
  -H "Content-Type: application/json" \
  -d '{"twoFactorAuthCode":"123456"}'
# -> { "message": "2FA enabled...", "backupCodes": ["a1b2c3d4e5", ...] }
# Show these to the user ONCE — they're not recoverable after this response.
# 5. Log in again — now a PARTIAL token comes back instead of a full one
curl -X POST http://localhost:3000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"correct-horse-battery"}'
# -> { "accessToken": "<partial token>", "twoFactorRequired": true }

# 6. That partial token alone isn't enough for a protected route
curl http://localhost:3000/auth/me -H "Authorization: Bearer <partialAccessToken>"
# -> 401 { "message": "Two-factor authentication required" }

# 7. A wrong code is rejected outright
curl -X POST http://localhost:3000/auth/2fa/authenticate \
  -H "Authorization: Bearer <partialAccessToken>" \
  -H "Content-Type: application/json" \
  -d '{"twoFactorAuthCode":"000000"}'
# -> 401 { "message": "Invalid authentication code" }

# 8. A valid TOTP code (or one of the backup codes from step 4) clears the second factor
curl -X POST http://localhost:3000/auth/2fa/authenticate \
  -H "Authorization: Bearer <partialAccessToken>" \
  -H "Content-Type: application/json" \
  -d '{"twoFactorAuthCode":"123456"}'
# -> { "accessToken": "<full token>" }
# 9. The full token now works on protected routes
curl http://localhost:3000/auth/me -H "Authorization: Bearer <fullAccessToken>"
# -> { "id": "...", "email": "alice@example.com" }

# 10. Disabling 2FA requires a full token AND a current valid code
curl -X POST http://localhost:3000/auth/2fa/turn-off \
  -H "Authorization: Bearer <fullAccessToken>" \
  -H "Content-Type: application/json" \
  -d '{"twoFactorAuthCode":"123456"}'
# -> { "message": "2FA disabled" }

One thing worth testing directly: re-submitting the same backup code from step 4 a second time. It should — and does — come back with the same 401 Invalid authentication code, since verifyAndConsumeBackupCode deletes a code from storage the moment it's used.

It's also worth confirming the boundary from the "Architecture" section explicitly, not just trusting the explanation — a partial token should be rejected by /2fa/generate too, the same way it was rejected by /auth/me in step 6:

curl -X POST http://localhost:3000/auth/2fa/generate -H "Authorization: Bearer <partialAccessToken>"
# -> 401 { "message": "Two-factor authentication required" }

That's the line that closes the privilege-escalation path: holding a stolen password gets you a partial token, and a partial token alone gets you nothing on that route — you have to clear /2fa/authenticate first.

Security notes before shipping this for real

A few things were deliberately simplified for clarity and are worth tightening before production use:

  • Encrypt the TOTP secret at rest with a KMS-backed key rather than relying on select: false alone — that flag protects against accidental leakage in queries, not a database compromise.

  • Rate-limit /auth/login and /auth/2fa/authenticate. Both are brute-force surfaces; @nestjs/throttler handles this with a few lines of config.

  • Give the partial token a short, distinct expiry — it represents an incomplete login and shouldn't live as long as a real session.

  • Require the current password again before disabling 2FA, not just a TOTP code, so a stolen-but-still-valid session token can't silently downgrade an account's security.

  • Never log the secret, TOTP code, or backup codes — not even in error traces.

TOTP is a solid, broadly compatible baseline. If you want to go further, WebAuthn/passkeys remove the phishability that TOTP still has (a user can still be tricked into typing a valid code into a fake login page) — but TOTP remains the most universally supported second factor today, and it's a meaningful security upgrade over passwords alone.

Source code

The complete, tested implementation — NestJS module, entities, guards, DTOs, and a README with the full setup and curl walkthrough — is available as a standalone repository: 2fa-nestjs-demo. Clone it, drop in your own Postgres credentials, and the registration → 2FA enrollment → login flow above works out of the box.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Table of Contents

How TOTP actually worksArchitecture of the flowData modelGenerating the secret and QR codeVerifying a codeTurning 2FA on — and generating backup codesAuthenticating with a TOTP or backup codeSetting up and running the projectTesting it end-to-endSecurity notes before shipping this for realSource code

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Related Posts

IDOR Vulnerabilities in NestJS: How to Build Ownership Guards That Actually Protect Your Data

IDOR is OWASP's top API risk for a reason. A single missing ownership check can expose customer data across your entire application. This guide shows how IDOR vulnerabilities appear in NestJS APIs, how to implement robust authorization guards, and how to verify your protections with practical security tests.

Read article

Why Your App Is Slow (And It's Not the Database)

Slow APIs with a clean slow query log trace to one of five root causes. Four have nothing to do with query execution. Here's how to identify each one, measure it precisely, and fix it for good.

Read article

SQL Mistakes That Kill Your Database (And How to Fix Them)

SQL performance problems rarely come from the database itself — they come from inefficient queries. This guide covers the most common mistakes that slow production systems down, including missing indexes, N+1 queries, full table scans, bad joins, overfetching, and how to debug and optimize them properly.

Read article

NestJS Error Monitoring with Sentry: Production-Grade Setup Guide

Modern backend systems rarely fail in obvious ways. APIs timeout silently, background jobs crash without logs, and database exceptions disappear behind generic ...

Read article

TypeORM is Killing Your Node Process: Handling Large Datasets Without OOM Crashes

Object-Relational Mappers (ORMs) are excellent for standard CRUD operations, relationship management, and schema migrations. But when you transition from basic ...

Read article

Popular Tags

#.env.example Node.js#0x profiling#10x faster python scraper tutorial#12-factor#2026#2FA#AI#AI Backend#AI Comparison#AI Cost Optimization