I Built Production-Ready 2FA (TOTP) in Node.js + Angular – Here’s How

Ever wondered how apps like Google Authenticator work? I recently implemented Two-Factor Authentication (TOTP) in a full-stack app, and I’m sharing everything I learned. No hand-waving, no “figure out the rest yourself” – just complete, working code.

What You’ll Build

By the end of this tutorial, you’ll have:

  • Complete backend 2FA system using Speakeasy and JWT
  • Angular frontend with QR code generation
  • Seamless integration with existing auth flow
  • Production-grade security with temporary tokens

Stack: Node.js 20, Angular 20, Speakeasy 2.0, PostgreSQL 16

The User Journey

Before diving into code, here’s what we’re building:

Enabling 2FA:

  1. User toggles 2FA in profile settings
  2. Backend generates a secret key
  3. Frontend displays QR code
  4. User scans with Google Authenticator (or similar)
  5. User enters 6-digit code to verify
  6. Done! 2FA is enabled

Logging In with 2FA:

  1. Enter email/password
  2. If valid + 2FA enabled → get temporary token (5 min expiry)
  3. Redirect to 2FA page
  4. Enter 6-digit code from authenticator
  5. Backend verifies → issue full session tokens
  6. You’re in!

Why This Approach Works

Three key design decisions make this secure:

  1. Temporary 2FA tokens – 5-minute expiry prevents replay attacks
  2. Separate 2FA cookie – Isolates pre-login state from authenticated sessions
  3. Token version increment – Invalidates all sessions when 2FA changes

Part 1: Database Setup

Add these fields to your User model (using Prisma):

model User {
  id               String    @id @default(cuid())
  email            String    @unique
  password         String?
  tokenVersion     Int       @default(0)

  // 2FA fields
  twoFactorEnabled Boolean   @default(false)
  twoFactorSecret  String?   // base32-encoded secret

  // ... other fields
}

Run migration:

npx prisma migrate dev --name add_2fa_fields

Why tokenVersion? When users enable/disable 2FA, we increment this to invalidate all existing refresh tokens. Forces re-authentication across all devices.

Part 2: JWT Token Types

We need a special JWT type for the pre-authenticated state (after password, before TOTP).

backend/src/utils/jwt.ts

import jwt, { Secret } from 'jsonwebtoken';

const TWOFA_SECRET: Secret = process.env.JWT_2FA_SECRET as Secret;
const TWOFA_EXPIRES = '5m'; // Short-lived for security

export type TwoFAPayload = { 
  sub: string;      // user ID
  n: string;        // nonce (prevents replay)
  type: 'twofa' 
};

export function signTwoFAToken(userId: string, nonce: string) {
  return jwt.sign(
    { sub: userId, n: nonce, type: 'twofa' } as TwoFAPayload,
    TWOFA_SECRET,
    { expiresIn: TWOFA_EXPIRES }
  );
}

export function verifyTwoFAToken(token: string): TwoFAPayload {
  const payload = jwt.verify(token, TWOFA_SECRET) as TwoFAPayload;
  if (payload.type !== 'twofa') {
    throw new Error('Invalid token type');
  }
  return payload;
}

// You should already have these for your normal auth:
// - signAccessToken(userId)
// - signRefreshToken(userId, tokenVersion)

Security note: The nonce (n) ensures each 2FA token is unique, preventing replay attacks even within the 5-minute window.

Part 3: Backend Controller

This is the heart of the implementation. Five functions handle the complete 2FA lifecycle.

backend/src/controllers/twofaController.ts

import { Request, Response, NextFunction } from 'express';
import speakeasy from 'speakeasy';
import { randomUUID } from 'crypto';
import { prisma } from '../prisma';
import { verifyTwoFAToken, signAccessToken, signRefreshToken } from '../utils/jwt';

// 1. Generate TOTP secret
export async function twofaSetup(req: Request, res: Response, next: NextFunction) {
  try {
    const userId = req.userId!; // From your auth middleware
    const user = await prisma.user.findUnique({
      where: { id: userId },
      select: { email: true },
    });

    const label = `YourApp:${user.email}`;
    const secret = speakeasy.generateSecret({ name: label });

    return res.json({
      secret: secret.base32,
      otpauthUrl: secret.otpauth_url, // For QR code
    });
  } catch (err) {
    next(err);
  }
}

