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:
The server generates a random secret and shares it with the user once, usually via QR code.
Both the server and the authenticator app derive a 6-digit code from
HMAC(secret, current_30s_time_window).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/authenticateonly. 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: falsealone — that flag protects against accidental leakage in queries, not a database compromise.Rate-limit
/auth/loginand/auth/2fa/authenticate. Both are brute-force surfaces;@nestjs/throttlerhandles 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.
Originally published on ZyVOP
💡 For more articles like this, subscribe to the ZyVOP newsletter!
Top comments (0)