# 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](https://datatracker.ietf.org/doc/html/rfc6238). 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:

```typescript
// 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

```typescript
@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.

```typescript
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:

```javascript
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

```typescript
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.

```typescript
@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.)

```typescript
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:

```typescript
@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

```typescript
@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:

```typescript
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:

```bash
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:

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

```

Then start the API:

```bash
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.

```bash
# 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 }

```

```bash
# 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.

```

```bash
# 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>" }

```

```bash
# 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:

```bash
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](https://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/2fa)

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.

---

*Published via [ZyVOP](https://zyvop.com/implementing-two-factor-authentication-totp-in-nestjs-with-full-source-code-xg82n?utm_source=hashnode&utm_medium=crosspost&utm_campaign=syndication) — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.*