// 2. Check if user has 2FA enabled
export async function twofaStatus(req: Request, res: Response, next: NextFunction) {
  try {
    const user = await prisma.user.findUnique({
      where: { id: req.userId! },
      select: { twoFactorEnabled: true },
    });
    res.json({ enabled: !!user?.twoFactorEnabled });
  } catch (err) {
    next(err);
  }
}

// 3. Verify code and enable 2FA
export async function twofaEnable(req: Request, res: Response, next: NextFunction) {
  try {
    const { secret, token } = req.body;

    if (!secret || !token) {
      return res.status(400).json({ message: 'secret and token required' });
    }

    // Verify TOTP code
    const verified = speakeasy.totp.verify({
      secret,
      encoding: 'base32',
      token,
      window: 1, // Allows 30-second clock drift
    });

    if (!verified) {
      return res.status(400).json({ message: 'Invalid code' });
    }

    // Save and enable
    await prisma.user.update({
      where: { id: req.userId! },
      data: {
        twoFactorEnabled: true,
        twoFactorSecret: secret,
        tokenVersion: { increment: 1 }, // Invalidate sessions
      },
    });

    return res.json({ message: '2FA enabled' });
  } catch (err) {
    next(err);
  }
}

// 4. Disable 2FA
export async function twofaDisable(req: Request, res: Response, next: NextFunction) {
  try {
    await prisma.user.update({
      where: { id: req.userId! },
      data: {
        twoFactorEnabled: false,
        twoFactorSecret: null,
        tokenVersion: { increment: 1 },
      },
    });

    return res.json({ message: '2FA disabled' });
  } catch (err) {
    next(err);
  }
}

// 5. Verify TOTP during login (CRITICAL)
export async function twofaVerifyLogin(req: Request, res: Response, next: NextFunction) {
  try {
    const { code } = req.body;

    // Get temporary 2FA token from cookie
    const token = req.cookies?.twofa_token;
    if (!token) {
      return res.status(401).json({ message: 'Session expired' });
    }

    // Verify temporary token
    let payload;
    try {
      payload = verifyTwoFAToken(token);
    } catch {
      return res.status(401).json({ message: 'Session expired' });
    }

    // Get user
    const user = await prisma.user.findUnique({
      where: { id: payload.sub },
      select: {
        id: true,
        twoFactorEnabled: true,
        twoFactorSecret: true,
        tokenVersion: true,
      },
    });

    if (!user?.twoFactorEnabled || !user.twoFactorSecret) {
      return res.status(401).json({ message: '2FA not enabled' });
    }

    // Verify TOTP code
    const verified = speakeasy.totp.verify({
      secret: user.twoFactorSecret,
      encoding: 'base32',
      token: code,
      window: 1,
    });

    if (!verified) {
      return res.status(400).json({ message: 'Invalid code' });
    }

    // Success! Issue full session tokens
    const access = signAccessToken(user.id);
    const refresh = signRefreshToken(user.id, user.tokenVersion);

    const isProd = process.env.NODE_ENV === 'production';
    const cookieOpts = {
      httpOnly: true,
      secure: isProd,
      sameSite: isProd ? ('none' as const) : ('lax' as const),
      path: '/',
    };

    res.cookie('access_token', access, {
      ...cookieOpts,
      maxAge: 15 * 60 * 1000, // 15 min
    });

    res.cookie('refresh_token', refresh, {
      ...cookieOpts,
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    });

    // Clear temporary token
    res.clearCookie('twofa_token', cookieOpts);

    return res.json({ message: '2FA verified' });
  } catch (err) {
    next(err);
  }
}

Key insight: The window: 1 parameter in speakeasy.totp.verify() is crucial. It allows for 30 seconds of clock drift between server and client, which is essential for real-world usage.

Part 4: Routes + Login Integration

Add routes (backend/src/routes/authRoutes.ts)

import { Router } from 'express';
import { requireAuth } from '../middleware/requireAuth';
import * as twofa from '../controllers/twofaController';

const router = Router();

