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:
- User toggles 2FA in profile settings
- Backend generates a secret key
- Frontend displays QR code
- User scans with Google Authenticator (or similar)
- User enters 6-digit code to verify
- Done! 2FA is enabled
Logging In with 2FA:
- Enter email/password
- If valid + 2FA enabled → get temporary token (5 min expiry)
- Redirect to 2FA page
- Enter 6-digit code from authenticator
- Backend verifies → issue full session tokens
- You’re in!
Why This Approach Works
Three key design decisions make this secure:
- Temporary 2FA tokens – 5-minute expiry prevents replay attacks
- Separate 2FA cookie – Isolates pre-login state from authenticated sessions
- 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:
-
Enable 2FA:
- [ ] Toggle 2FA in profile
- [ ] QR code appears
- [ ] Scan with Google Authenticator
- [ ] Enter code successfully
- [ ] 2FA is enabled
-
Login Flow:
- [ ] Logout
- [ ] Login with email/password
- [ ] Redirected to 2FA page
- [ ] Enter valid code → success
- [ ] Enter invalid code → error
-
Edge Cases:
- [ ] Try expired 2FA token (wait 6 minutes)
- [ ] Try code with wrong time sync
- [ ] Disable 2FA
- [ ] Login without 2FA prompt
-
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-qrcodeis installed - QRCodeComponent is in imports array
-
otpauthUrlformat isotpauth://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:
- Rate Limiting
// Limit 2FA verification attempts
// 5 attempts per 15 minutes per user
- Audit Logging
// Log all 2FA events:
// - Setup initiated
// - 2FA enabled/disabled
// - Verification attempts (success/failure)
- Backup Codes (Recommended)
// Generate 10 single-use backup codes when enabling 2FA
// Store hashed in database
- Secret Encryption (Highly Recommended)
// Encrypt twoFactorSecret at rest
// Use AES-256 with app-level key
- 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
windowparameter - 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.
