Multi-Factor Auth

intermediate

Multi-factor authentication with TOTP, QR code generation, and backup codes.

mfa2fatotpsecurityauthentication
Tested on201619TS5.9
$ bunx sinew add security/mfa
Interactive demo coming soon

1The Problem

Password-only authentication is vulnerable to:

  • Credential stuffing attacks
  • Phishing
  • Password reuse across sites
  • Brute force attempts

2The Solution

Add TOTP (Time-based One-Time Passwords) as a second factor using the otpauth library. Includes QR code generation for authenticator apps and backup codes for recovery.

3Files

lib/mfa/totp.ts

lib/mfa/totp.tsTypeScript
import { TOTP, Secret } from "otpauth";
import crypto from "crypto";

const MFA_ISSUER = process.env.NEXT_PUBLIC_APP_NAME || "MyApp";

// Generate a new TOTP secret
export function generateSecret(): string {
  const secret = new Secret({ size: 20 });
  return secret.base32;
}

// Generate TOTP URI for QR code
export function generateTOTPUri(secret: string, accountName: string): string {
  const totp = new TOTP({
    issuer: MFA_ISSUER,
    label: accountName,
    algorithm: "SHA1",
    digits: 6,
    period: 30,
    secret: Secret.fromBase32(secret),
  });
  return totp.toString();
}

// Verify a TOTP code
export function verifyTOTPCode(secret: string, code: string): boolean {
  const totp = new TOTP({
    algorithm: "SHA1",
    digits: 6,
    period: 30,
    secret: Secret.fromBase32(secret),
  });
  // window: 1 accepts +/-1 period (90s total acceptance window).
  return totp.validate({ token: code, window: 1 }) !== null;
}

// Server-side key used to HMAC backup codes (set MFA_BACKUP_SECRET, >= 32 chars).
function getBackupCodeKey(): Buffer {
  const key = process.env.MFA_BACKUP_SECRET;
  if (!key || key.length < 32) {
    throw new Error("MFA_BACKUP_SECRET is required (>= 32 chars)");
  }
  return Buffer.from(key);
}

// Generate backup codes: 8 random bytes (64 bits) each.
export function generateBackupCodes(count = 10): string[] {
  const codes: string[] = [];
  for (let i = 0; i < count; i++) {
    const code = crypto.randomBytes(8).toString("hex").toUpperCase();
    codes.push(`${code.slice(0, 4)}-${code.slice(4, 8)}-${code.slice(8, 12)}-${code.slice(12)}`);
  }
  return codes;
}

// Hash a backup code with a keyed HMAC (not bare SHA-256), so a DB leak alone
// cannot be rainbow-tabled back to the codes.
export function hashBackupCode(code: string): string {
  return crypto
    .createHmac("sha256", getBackupCodeKey())
    .update(code.replace(/-/g, "").toUpperCase())
    .digest("hex");
}

lib/mfa/qrcode.ts

lib/mfa/qrcode.tsTypeScript
import QRCode from "qrcode";

export async function generateQRCodeDataURL(uri: string): Promise<string> {
  return QRCode.toDataURL(uri, {
    width: 256,
    margin: 2,
    color: { dark: "#000000", light: "#FFFFFF" },
  });
}

app/api/mfa/setup/route.ts

app/api/mfa/setup/route.tsTypeScript
import { NextRequest, NextResponse } from "next/server";
import {
  generateSecret,
  generateTOTPUri,
  generateBackupCodes,
  hashBackupCode,
} from "@/lib/mfa/totp";
import { generateQRCodeDataURL } from "@/lib/mfa/qrcode";

export async function POST(req: NextRequest) {
  // SECURITY: identify the user from your verified session, not from request
  // headers. The "x-user-id"/"x-user-email" headers below are client-controlled
  // and let a caller set up MFA for any account, so they MUST be populated (and
  // any inbound copy stripped) by your auth layer before this handler runs.
  // Replace with your own session lookup, e.g.:
  //   const session = await auth();
  //   const userId = session?.user?.id;
  //   const userEmail = session?.user?.email;
  const userId = req.headers.get("x-user-id");
  const userEmail = req.headers.get("x-user-email");

  if (!userId || !userEmail) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const secret = generateSecret();
  const uri = generateTOTPUri(secret, userEmail);
  const qrCode = await generateQRCodeDataURL(uri);
  const backupCodes = generateBackupCodes(10);

  // TODO: Store secret and hashed backup codes in database
  // const hashedCodes = backupCodes.map(hashBackupCode);

  return NextResponse.json({
    secret,
    qrCode,
    backupCodes, // Show to user once
  });
}

4Dependencies

$ bun add otpauth qrcode

5Configuration

Environment Variables

| Variable | Description | Required | | ---------------------- | ------------------------------------------- | -------- | | NEXT_PUBLIC_APP_NAME | App name shown in authenticator | No | | MFA_BACKUP_SECRET | Key (>= 32 chars) used to HMAC backup codes | Yes |

Database Schema

6Usage

Setup Flow

  1. User initiates MFA setup
  2. Generate secret and QR code
  3. User scans QR code with authenticator app
  4. User enters code to verify setup
  5. Store the secret in the database, encrypting it at rest (see the data-encryption pattern); store backup codes as the keyed HMAC hashes from hashBackupCode

Verify During Login

import { verifyTOTPCode } from "@/lib/mfa/totp";

// After password verification
const mfaSecret = await getUserMFASecret(userId);
if (mfaSecret) {
  const isValid = verifyTOTPCode(mfaSecret, userInputCode);
  if (!isValid) {
    return { error: "Invalid MFA code" };
  }
}
TypeScript

Related patterns