Multi-Factor Authentication (MFA)
- nestjs
- mfa
- totp
- authentication
- security
- webauthn
- nodejs
- typescript
- backend
Last time in this series, I covered Adding OAuth and Social Login, which let users sign in with an identity they already trust instead of yet another password. That solves the "too many passwords" problem for a slice of your users. It does nothing for the ones who still sign in with email and password, and even OAuth accounts get compromised when a user's Google or GitHub account itself is weak. MFA is the layer that catches both.
This is part of the Full Stack SaaS Masterclass, closing out the authentication half of Module 2. By now the app has JWTs, refresh tokens, and social login. What's missing is a second factor for the accounts that matter most: admins, billing owners, anyone with access to sensitive data.
I want to set expectations early. MFA is a small subsystem, not a single feature flag. It comes with its own storage and its own recovery flow, plus a set of failure modes nobody thinks about until an incident forces the issue: what happens when someone loses their phone.
Why MFA earns its complexity
A password is one secret. If it leaks, in a breach dump, a phishing page, a reused-password attack against another site, the account is compromised the moment an attacker types it in. MFA adds a second, independent factor so a leaked password alone isn't enough. The industry usually buckets factors into three categories: something you know (password), something you have (a phone, a hardware key), and something you are (biometrics). Requiring two different categories is what makes MFA meaningfully harder to bypass than just asking for the password twice.
For a SaaS product, the honest case for MFA rarely starts with "every user needs it on day one." It starts with the asymmetry between account types: takeover on a shared workspace is worse than takeover on a personal blog, because one compromised admin account in a multi-tenant SaaS can expose data across an entire organization, not a single person's data. That asymmetry is why MFA belongs on this roadmap even though most consumer apps ship without it for months.
Where I'd draw the line: make MFA available to everyone, require it for roles that can do damage (owner, admin, billing), and let organizations enforce it for their whole workspace once they're mature enough to ask for it. Forcing MFA on every signup from day one adds friction that kills conversion for a product nobody has decided to trust yet.
TOTP is the default, and here's the actual mechanism
Time-based One-Time Password (TOTP, RFC 6238) is the standard second factor for a reason. It needs no SMS gateway, no carrier dependency, and no per-message cost, and it works with any authenticator app (Google Authenticator, Authy, 1Password). The mechanism is simple enough to reason about. Server and client share a secret at enrollment time. Both sides independently compute HMAC(secret, current_time_step), truncate it to six digits, and if the two six-digit codes match within a small clock-drift window, the user has proven they hold the secret.
That shared secret is the entire trust model, so how you store it matters as much as how you generate it. Here's an enrollment and verification flow with otplib, which is a solid, actively maintained TOTP library for Node:
npm install otplib qrcode
// src/mfa/mfa.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { authenticator } from 'otplib';
import * as qrcode from 'qrcode';
import { randomBytes, createHash } from 'crypto';
import { EncryptionService } from '../common/encryption.service';
import { UsersRepository } from '../users/users.repository';
@Injectable()
export class MfaService {
constructor(
private readonly usersRepository: UsersRepository,
private readonly encryptionService: EncryptionService,
) {}
async beginEnrollment(userId: string, email: string) {
const secret = authenticator.generateSecret();
const otpauthUrl = authenticator.keyuri(email, 'YourSaaS', secret);
// Store encrypted and unconfirmed until the user verifies a code.
await this.usersRepository.setPendingMfaSecret(
userId,
this.encryptionService.encrypt(secret),
);
const qrCodeDataUrl = await qrcode.toDataURL(otpauthUrl);
return { qrCodeDataUrl, manualEntryKey: secret };
}
async confirmEnrollment(userId: string, code: string) {
const user = await this.usersRepository.findById(userId);
if (!user?.pendingMfaSecret) {
throw new UnauthorizedException('No pending MFA enrollment');
}
const secret = this.encryptionService.decrypt(user.pendingMfaSecret);
const isValid = authenticator.verify({ token: code, secret });
if (!isValid) {
throw new UnauthorizedException('Invalid verification code');
}
const backupCodes = this.generateBackupCodes(10);
const hashedCodes = backupCodes.map((c) => createHash('sha256').update(c).digest('hex'));
await this.usersRepository.activateMfa(userId, {
secret: user.pendingMfaSecret,
backupCodeHashes: hashedCodes,
});
// Return the plaintext codes exactly once. They cannot be retrieved again.
return { backupCodes };
}
async verifyLoginChallenge(userId: string, code: string): Promise<boolean> {
const user = await this.usersRepository.findById(userId);
if (!user?.mfaSecret) return false;
const secret = this.encryptionService.decrypt(user.mfaSecret);
const validTotp = authenticator.verify({ token: code, secret });
if (validTotp) return true;
const hashedInput = createHash('sha256').update(code).digest('hex');
const matchedBackupCode = user.backupCodeHashes?.includes(hashedInput);
if (matchedBackupCode) {
await this.usersRepository.consumeBackupCode(userId, hashedInput);
return true;
}
return false;
}
private generateBackupCodes(count: number): string[] {
return Array.from({ length: count }, () =>
randomBytes(5).toString('hex').toUpperCase(),
);
}
}
A few things in that code matter more than they look. The secret is encrypted at rest, not just hashed, because unlike a password, you need the plaintext secret back to run the TOTP algorithm every time someone logs in. That means your database is only as safe as your encryption key, so that key belongs in a secrets manager, not an environment file sitting next to your source. Backup codes, by contrast, are hashed the same way passwords are, because you never need the plaintext back once they're generated. You show them to the user exactly once, at enrollment, and never again.
Wiring MFA into the login flow
The tricky part is the two-step login flow that MFA forces on you, not verifying a single TOTP code. A normal login exchanges credentials for a token in one request. An MFA-enabled login has to pause in the middle: verify the password, then ask for the second factor, then only after both succeed, issue the actual session token.
// src/auth/auth.service.ts (excerpt)
async login(email: string, password: string) {
const user = await this.validateCredentials(email, password);
if (user.mfaEnabled) {
const mfaChallengeToken = await this.issueMfaChallengeToken(user.id);
return { mfaRequired: true, mfaChallengeToken };
}
return this.issueSessionTokens(user);
}
async completeMfaChallenge(mfaChallengeToken: string, code: string) {
const userId = await this.verifyMfaChallengeToken(mfaChallengeToken);
const isValid = await this.mfaService.verifyLoginChallenge(userId, code);
if (!isValid) {
throw new UnauthorizedException('Invalid or expired code');
}
const user = await this.usersRepository.findById(userId);
return this.issueSessionTokens(user);
}
The mfaChallengeToken is a short-lived, single-purpose token (a few minutes, stored in Redis with the user ID as the value) that proves the password step already succeeded, without granting any actual access. This is the piece teams skip when they bolt MFA on quickly: without it, you either have to re-send the password on the second request, which is a needless replay of a secret over the wire, or you skip the binding entirely and let anyone who guesses a valid TOTP code for a known username log in. Either way, you've defeated the point of tying the two factors to the same session.
Tradeoffs: TOTP, SMS, and WebAuthn
TOTP isn't the only option, and the alternatives deserve an honest look. SMS-based OTP is the most familiar to users but carries a real, well-documented weakness: SIM-swapping. An attacker who convinces (or bribes) a carrier to port a victim's number gets every SMS code sent to that number afterward. It also costs money per message and depends on carrier delivery, which fails at inconvenient times. I'd only reach for SMS as a fallback channel for account recovery, never as the primary factor, and I'd say so plainly to any team reaching for it as a first choice out of familiarity.
WebAuthn and passkeys (FIDO2) are the strongest option available today: a hardware-backed key pair where the private key never leaves the user's device or security key, and the protocol is phishing-resistant because the browser binds the credential to the origin. The cost is integration complexity and a steeper mental model for both engineering and support, since "insert your security key" is a less familiar UX than "type six digits." For a SaaS in its early phase, TOTP is the pragmatic default: near-universal authenticator app support, no per-message cost, and a well-understood implementation. WebAuthn is worth adding once you have security-conscious enterprise customers asking for it specifically, which is a real signal, not a hypothetical one.
Production pitfalls
No account recovery path. Every MFA rollout eventually meets a user who lost their phone and never saved their backup codes. Without a recovery flow, that's a support ticket that turns into a manual database edit, which is exactly the kind of ad hoc access you don't want normalized. Build an explicit, audited recovery process (identity verification plus a support-initiated MFA reset) before you need it under pressure.
Rate limiting the verification endpoint. A six-digit TOTP code has a million possible values, and a 30-second validity window. Without rate limiting on the verification attempt, that's a brute-forceable target. Lock the endpoint down hard, a handful of attempts per challenge token, backed by the same Redis instance you're already using for the challenge token itself.
Trusting client-side clocks. TOTP depends on both sides agreeing roughly on the time. Server clock drift is the usual cause of "valid codes rejected" support tickets, not user error. Keep your servers on NTP and allow a small window (one step before and after) for clock drift, which otplib's verify already accounts for by default.
Treating MFA as done once enrollment ships. The harder half is the operational surface. What happens on password reset? Should it also require MFA re-verification, or does it become a bypass? What happens when support needs to disable MFA for a locked-out user? And how do you log and alert on MFA disablement events, since that's exactly the action an attacker takes right before doing damage undetected?
Key takeaways
- MFA closes the gap a single leaked password leaves open; require it for privileged roles first, make it available to everyone else.
- TOTP is the pragmatic default: no per-message cost, works with any authenticator app, and doesn't depend on carrier SMS delivery.
- Encrypt TOTP secrets at rest since you need the plaintext back to verify codes; hash backup codes since you never do.
- Bind the password step to the MFA step with a short-lived, single-purpose challenge token, never re-send the password on the second request.
- Build an audited account recovery flow before you ship MFA, not after the first support ticket forces you to improvise one.
- Rate limit verification attempts hard; a six-digit code is brute-forceable without it.
Frequently asked questions
Should every user be required to enable MFA?
Not necessarily from day one. Make it available to everyone, require it for roles that can cause real damage (owners, admins, billing), and let organizations enforce it workspace-wide once they're mature enough to request it. Forcing it on every new signup adds friction before you've earned the user's trust.
Is SMS-based MFA secure enough for production?
It's better than nothing but weaker than TOTP or WebAuthn because of SIM-swapping risk and carrier dependency. Use it as a fallback recovery channel, not as the primary second factor.
How should I store the TOTP secret?
Encrypted at rest, not hashed, because the server needs the plaintext secret back to compute the verification code on every login. That means the encryption key itself needs to live in a proper secrets manager, separate from the database it protects.
What happens if a user loses access to their authenticator app?
This is what backup codes and a recovery flow are for. Hash backup codes the same way you hash passwords, show them once at enrollment, and build an explicit, audited support process for the case where those are gone too.
Is WebAuthn worth the integration effort over TOTP?
It's the strongest option available and phishing-resistant by design, but it's more complex to integrate and less familiar to users. TOTP is a reasonable default for most SaaS products; add WebAuthn when security-conscious enterprise customers specifically ask for it.
How do I prevent brute-forcing a six-digit TOTP code?
Rate limit the verification endpoint aggressively, a handful of attempts per challenge token, and invalidate the challenge token after too many failures. The short validity window helps, but it's not a substitute for rate limiting.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.