Skip to content

Two-Factor Authentication

Add TOTP 2FA with recovery codes to your app.

Updated View as Markdown

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-key

Copy the output into your .env file:

.envbash
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.

  1. 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 });
    }
  2. 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.

  3. 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.

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 database

You 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_pending cookie only over HTTPS in production
Navigation

Type to search…

↑↓ navigate↵ selectEsc close