router.post('/2fa/setup', requireAuth, twofa.twofaSetup);
router.post('/2fa/enable', requireAuth, twofa.twofaEnable);
router.post('/2fa/disable', requireAuth, twofa.twofaDisable);
router.get('/2fa/status', requireAuth, twofa.twofaStatus);
router.post('/2fa/verify-login', twofa.twofaVerifyLogin); // No auth needed

export default router;

Modify your login controller

Add this check after password verification:

import { randomUUID } from 'crypto';
import { signTwoFAToken } from '../utils/jwt';

export async function login(req: Request, res: Response) {
  const { email, password } = req.body;

  // Validate credentials (your existing code)
  const user = await validateUser(email, password);
  if (!user) return res.status(401).json({ message: 'Invalid credentials' });

  const dbUser = await prisma.user.findUnique({
    where: { id: user.id },
    select: { 
      id: true, 
      tokenVersion: true, 
      twoFactorEnabled: true 
    },
  });

  // Check for 2FA
  if (dbUser.twoFactorEnabled) {
    const nonce = randomUUID();
    const twofaToken = signTwoFAToken(user.id, nonce);

    res.cookie('twofa_token', twofaToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 5 * 60 * 1000, // 5 minutes
      path: '/',
    });

    return res.json({ requires2FA: true }); // Signal frontend
  }

  // Normal login flow (issue access/refresh tokens)
  // ... your existing code
}

Part 5: Frontend – Auth Service

frontend/src/app/services/auth.service.ts

import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, tap } from 'rxjs';
import { environment } from '../../environments/environment';

@Injectable({ providedIn: 'root' })
export class AuthService {
  private http = inject(HttpClient);
  private base = environment.apiUrl + '/auth';

  private _isAuthenticated$ = new BehaviorSubject<boolean>(false);
  isAuthenticated$ = this._isAuthenticated$.asObservable();

  login(data: { email: string; password: string }): Observable<any> {
    return this.http.post<any>(`${this.base}/login`, data, { 
      withCredentials: true 
    }).pipe(
      tap((resp) => {
        // Only set authenticated if 2FA not required
        this._isAuthenticated$.next(resp && !resp.requires2FA);
      })
    );
  }

  twofaVerify(code: string): Observable<{ message: string }> {
    return this.http.post<{ message: string }>(
      `${this.base}/2fa/verify-login`, 
      { code }, 
      { withCredentials: true }
    ).pipe(
      tap(() => this._isAuthenticated$.next(true))
    );
  }

  twofaSetup(): Observable<{ secret: string; otpauthUrl: string }> {
    return this.http.post<{ secret: string; otpauthUrl: string }>(
      `${this.base}/2fa/setup`, 
      {}, 
      { withCredentials: true }
    );
  }

  twofaEnable(secret: string, token: string): Observable<{ message: string }> {
    return this.http.post<{ message: string }>(
      `${this.base}/2fa/enable`, 
      { secret, token }, 
      { withCredentials: true }
    );
  }

  twofaDisable(): Observable<{ message: string }> {
    return this.http.post<{ message: string }>(
      `${this.base}/2fa/disable`, 
      {}, 
      { withCredentials: true }
    );
  }

  twofaStatus(): Observable<{ enabled: boolean }> {
    return this.http.get<{ enabled: boolean }>(
      `${this.base}/2fa/status`, 
      { withCredentials: true }
    );
  }
}

Part 6: Frontend – 2FA Verification Page

This is the page users land on after entering their password.

frontend/src/app/pages/two-factor/two-factor.page.ts

import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AuthService } from '../../services/auth.service';

@Component({
  selector: 'app-two-factor',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, RouterLink],
  template: `
    <div class="container">
      <div class="card">
        <h2>Two-Factor Authentication</h2>
        <p>Enter the 6-digit code from your authenticator app</p>

        <form [formGroup]="form" (ngSubmit)="onSubmit()">
          <input 
            type="text" 
            formControlName="code" 
            placeholder="000000" 
            maxlength="6"
            autocomplete="one-time-code"
            autofocus
          />

          <button 
            type="submit" 
            [disabled]="form.invalid || loading"
          >
            {{ loading ? 'Verifying...' : 'Verify' }}
          </button>

          <a routerLink="/login" class="back-link">Back to login</a>

          @if (errorMessage) {
            <div class="error">{{ errorMessage }}</div>
          }
        </form>
      </div>
    </div>
  `,
  styles: [`
    .container {
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      padding: 20px;
    }
    .card {
      max-width: 400px;
      width: 100%;
      padding: 2rem;
      border-radius: 8px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.1);
    }
    input {
      width: 100%;
      padding: 12px;
      font-size: 24px;
      text-align: center;
      letter-spacing: 8px;
      margin: 1rem 0;
    }
    button {
      width: 100%;
      padding: 12px;
      margin-top: 1rem;
    }
    .back-link {
      display: block;
      text-align: center;
      margin-top: 1rem;
    }
    .error {
      color: red;
      margin-top: 1rem;
      text-align: center;
    }
  `]
})
export class TwoFactorPage {
  private auth = inject(AuthService);
  private router = inject(Router);

