Two-Factor Authentication
Two-factor authentication (2FA) adds a second verification step after a password login. ideal-auth provides createTOTP for time-based one-time passwords (TOTP) and generateRecoveryCodes / verifyRecoveryCode for backup recovery codes.
This guide covers the complete 2FA lifecycle: setup, login verification, recovery codes, replay protection, and disabling 2FA.
import { createTOTP, createHash, generateRecoveryCodes, encrypt, decrypt,} from 'ideal-auth';
const totp = createTOTP(); // defaults: 6 digits, 30s period, window ±1const hash = createHash();You can customize the TOTP configuration:
const totp = createTOTP({ digits: 6, // number of digits in the code (default: 6) period: 30, // time step in seconds (default: 30) window: 1, // accept codes ±1 time step from current (default: 1)});Encryption key
Section titled “Encryption key”The examples in this guide use encrypt() and decrypt() to protect TOTP secrets at rest. These functions require a secret key passed as the second argument. Generate one and add it to your environment:
bunx ideal-auth encryption-keyCopy the output into your .env file:
ENCRYPTION_KEY="your-generated-key"TOTP setup flow
Section titled “TOTP setup flow”When a user enables 2FA, you need to generate a secret, show them a QR code, and verify they can produce a valid code before storing the secret.
-
Generate a secret and QR URI
// POST /api/2fa/setupexport async function setupTwoFactor(req, res) {const user = await getAuthenticatedUser(req);const secret = totp.generateSecret();const qrUri = totp.generateQrUri({secret,issuer: 'MyApp',account: user.email,});// Store the secret temporarily until the user confirms setup.// Encrypt it so it is not stored in plaintext.const encryptedSecret = await encrypt(secret, process.env.ENCRYPTION_KEY);await db.user.update({where: { id: user.id },data: { pendingTotpSecret: encryptedSecret },});return res.json({ qrUri });} -
User scans the QR code and enters a verification code
The client displays the QR code. The user scans it with their authenticator app and submits the 6-digit code.
-
Verify the code and activate 2FA
// POST /api/2fa/confirmexport async function confirmTwoFactor(req, res) {const user = await getAuthenticatedUser(req);const { code } = req.body;if (!user.pendingTotpSecret) {return res.status(400).json({ error: 'No pending 2FA setup' });}// Decrypt the pending secretconst secret = await decrypt(user.pendingTotpSecret,process.env.ENCRYPTION_KEY,);// Verify the code from the authenticator appif (!totp.verify(code, secret)) {return res.status(400).json({ error: 'Invalid code' });}// Encrypt the confirmed secret for permanent storageconst encryptedSecret = await encrypt(secret, process.env.ENCRYPTION_KEY);// Generate recovery codesconst { codes, hashed } = await generateRecoveryCodes(hash);await db.user.update({where: { id: user.id },data: {totpSecret: encryptedSecret,pendingTotpSecret: null,recoveryCodeHashes: hashed,twoFactorEnabled: true,},});// Show recovery codes to the user ONCEreturn res.json({message: '2FA enabled',recoveryCodes: codes,});}
TOTP login verification
Section titled “TOTP login verification”After a user logs in with their password, you need to check whether they have 2FA enabled. If they do, do not complete the session until 2FA is verified. The pattern uses a short-lived pending cookie to track the intermediate state.
Step 1: Password login sets a pending cookie
Section titled “Step 1: Password login sets a pending cookie”import { createAuth, createHash, createTokenVerifier } from 'ideal-auth';
const hash = createHash();const auth = createAuth({ secret: process.env.SESSION_SECRET, cookie: cookieBridge, hash, resolveUser: (id) => db.user.findUnique({ where: { id } }), resolveUserByCredentials: (creds) => db.user.findUnique({ where: { email: creds.email } }),});
// Token verifier for the 2FA pending state (short-lived)const pendingVerifier = createTokenVerifier({ secret: process.env.SESSION_SECRET, expiryMs: 5 * 60 * 1000, // 5 minutes});
// POST /api/loginexport async function login(req, res) { const { email, password, remember } = req.body; const session = auth();
// Find the user and verify password manually // (don't use attempt() yet because we might need to gate on 2FA) const user = await db.user.findUnique({ where: { email } }); if (!user) { return res.status(401).json({ error: 'Invalid credentials' }); }
const valid = await hash.verify(password, user.password); if (!valid) { return res.status(401).json({ error: 'Invalid credentials' }); }
// If 2FA is enabled, set a pending cookie instead of logging in if (user.twoFactorEnabled) { const pendingToken = pendingVerifier.createToken(String(user.id));
await cookieBridge.set('2fa_pending', pendingToken, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: 300, // 5 minutes });
return res.json({ requiresTwoFactor: true }); }
// No 2FA — log in directly await session.login(user, { remember }); return res.json({ success: true });}Step 2: Verify the TOTP code and complete login
Section titled “Step 2: Verify the TOTP code and complete login”// POST /api/2fa/verifyexport async function verifyTwoFactor(req, res) { const { code, remember } = req.body;
// Read the pending cookie const pendingToken = await cookieBridge.get('2fa_pending'); if (!pendingToken) { return res.status(401).json({ error: 'No pending 2FA session' }); }
// Verify the pending token const result = pendingVerifier.verifyToken(pendingToken); if (!result) { return res.status(401).json({ error: '2FA session expired' }); }
const user = await db.user.findUnique({ where: { id: result.userId } }); if (!user || !user.twoFactorEnabled) { return res.status(401).json({ error: 'Invalid session' }); }
// Decrypt the TOTP secret and verify the code const secret = await decrypt(user.totpSecret, process.env.ENCRYPTION_KEY);
if (!totp.verify(code, secret)) { return res.status(400).json({ error: 'Invalid 2FA code' }); }
// Delete the pending cookie and create a real session await cookieBridge.delete('2fa_pending');
const session = auth(); await session.login(user, { remember });
return res.json({ success: true });}Step 3: Middleware to redirect pending users
Section titled “Step 3: Middleware to redirect pending users”In frameworks with middleware (Next.js, SvelteKit), redirect users who have a pending 2FA cookie to the verification page:
import { NextResponse } from 'next/server';import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) { const hasPending = request.cookies.has('2fa_pending'); const isOnTwoFactorPage = request.nextUrl.pathname === '/2fa'; const isApiRoute = request.nextUrl.pathname.startsWith('/api/');
// User has a pending 2FA cookie but isn't on the 2FA page if (hasPending && !isOnTwoFactorPage && !isApiRoute) { return NextResponse.redirect(new URL('/2fa', request.url)); }
// User is on the 2FA page without a pending cookie if (!hasPending && isOnTwoFactorPage) { return NextResponse.redirect(new URL('/login', request.url)); }
return NextResponse.next();}
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],};import { redirect } from '@sveltejs/kit';import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => { const hasPending = event.cookies.get('2fa_pending'); const isOnTwoFactorPage = event.url.pathname === '/2fa'; const isApiRoute = event.url.pathname.startsWith('/api/');
if (hasPending && !isOnTwoFactorPage && !isApiRoute) { throw redirect(302, '/2fa'); }
if (!hasPending && isOnTwoFactorPage) { throw redirect(302, '/login'); }
return resolve(event);};Recovery codes
Section titled “Recovery codes”Recovery codes allow users to log in when they lose access to their authenticator app. ideal-auth generates 8 codes by default, each formatted as xxxxxxxx-xxxxxxxx.
Generating recovery codes
Section titled “Generating recovery codes”Recovery codes are generated during 2FA setup (shown above) or when a user regenerates them:
import { generateRecoveryCodes, createHash } from 'ideal-auth';
const hash = createHash();const { codes, hashed } = await generateRecoveryCodes(hash);
// codes: ['a1b2c3d4-e5f6a7b8', '...', ...] — show to user// hashed: ['$2a$12$...', '$2a$12$...', ...] — store in databaseYou can pass a custom count:
const { codes, hashed } = await generateRecoveryCodes(hash, 12);Verifying a recovery code
Section titled “Verifying a recovery code”import { verifyRecoveryCode } from 'ideal-auth';
// POST /api/2fa/recoverexport async function recoverWithCode(req, res) { const { code, remember } = req.body;
// Read and verify the pending cookie (same as TOTP verify) const pendingToken = await cookieBridge.get('2fa_pending'); if (!pendingToken) { return res.status(401).json({ error: 'No pending 2FA session' }); }
const result = pendingVerifier.verifyToken(pendingToken); if (!result) { return res.status(401).json({ error: '2FA session expired' }); }
const user = await db.user.findUnique({ where: { id: result.userId } }); if (!user) { return res.status(401).json({ error: 'Invalid session' }); }
// Verify the recovery code const { valid, remaining } = await verifyRecoveryCode( code, user.recoveryCodeHashes, hash, );
if (!valid) { return res.status(400).json({ error: 'Invalid recovery code' }); }
// Update the stored hashes — the used code is removed from `remaining` await db.user.update({ where: { id: user.id }, data: { recoveryCodeHashes: remaining }, });
// Delete the pending cookie and create a real session await cookieBridge.delete('2fa_pending');
const session = auth(); await session.login(user, { remember });
return res.json({ success: true, remainingCodes: remaining.length, });}Regenerating recovery codes
Section titled “Regenerating recovery codes”Users should be able to regenerate their recovery codes. This invalidates all previous codes:
// POST /api/2fa/regenerate-codesexport async function regenerateCodes(req, res) { const user = await getAuthenticatedUser(req);
if (!user.twoFactorEnabled) { return res.status(400).json({ error: '2FA is not enabled' }); }
const { codes, hashed } = await generateRecoveryCodes(hash);
await db.user.update({ where: { id: user.id }, data: { recoveryCodeHashes: hashed }, });
// Show new codes to the user return res.json({ recoveryCodes: codes });}Replay protection
Section titled “Replay protection”TOTP codes are valid for the current time step plus any steps within the configured window. With the default settings (30-second period, window of 1), a code is valid for up to 90 seconds (the previous, current, and next time steps). This means a code could be reused within that window.
To prevent replay attacks, store the last used time step for each user and reject codes at or before that step.
Implementation
Section titled “Implementation”function getCurrentTimeStep(period: number = 30): number { return Math.floor(Date.now() / 1000 / period);}
// POST /api/2fa/verify (enhanced with replay protection)export async function verifyTwoFactorWithReplayProtection(req, res) { const { code, remember } = req.body;
const pendingToken = await cookieBridge.get('2fa_pending'); if (!pendingToken) { return res.status(401).json({ error: 'No pending 2FA session' }); }
const result = pendingVerifier.verifyToken(pendingToken); if (!result) { return res.status(401).json({ error: '2FA session expired' }); }
const user = await db.user.findUnique({ where: { id: result.userId } }); if (!user || !user.twoFactorEnabled) { return res.status(401).json({ error: 'Invalid session' }); }
const secret = await decrypt(user.totpSecret, process.env.ENCRYPTION_KEY);
// Verify the TOTP code if (!totp.verify(code, secret)) { return res.status(400).json({ error: 'Invalid 2FA code' }); }
// Check for replay: reject if the current time step has already been used const currentStep = getCurrentTimeStep(); if (user.lastTotpStep && currentStep <= user.lastTotpStep) { return res.status(400).json({ error: 'Code already used' }); }
// Record the time step to prevent replay await db.user.update({ where: { id: user.id }, data: { lastTotpStep: currentStep }, });
// Delete the pending cookie and create a real session await cookieBridge.delete('2fa_pending');
const session = auth(); await session.login(user, { remember });
return res.json({ success: true });}Database schema addition
Section titled “Database schema addition”Add a column to track the last used time step:
ALTER TABLE users ADD COLUMN last_totp_step BIGINT;Disabling 2FA
Section titled “Disabling 2FA”Always require the user’s current password before disabling 2FA. This prevents an attacker who has access to an active session from removing the second factor.
// POST /api/2fa/disableexport async function disableTwoFactor(req, res) { const user = await getAuthenticatedUser(req); const { password } = req.body;
if (!user.twoFactorEnabled) { return res.status(400).json({ error: '2FA is not enabled' }); }
// Verify current password const valid = await hash.verify(password, user.password); if (!valid) { return res.status(401).json({ error: 'Invalid password' }); }
// Clear all 2FA data await db.user.update({ where: { id: user.id }, data: { twoFactorEnabled: false, totpSecret: null, pendingTotpSecret: null, recoveryCodeHashes: null, lastTotpStep: null, }, });
return res.json({ message: '2FA disabled' });}Complete database schema
Section titled “Complete database schema”Here is a reference schema with all the columns needed for 2FA:
CREATE TABLE users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL,
-- 2FA fields two_factor_enabled BOOLEAN DEFAULT FALSE, totp_secret TEXT, -- encrypted with encrypt() pending_totp_secret TEXT, -- encrypted, temporary during setup recovery_code_hashes TEXT[], -- array of bcrypt hashes last_totp_step BIGINT -- replay protection);Security checklist
Section titled “Security checklist”- Always encrypt the TOTP secret at rest using
encrypt()from ideal-auth - Never log or expose the TOTP secret after initial setup
- Show recovery codes exactly once; store only the bcrypt hashes
- Use a short-lived pending cookie (5 minutes or less) for the 2FA intermediate state
- Implement replay protection by tracking the last used time step
- Require password verification before disabling 2FA
- Rate limit 2FA verification attempts to prevent brute force
- Use the
2fa_pendingcookie only over HTTPS in production