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 app.// This is a fresh setup, so there is no previous counter — pass null.const { valid, counter } = totp.verify(code, secret, null);if (!valid) {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,// record the enrollment code's step so it can't be replayed at logintotpLastUsedCounter: counter,},});// 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.IDEAL_AUTH_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.IDEAL_AUTH_SECRET!, purpose: '2fa-pending', // tokens from other flows never verify here 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. // verify + the persisted counter rejects replayed codes — // a code that already logged someone in can't be used again within // the acceptance window. const secret = await decrypt(user.totpSecret, process.env.ENCRYPTION_KEY);
const { valid, counter } = totp.verify( code, secret, user.totpLastUsedCounter, ); if (!valid) { return res.status(400).json({ error: 'Invalid 2FA code' }); } await db.user.update({ where: { id: user.id }, data: { totpLastUsedCounter: counter }, });
// 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.
verify() handles this: it returns the time step the code matched, and rejects codes at or before the lastUsedCounter you pass in. Persist the counter after each successful verification (as shown in Step 2 above) and every code becomes single-use:
const { valid, counter } = totp.verify( code, secret, user.totpLastUsedCounter, // null on first use);if (!valid) { return res.status(400).json({ error: 'Invalid or already used 2FA code' });}
// counter is the step the code actually matched (may be the previous or// next step within the drift window) — persist it, not the current timeawait db.user.update({ where: { id: user.id }, data: { totpLastUsedCounter: counter },});Database schema addition
Section titled “Database schema addition”Add a column to track the last used time step:
ALTER TABLE users ADD COLUMN totp_last_used_counter 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, totpLastUsedCounter: 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 totp_last_used_counter BIGINT -- replay protection (verify));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