  form = inject(FormBuilder).group({
    code: ['', [
      Validators.required, 
      Validators.pattern(/^d{6}$/)
    ]]
  });

  loading = false;
  errorMessage = '';

  onSubmit() {
    if (this.form.invalid) return;

    this.loading = true;
    this.errorMessage = '';

    this.auth.twofaVerify(this.form.value.code!).subscribe({
      next: () => {
        this.router.navigate(['/dashboard']);
      },
      error: (err) => {
        this.errorMessage = err?.error?.message || 'Invalid code';
        this.form.reset();
        this.loading = false;
      },
      complete: () => {
        this.loading = false;
      }
    });
  }
}

UX tip: The autocomplete="one-time-code" attribute enables iOS/Android to auto-fill the code from SMS (if you ever add SMS 2FA).

Part 7: Frontend – Profile 2FA Setup

First, install the QR code library:

npm install angularx-qrcode

frontend/src/app/pages/profile/profile.page.ts

import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
import { QRCodeComponent } from 'angularx-qrcode';
import { AuthService } from '../../services/auth.service';

@Component({
  selector: 'app-profile',
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, QRCodeComponent],
  templateUrl: './profile.page.html',
  styleUrls: ['./profile.page.css']
})
export class ProfilePage implements OnInit {
  private auth = inject(AuthService);

  twoFactorEnabled = false;
  setupSecret: string | null = null;
  otpauthUrl: string | null = null;

  twofaForm = inject(FormBuilder).group({
    code: ['', [
      Validators.required, 
      Validators.pattern(/^d{6}$/)
    ]]
  });

  ngOnInit() {
    this.refreshStatus();
  }

  refreshStatus() {
    this.auth.twofaStatus().subscribe({
      next: ({ enabled }) => {
        this.twoFactorEnabled = enabled;
      }
    });
  }

  start2FASetup() {
    this.auth.twofaSetup().subscribe({
      next: ({ secret, otpauthUrl }) => {
        this.setupSecret = secret;
        this.otpauthUrl = otpauthUrl;
      },
      error: () => {
        alert('Failed to start setup');
      }
    });
  }

  confirm2FAEnable() {
    if (!this.setupSecret || this.twofaForm.invalid) return;

    this.auth.twofaEnable(
      this.setupSecret, 
      this.twofaForm.value.code!
    ).subscribe({
      next: () => {
        alert('2FA enabled successfully!');
        this.cancel();
        this.refreshStatus();
      },
      error: (err) => {
        alert(err?.error?.message || 'Verification failed');
      }
    });
  }

  cancel() {
    this.setupSecret = null;
    this.otpauthUrl = null;
    this.twofaForm.reset();
    this.refreshStatus();
  }

  disable2FA() {
    if (!confirm('Are you sure you want to disable 2FA?')) {
      this.twoFactorEnabled = true; // Revert toggle
      return;
    }

    this.auth.twofaDisable().subscribe({
      next: () => {
        alert('2FA disabled');
        this.cancel();
      },
      error: () => {
        alert('Failed to disable 2FA');
        this.twoFactorEnabled = true; // Revert
      }
    });
  }
}

profile.page.html

