Designing 2FA (TOTP) with Claude Code: Google Authenticator, Backup Codes, Recovery
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));
});