Two‑factor authentication (2FA) is no longer optional for modern web and mobile applications. Passwords are easy to guess, steal or reuse, and API endpoints are under constant attack (some sources estimate over 90 k attacks on APIs per minute[1]). By adding a second factor—such as a time‑based one‑time password (TOTP), hardware token or passkey—you drastically reduce the risk of credential stuffing, phishing, session hijacking and other common threats. In this article you will learn why 2FA matters, how to implement it in a Node.js/TypeScript backend and integrate it with React/Next.js, and what security practices you should follow to avoid common pitfalls.
Why 2FA matters
Two‑factor authentication is a specific type of multi‑factor authentication requiring two distinct forms of identification[1]. The most common combination is “something you know” (password or PIN) and “something you have” (smartphone running an authenticator app). 2FA mitigates several attack vectors:
- Credential theft and replay attacks – Even if a password is compromised, an attacker cannot log in without the second factor.
- Phishing and social engineering – OTPs expire quickly and cannot be reused.
- Brute‑force and credential stuffing – Rate‑limiting on OTP verification makes automated attacks impractical[3].
2FA is also simple and cost‑effective for end users. Most authenticator apps (Google Authenticator, Authy, Microsoft Authenticator) are free and widely available, and scanning a QR code is easier than typing complex passwords[1].
Recommended 2FA strategies
The OWASP Multifactor Authentication cheat sheet outlines several high‑level recommendations suitable for most applications[2]:
- Require some form of MFA for all users.
- Provide users the option to enable MFA using TOTP.
- Always enforce MFA for administrators or high‑privilege accounts.
- Implement a secure MFA reset process (recovery codes, support workflow).
- Consider third‑party MFA providers if you lack the resources to build and maintain your own implementation.
Teleport’s Authentication Best Practices add further hardening guidelines[3]:
- Prefer passwordless or hardware‑token (U2F) based 2FA over TOTP or SMS.
- Ensure authenticated sessions are created only after successful 2FA verification.
- Require re‑authentication to change 2FA settings.
- Apply rate‑limiting to failed 2FA attempts.
These recommendations should drive your design regardless of the framework or language you use.
Implementing TOTP 2FA in Node.js / TypeScript
Choosing a library
Historically many tutorials used the speakeasy library to generate and verify TOTPs. However, speakeasy has not been updated in several years, prompting many developers to switch to more actively maintained alternatives. The codevoweb tutorial notes that speakeasy’s maintenance gap is a common pain point and recommends using the otpauth
library instead[1].
We will use the otpauth
package for generating secrets and verifying tokens. You will also need qrcode
to render the QR code used by the authenticator app.
npm install otpauth qrcode
Generating a TOTP secret and QR code
Create a helper module 2fa.ts
that handles generation and verification of TOTP secrets. The example uses TypeScript and assumes you store the secret in your database associated with the user record (ideally encrypted at rest):
// src/utils/twoFactor.ts
import { TOTP } from 'otpauth';
import QRCode from 'qrcode';
export interface TwoFactorSecret {
secret: string; // base32 encoded secret
otpauthUrl: string; // URI for authenticator apps
qrCodeDataUrl: string; // data URL for QR code
}
/**
* Generate a TOTP secret for a user and return the secret and QR code.
*/
export async function generateTwoFactorSecret(userEmail: string): Promise<TwoFactorSecret> {
// The label becomes the account name shown in the authenticator app
const totp = new TOTP({
issuer: 'YourAppName',
label: userEmail,
algorithm: 'SHA1', // RFC 6238 default
digits: 6,
period: 30,
});
const secret = totp.secret.base32; // store this in DB (encrypted)
const otpauthUrl = totp.toString();
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
return { secret, otpauthUrl, qrCodeDataUrl };
}
/**
* Verify a user‑supplied TOTP token.
*/
export function verifyTwoFactorToken(token: string, secret: string): boolean {
const totp = new TOTP({ secret });
// Use the built‑in check; allows slight clock drift and returns the matching delta
const delta = totp.validate({ token, window: 1 });
return delta !== null;
}
The generateTwoFactorSecret
function constructs a new TOTP with an issuer (your application name) and label (the user’s email) and returns a Base32 secret and a QR code. The verifyTwoFactorToken
function validates a 6‑digit token using the secret stored in your database and allows a small time window (window: 1
) to account for clock drift.
Express route handlers
Create a set of endpoints to enable, verify and disable 2FA for a user. These handlers assume you have user authentication and session management in place using JWT or session cookies. The 2FA state is stored on the user model (twoFactorEnabled: boolean
, twoFactorSecret: string | null
).
// src/routes/twoFactorRoutes.ts
import { Router, Request, Response } from 'express';
import { generateTwoFactorSecret, verifyTwoFactorToken } from '../utils/twoFactor';
import { prisma } from '../db';
const router = Router();
// Step 1: generate secret and send QR code
router.post('/generate', async (req: Request, res: Response) => {
const userId = req.user.id; // assume user is authenticated
const user = await prisma.user.findUnique({ where: { id: userId } });
if (user?.twoFactorEnabled) {
return res.status(400).json({ message: '2FA already enabled' });
}
const { secret, qrCodeDataUrl } = await generateTwoFactorSecret(user.email);
// Save secret (hashed or encrypted) but don’t mark 2FA as enabled yet
await prisma.user.update({ where: { id: userId }, data: { twoFactorTempSecret: secret } });
res.json({ qrCodeDataUrl });
});
// Step 2: verify token and enable 2FA
router.post('/verify', async (req: Request, res: Response) => {
const { token } = req.body;
const userId = req.user.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.twoFactorTempSecret) {
return res.status(400).json({ message: 'No 2FA setup in progress' });
}
const isValid = verifyTwoFactorToken(token, user.twoFactorTempSecret);
if (!isValid) {
return res.status(400).json({ message: 'Invalid token' });
}
// Move the secret to permanent field and mark as enabled
await prisma.user.update({
where: { id: userId },
data: {
twoFactorSecret: user.twoFactorTempSecret,
twoFactorTempSecret: null,
twoFactorEnabled: true,
},
});
res.json({ message: '2FA enabled' });
});
// Step 3: validate 2FA token during login
router.post('/validate', async (req: Request, res: Response) => {
const { token } = req.body;
const userId = req.user.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.twoFactorEnabled || !user.twoFactorSecret) {
return res.status(400).json({ message: '2FA not enabled' });
}
const isValid = verifyTwoFactorToken(token, user.twoFactorSecret);
if (!isValid) {
// Optionally increment failed attempt counter and rate‑limit
return res.status(400).json({ message: 'Invalid token' });
}
// Mark the session as 2FA‑verified
req.session.twoFactorVerified = true;
res.json({ message: '2FA validation successful' });
});
// Step 4: disable 2FA (require re‑authentication and 2FA)
router.post('/disable', async (req: Request, res: Response) => {
const { token } = req.body;
const userId = req.user.id;
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user?.twoFactorEnabled || !user.twoFactorSecret) {
return res.status(400).json({ message: '2FA not enabled' });
}
// Verify token again before disabling
const isValid = verifyTwoFactorToken(token, user.twoFactorSecret);
if (!isValid) {
return res.status(400).json({ message: 'Invalid token' });
}
await prisma.user.update({
where: { id: userId },
data: { twoFactorEnabled: false, twoFactorSecret: null },
});
res.json({ message: '2FA disabled' });
});
export default router;
Key security considerations
- Never store TOTP secrets in plain text. Encrypt secrets at rest (e.g., with libsodium or AWS KMS) and hash them using HMAC if you only need to verify tokens but not regenerate the QR code.
- Rate‑limit failed verification attempts to mitigate brute‑force attacks[3].
- Mark the session as 2FA‑verified after successful token validation and require this flag before issuing a JWT or setting a session cookie[3].
- Require password+2FA for sensitive operations (updating email, password, or disabling 2FA)[2].
Integrating 2FA into React / Next.js
For the frontend we will build a simple component to enable and verify 2FA. When the user clicks “Enable 2FA,” the client fetches the QR code from /generate
. The user scans the code with an authenticator app and enters the current 6‑digit token to enable 2FA.
// components/TwoFactorSetup.tsx
import React, { useState } from 'react';
import Image from 'next/image';
import axios from 'axios';
export default function TwoFactorSetup() {
const [qrCode, setQrCode] = useState<string | null>(null);
const [token, setToken] = useState('');
const [enabled, setEnabled] = useState(false);
const handleGenerate = async () => {
const res = await axios.post('/api/auth/otp/generate');
setQrCode(res.data.qrCodeDataUrl);
};
const handleVerify = async () => {
try {
await axios.post('/api/auth/otp/verify', { token });
setEnabled(true);
} catch (err) {
alert('Invalid token');
}
};
return (
<div>
{!qrCode && !enabled && (
<button onClick={handleGenerate}>Enable 2FA</button>
)}
{qrCode && !enabled && (
<div>
<p>Scan this QR code with your authenticator app:</p>
<Image src={qrCode} alt="2FA QR code" width={150} height={150} />
<input
type="text"
placeholder="Enter 6‑digit code"
value={token}
onChange={(e) => setToken(e.target.value)}
/>
<button onClick={handleVerify}>Verify & Activate</button>
</div>
)}
{enabled && <p>Two‑factor authentication is enabled on your account.</p>}
</div>
);
}
The server and client exchange JSON only; the actual secret never leaves the server. You should protect these endpoints behind your existing authentication middleware so that only authenticated users can enable or disable 2FA.
React Native considerations
For mobile apps built with React Native, you can either:
- Delegate scanning to the OS by displaying the otpauth URL as a link. On iOS and Android, authenticator apps can intercept
otpauth://
URIs and prompt the user to add the account. - Use a QR‑code scanner library such as
react-native-camera
orexpo-barcode-scanner
to scan the QR code and display a manual entry field for the 6‑digit token.
When handling 2FA on mobile, never embed the TOTP secret in the mobile code. Always fetch and verify tokens via your backend API.
Beyond TOTP: Passkeys and hardware tokens
TOTP is popular because it is easy to implement and broadly supported. However, modern security guidelines recommend passwordless or hardware‑based 2FA whenever possible[3]. Passkeys (FIDO2/WebAuthn) combine the security of possession factors with biometric or PIN verification and provide resistance to phishing[2]. You can integrate passkeys in web applications using the WebAuthn API (navigator.credentials.create
and navigator.credentials.get
) with server‑side libraries like @simplewebauthn/server.
Hardware tokens (U2F) provide similar benefits and can be registered as an additional factor. When designing a 2FA system, offer multiple factor types (TOTP, passkey, hardware token) and let users select their preferred method. For high‑privilege accounts, require at least one hardware token.
Common mistakes and anti‑patterns
- Storing secrets insecurely – Never store TOTP secrets unhashed or unencrypted. Use an HSM or key management service (KMS) to protect secrets.
- Relying on SMS or email for 2FA – SMS OTPs are susceptible to SIM swapping and phishing. Email OTPs can be intercepted. Prefer TOTP, passkeys or hardware tokens[3].
- Missing rate‑limiting – Without rate limits, attackers can brute‑force 2FA tokens. Use algorithms like token bucket or leaky bucket to rate‑limit failed attempts[3].
- No session flag for 2FA – You must track whether a user has completed 2FA. Failing to do so can allow bypassing the second factor by replaying a valid session cookie[3].
- Out‑of‑band MFA reset – Don’t allow users to reset 2FA via email without verifying their identity. Provide recovery codes and require additional verification when resetting MFA[2].
-
Using unmaintained libraries – Choose actively maintained TOTP/FIDO2 libraries. The
speakeasy
library has not been updated for years[1], so prefer alternatives likeotpauth
or@simplewebauthn
. - Prompting for 2FA too often – Excessive prompts frustrate users and lead them to disable MFA. Implement risk‑based authentication to require MFA only in high‑risk scenarios (new device, unusual location)[2].
Summary and next steps
Two‑factor authentication significantly enhances the security of your applications by requiring an additional proof of identity beyond passwords. Following OWASP’s recommendations—enforcing MFA for all users, providing TOTP for convenience, securing the MFA reset process and considering third‑party providers[2]—will put you on solid footing. When implementing 2FA in your Node.js/TypeScript backend, use reliable libraries like otpauth
, secure secrets at rest, track 2FA state in the session and apply rate‑limiting. On the frontend, guide users through scanning a QR code and entering the OTP, and consider supporting passkeys and hardware tokens for a passwordless future[3].
With these practices you can ship a robust 2FA solution that protects both your users and your infrastructure. For further reading, explore the OWASP Multifactor Authentication cheat sheet, Teleport’s Authentication Best Practices, and libraries such as @simplewebauthn/server
for passkey support.
References
[1] CodevoWeb – How to Implement Two‑factor Authentication (2FA) in Node.js. 2023. https://codevoweb.com/two-factor-authentication-2fa-in-nodejs/.
[2] OWASP Cheat Sheet Series – Multifactor Authentication Cheat Sheet. https://cheatsheetseries.owasp.org/cheatsheets/Multifactor_Authentication_Cheat_Sheet.html.
[3] Teleport – Authentication Best Practices (2022). https://goteleport.com/blog/authentication-best-practices/.
Top comments (0)