DEV Community

Wesley Israel
Wesley Israel

Posted on

How to Create Two‑Factor Authentication (2FA) and Best Practices

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 or expo-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 like otpauth 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)