<div class="profile-container">
  <section class="twofa-section">
    <h3>Two-Factor Authentication</h3>
    <p>Add an extra layer of security to your account</p>

    <label class="toggle-label">
      <input 
        type="checkbox" 
        [checked]="twoFactorEnabled"
        (change)="twoFactorEnabled ? disable2FA() : start2FASetup()"
      />
      Enable 2FA
    </label>

    @if (setupSecret && otpauthUrl) {
      <div class="setup-modal">
        <h4>Scan QR Code</h4>
        <qrcode 
          [qrdata]="otpauthUrl" 
          [width]="200" 
          [errorCorrectionLevel]="'M'"
        ></qrcode>

        <p>Or enter this code manually:</p>
        <code>{{ setupSecret }}</code>

        <form [formGroup]="twofaForm" (ngSubmit)="confirm2FAEnable()">
          <input 
            type="text" 
            formControlName="code" 
            placeholder="000000" 
            maxlength="6"
          />
          <div class="button-group">
            <button 
              type="submit" 
              [disabled]="twofaForm.invalid"
            >
              Verify & Enable
            </button>
            <button 
              type="button" 
              (click)="cancel()"
            >
              Cancel
            </button>
          </div>
        </form>
      </div>
    }
  </section>
</div>

Testing Your Implementation

Here’s your complete testing checklist:

  1. Enable 2FA:

    • [ ] Toggle 2FA in profile
    • [ ] QR code appears
    • [ ] Scan with Google Authenticator
    • [ ] Enter code successfully
    • [ ] 2FA is enabled
  2. Login Flow:

    • [ ] Logout
    • [ ] Login with email/password
    • [ ] Redirected to 2FA page
    • [ ] Enter valid code → success
    • [ ] Enter invalid code → error
  3. Edge Cases:

    • [ ] Try expired 2FA token (wait 6 minutes)
    • [ ] Try code with wrong time sync
    • [ ] Disable 2FA
    • [ ] Login without 2FA prompt
  4. Security:

    • [ ] Old refresh tokens invalid after enabling 2FA
    • [ ] Old refresh tokens invalid after disabling 2FA
    • [ ] Can’t reuse 2FA temp token

Common Issues & Fixes

“Invalid 2FA code” with correct code

Problem: Time sync issue between server and client device.

Fix: Increase the window parameter:

speakeasy.totp.verify({
  secret: user.twoFactorSecret,
  encoding: 'base32',
  token: code,
  window: 2, // Increased from 1
});

This allows for 60 seconds of clock drift instead of 30.

“2FA session expired”

Problem: User took more than 5 minutes to enter code.

Fix: Either increase the expiry time or add a “Resend” button to restart the flow.

QR code not displaying

Problem: Missing QRCodeComponent import or wrong data format.

Fix: Verify:

  • angularx-qrcode is installed
  • QRCodeComponent is in imports array
  • otpauthUrl format is otpauth://totp/...

CORS issues with cookies

Problem: Cookies not being set in development.

Fix: For localhost, use:

secure: false,
sameSite: 'lax'

For production with different domains, use:

secure: true,
sameSite: 'none'

Production Security Checklist

Before deploying, implement these additional security measures:

  1. Rate Limiting
   // Limit 2FA verification attempts
   // 5 attempts per 15 minutes per user
  1. Audit Logging
   // Log all 2FA events:
   // - Setup initiated
   // - 2FA enabled/disabled
   // - Verification attempts (success/failure)
  1. Backup Codes (Recommended)
   // Generate 10 single-use backup codes when enabling 2FA
   // Store hashed in database
  1. Secret Encryption (Highly Recommended)
   // Encrypt twoFactorSecret at rest
   // Use AES-256 with app-level key
  1. Admin Enforcement
   // Force 2FA for admin users
   // Check role on login, require 2FA setup

What We Built

You now have a complete, production-ready 2FA system with:

  • Speakeasy for TOTP generation/verification
  • Temporary JWT tokens for secure pre-auth state
  • QR codes for easy authenticator app setup
  • Token versioning for session invalidation
  • Angular integration with reactive forms

Key Concepts:

  • TOTP (Time-Based One-Time Password)
  • JWT token types and lifecycle
  • Clock drift handling with window parameter
  • Session security with token versioning

Resources

Want to Skip the Setup?

If you’d rather start with a complete, production-ready auth system that includes this 2FA implementation plus email verification, social auth, and more, check out StackInsight Auth Pro – a full Angular 20 SSR + Node.js starter with everything wired up.

Questions? Hit me up in the comments!

Found this helpful? Drop a like and follow for more full-stack tutorials.

Leave a Reply