DEV Community

myougaTheAxo
myougaTheAxo

Posted on

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

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.


CLAUDE.md 2FA Rules

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

Generated 2FA Implementation

// src/auth/twoFactor.ts
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) },
  });

  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 < new Date()) throw new ValidationError('2FA setup expired');

  const secret = decryptSecret(setup.encryptedSecret);
  if (!authenticator.check(totpCode, secret)) throw new ValidationError('Invalid TOTP code');

  const plainBackupCodes = Array.from({ length: 10 }, () =>
    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<void> {
  const rateKey = `2fa:attempts:${userId}`;
  const attempts = await redis.incr(rateKey);
  await redis.expire(rateKey, 900);
  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');
}
Enter fullscreen mode Exit fullscreen mode

Login Flow Integration

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

Summary

Design 2FA (TOTP) with Claude Code:

  1. CLAUDE.md — RFC 6238, encrypt secrets, 10 backup codes, 5-attempt rate limit
  2. AES-256-GCM for TOTP secret encryption in DB (plaintext forbidden)
  3. Confirm first TOTP code before activating 2FA
  4. BCrypt backup codes — immediately invalidate used codes

Review 2FA security designs with **Security Pack (¥1,480)* using /security-check at prompt-works.jp*

myouga (@myougatheaxo) — Axolotl VTuber.

Top comments (0)