Designing 2FA (TOTP) with Claude Code: Google Authenticator, Backup Codes, Recovery

Published: (March 11, 2026 at 01:55 AM EDT)
3 min read
Source: Dev.to

Source: Dev.to

Introduction

Passwords alone aren’t enough — implement 2FA with TOTP (Time‑based One‑Time Password). Generate RFC 6238‑compliant implementations compatible with Google Authenticator and Authy using Claude Code.

Two-Factor Authentication Design Rules

TOTP Implementation

  • RFC 6238 compliant (TOTP)
  • Store secrets encrypted in DB (AES‑256‑GCM)
  • Correctly set issuer and account name in QR code
  • Allow ±1 step time drift (30 seconds)

Backup Codes

  • Generate 10 codes of 8 characters each
  • Store BCrypt‑hashed
  • Immediately invalidate used codes
  • Force regeneration after recovery use

Security

  • Rate limit TOTP verification (lock for 15 min after 5 failures)
  • Don’t enable 2FA until setup is confirmed
  • Require current password + 2FA code to disable 2FA
// src/auth/twoFactor.ts
import crypto from 'crypto';
import bcrypt from 'bcrypt';
import { authenticator } from 'otplib';
import QRCode from 'qrcode';
import { prisma } from './prisma';
import { redis } from './redis';
import { ValidationError, TooManyRequestsError } from './errors';
import { decryptSecret } from './crypto';
import { verifyPassword, createTempToken, generateTokenPair, verifyTempToken } from './authHelpers';
import { router } from './router';

const ENCRYPTION_KEY = Buffer.from(process.env.TOTP_ENCRYPTION_KEY!, 'hex');

function encryptSecret(secret: string): string {
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
  const encrypted = Buffer.concat([cipher.update(secret, 'utf8'), cipher.final()]);
  const authTag = cipher.getAuthTag();
  return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
}

export async function setup2FA(userId: string, userEmail: string) {
  const secret = authenticator.generateSecret(20);

  await prisma.twoFactorSetup.deleteMany({ where: { userId } });
  await prisma.twoFactorSetup.create({
    data: {
      userId,
      encryptedSecret: encryptSecret(secret),
      expiresAt: new Date(Date.now() + 600_000), // 10 min
    },
  });

  const otpauthUrl = authenticator.keyuri(
    userEmail,
    process.env.APP_NAME ?? 'MyApp',
    secret,
  );
  const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);

  return { qrCodeDataUrl, manualEntryKey: secret };
}

export async function confirm2FA(userId: string, totpCode: string) {
  const setup = await prisma.twoFactorSetup.findUnique({ where: { userId } });
  if (!setup || setup.expiresAt 
    crypto.randomBytes(4).toString('hex').toUpperCase(),
  );
  const hashedBackupCodes = await Promise.all(
    plainBackupCodes.map((code) => bcrypt.hash(code, 10)),
  );

  await prisma.$transaction(async (tx) => {
    await tx.twoFactorSetup.delete({ where: { userId } });
    await tx.twoFactor.upsert({
      where: { userId },
      create: {
        userId,
        encryptedSecret: setup.encryptedSecret,
        enabledAt: new Date(),
      },
      update: {
        encryptedSecret: setup.encryptedSecret,
        enabledAt: new Date(),
      },
    });
    await tx.backupCode.deleteMany({ where: { userId } });
    await tx.backupCode.createMany({
      data: hashedBackupCodes.map((hash) => ({
        userId,
        codeHash: hash,
        usedAt: null,
      })),
    });
    await tx.user.update({
      where: { id: userId },
      data: { twoFactorEnabled: true },
    });
  });

  return { backupCodes: plainBackupCodes };
}

export async function verify2FA(userId: string, code: string): Promise {
  const rateKey = `2fa:attempts:${userId}`;
  const attempts = await redis.incr(rateKey);
  await redis.expire(rateKey, 900); // 15 min

  if (attempts > 5)
    throw new TooManyRequestsError(
      'Too many failed 2FA attempts. Try again in 15 minutes.',
    );

  const twoFactor = await prisma.twoFactor.findUnique({ where: { userId } });
  if (!twoFactor) throw new ValidationError('2FA not enabled');

  const secret = decryptSecret(twoFactor.encryptedSecret);
  if (authenticator.check(code, secret)) {
    await redis.del(rateKey);
    return;
  }

  const backupCodes = await prisma.backupCode.findMany({
    where: { userId, usedAt: null },
  });
  for (const bc of backupCodes) {
    if (await bcrypt.compare(code, bc.codeHash)) {
      await prisma.backupCode.update({
        where: { id: bc.id },
        data: { usedAt: new Date() },
      });
      await redis.del(rateKey);
      return;
    }
  }

  throw new ValidationError('Invalid 2FA code');
}

// Login flow
router.post('/login', async (req, res) => {
  const user = await verifyPassword(req.body.email, req.body.password);
  if (user.twoFactorEnabled) {
    const tempToken = await createTempToken(user.id, '2fa_pending');
    return res.json({ requiresTwoFactor: true, tempToken });
  }
  res.json(await generateTokenPair(user.id));
});

router.post('/login/2fa', async (req, res) => {
  const userId = await verifyTempToken(req.body.tempToken, '2fa_pending');
  if (!userId) return res.status(401).json({ error: 'Invalid token' });
  await verify2FA(userId, req.body.code);
  res.json(await generateTokenPair(userId));
});
0 views
Back to Blog

Related posts

Read more »