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.
Setup
import {
createTOTP,
createHash,
generateRecoveryCodes,
encrypt,
decrypt,
} from 'ideal-auth';
const totp = createTOTP(); // defaults: 6 digits, 30s period, window ±1
const 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
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
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/setup export 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/confirm export 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 secret const 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 storage const encryptedSecret = await encrypt(secret, process.env.ENCRYPTION_KEY); // Generate recovery codes const { 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 login totpLastUsedCounter: counter, }, }); // Show recovery codes to the user ONCE return res.json({ message: '2FA enabled', recoveryCodes: codes, }); }
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
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/login
export 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
// POST /api/2fa/verify
export 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
In frameworks with middleware (Next.js, SvelteKit), redirect users who have a pending 2FA cookie to the verification page:
// middleware.ts
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).*)'],
};// src/hooks.server.ts
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
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
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
import { verifyRecoveryCode } from 'ideal-auth';
// POST /api/2fa/recover
export 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
Users should be able to regenerate their recovery codes. This invalidates all previous codes:
// POST /api/2fa/regenerate-codes
export 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
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 time
await db.user.update({
where: { id: user.id },
data: { totpLastUsedCounter: counter },
});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
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/disable
export 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
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
- 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