50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
authMay 18, 2026·7 min read

Multi-Factor Authentication in Next.js Without a Library

TOTP-based 2FA from scratch in Next.js — no Authy SDK, no magic packages. Just crypto, QR codes, and a bit of base32.

Robert Seghedi

Robert Seghedi

Co-founder, peal.dev

Multi-Factor Authentication in Next.js Without a Library

We added 2FA to a client project last year and spent the first hour installing libraries, reading outdated docs, and questioning our life choices. Then we stripped everything out and implemented TOTP from scratch in about 150 lines. It was faster, we understood every piece of it, and there were zero dependency surprises six months later when someone ran npm audit.

This post walks through the full flow: generating a secret, creating a QR code the user can scan with Google Authenticator or Authy, verifying the 6-digit code, and handling recovery codes. No third-party auth libraries required. Just Node's built-in crypto module and one tiny QR code package.

How TOTP Actually Works

TOTP stands for Time-based One-Time Password (RFC 6238). The algorithm is almost embarrassingly simple: you have a shared secret between your server and the authenticator app. Every 30 seconds, both sides compute HMAC-SHA1 of that secret and the current time window. The result gets truncated to a 6-digit number. If they match, you're in.

The secret is just random bytes encoded in base32. The base32 part is annoying — it's not base64, it's the alphabet A-Z plus 2-7. This is why most people reach for a library. But the actual implementation is maybe 20 lines of code you write once and never touch again.

The shared secret never leaves your server after setup. The QR code contains it once, the app stores it, and from then on it's just time-based math on both sides. No network calls during verification.

Step 1: Generate and Encode the Secret

Start with generating a cryptographically random secret and encoding it in base32. We need base32 because that's what the TOTP spec requires and what authenticator apps expect in the otpauth:// URI.

// lib/totp.ts
import crypto from 'crypto';

const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

export function generateSecret(length = 20): string {
  const bytes = crypto.randomBytes(length);
  let result = '';
  
  for (let i = 0; i < bytes.length; i += 5) {
    const chunk = [
      bytes[i],
      bytes[i + 1] ?? 0,
      bytes[i + 2] ?? 0,
      bytes[i + 3] ?? 0,
      bytes[i + 4] ?? 0,
    ];
    
    result += BASE32_CHARS[(chunk[0] >> 3) & 0x1f];
    result += BASE32_CHARS[((chunk[0] & 0x07) << 2) | ((chunk[1] >> 6) & 0x03)];
    result += BASE32_CHARS[(chunk[1] >> 1) & 0x1f];
    result += BASE32_CHARS[((chunk[1] & 0x01) << 4) | ((chunk[2] >> 4) & 0x0f)];
    result += BASE32_CHARS[((chunk[2] & 0x0f) << 1) | ((chunk[3] >> 7) & 0x01)];
    result += BASE32_CHARS[(chunk[3] >> 2) & 0x1f];
    result += BASE32_CHARS[((chunk[3] & 0x03) << 3) | ((chunk[4] >> 5) & 0x07)];
    result += BASE32_CHARS[chunk[4] & 0x1f];
  }
  
  return result.slice(0, Math.ceil((length * 8) / 5));
}

function base32Decode(secret: string): Buffer {
  const normalized = secret.toUpperCase().replace(/[^A-Z2-7]/g, '');
  const bytes: number[] = [];
  let bits = 0;
  let value = 0;
  
  for (const char of normalized) {
    value = (value << 5) | BASE32_CHARS.indexOf(char);
    bits += 5;
    if (bits >= 8) {
      bytes.push((value >> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }
  
  return Buffer.from(bytes);
}

The bit manipulation looks intimidating but it's just packing 8-bit bytes into 5-bit groups. Write it once, test it, forget about it. The important part is that generateSecret() gives you something like JBSWY3DPEHPK3PXP — store this in your database against the user, encrypted at rest.

Step 2: Generate a TOTP Code (and Verify It)

Now the actual TOTP logic. We take the current 30-second window, HMAC it with the secret, then do a bit of dynamic truncation to get 6 digits. We also check the window before and after to handle clock skew — because users' phones are sometimes slightly off and you don't want support tickets about it.

// continuing lib/totp.ts

function generateTOTP(secret: string, window = 0): string {
  const timeStep = Math.floor(Date.now() / 1000 / 30) + window;
  
  // Pack time as 8-byte big-endian buffer
  const timeBuf = Buffer.alloc(8);
  timeBuf.writeUInt32BE(Math.floor(timeStep / 2 ** 32), 0);
  timeBuf.writeUInt32BE(timeStep % 2 ** 32, 4);
  
  const key = base32Decode(secret);
  const hmac = crypto.createHmac('sha1', key).update(timeBuf).digest();
  
  // Dynamic truncation
  const offset = hmac[hmac.length - 1] & 0x0f;
  const code =
    ((hmac[offset] & 0x7f) << 24) |
    ((hmac[offset + 1] & 0xff) << 16) |
    ((hmac[offset + 2] & 0xff) << 8) |
    (hmac[offset + 3] & 0xff);
  
  return String(code % 10 ** 6).padStart(6, '0');
}

export function verifyTOTP(
  secret: string,
  token: string,
  windowSize = 1
): boolean {
  // Check current window plus one before and after for clock drift
  for (let w = -windowSize; w <= windowSize; w++) {
    const expected = generateTOTP(secret, w);
    // Constant-time comparison to prevent timing attacks
    if (
      expected.length === token.length &&
      crypto.timingSafeEqual(
        Buffer.from(expected),
        Buffer.from(token.padStart(6, '0'))
      )
    ) {
      return true;
    }
  }
  return false;
}

Notice we use crypto.timingSafeEqual instead of ===. With a regular string comparison, an attacker can theoretically measure response time to figure out how many digits they got right. It's unlikely in practice over a network, but it costs nothing to do it right.

Step 3: QR Code Setup Flow

Now we need to give users a way to scan a QR code. The QR code encodes an otpauth:// URI — this is a standard format all authenticator apps understand. For the actual QR image, we'll use the qrcode package because generating QR codes from scratch is genuinely not worth it.

// app/api/auth/2fa/setup/route.ts
import { NextResponse } from 'next/server';
import QRCode from 'qrcode';
import { generateSecret } from '@/lib/totp';
import { getServerSession } from 'next-auth';
import { db } from '@/lib/db';

export async function POST() {
  const session = await getServerSession();
  if (!session?.user?.id) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Generate a fresh secret — don't save it yet, user hasn't verified it
  const secret = generateSecret();
  
  // Temporarily store in session or a pending_2fa table
  // We store it pending until the user confirms with a valid code
  await db.user.update({
    where: { id: session.user.id },
    data: { pending2faSecret: secret },
  });

  const appName = 'YourApp';
  const userEmail = session.user.email;
  const otpauthUrl = `otpauth://totp/${encodeURIComponent(appName)}:${encodeURIComponent(userEmail)}?secret=${secret}&issuer=${encodeURIComponent(appName)}&algorithm=SHA1&digits=6&period=30`;

  // Return as data URL so the client can render it in an <img> tag
  const qrDataUrl = await QRCode.toDataURL(otpauthUrl);

  return NextResponse.json({
    secret,   // show this as text fallback for manual entry
    qrCode: qrDataUrl,
  });
}

We store it as pending_2fa_secret, not the actual 2fa_secret. This matters: if the user scans the code but never verifies it worked, you don't want to lock them out of their account with an unconfirmed secret. Only promote it to the real 2FA secret after they enter a valid code.

// app/api/auth/2fa/confirm/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyTOTP } from '@/lib/totp';
import { getServerSession } from 'next-auth';
import { db } from '@/lib/db';
import crypto from 'crypto';

export async function POST(req: NextRequest) {
  const session = await getServerSession();
  if (!session?.user?.id) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const { token } = await req.json();
  
  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: { pending2faSecret: true },
  });

  if (!user?.pending2faSecret) {
    return NextResponse.json({ error: 'No pending 2FA setup' }, { status: 400 });
  }

  if (!verifyTOTP(user.pending2faSecret, token)) {
    return NextResponse.json({ error: 'Invalid code' }, { status: 400 });
  }

  // Generate recovery codes — 8 single-use codes
  const recoveryCodes = Array.from({ length: 8 }, () =>
    crypto.randomBytes(5).toString('hex').toUpperCase()
  );

  await db.user.update({
    where: { id: session.user.id },
    data: {
      twoFactorSecret: user.pending2faSecret,
      pending2faSecret: null,
      twoFactorEnabled: true,
      // Store hashed recovery codes
      recoveryCodes: recoveryCodes.map(code =>
        crypto.createHash('sha256').update(code).digest('hex')
      ),
    },
  });

  // Return plain codes ONCE — user must save these
  return NextResponse.json({ recoveryCodes });
}

Wiring It Into Your Login Flow

The tricky part isn't the TOTP math — it's the login state machine. After a user enters their password correctly, you don't want to give them a full session immediately if they have 2FA enabled. You want a half-authenticated state where they're only allowed to hit the 2FA verification endpoint.

If you're using NextAuth, the cleanest approach is a custom callback that sets a flag in the JWT token. If twoFactorVerified is false and the user has 2FA enabled, your middleware redirects them to /auth/verify-2fa before they can access anything.

// In your NextAuth config — authOptions.ts
callbacks: {
  async jwt({ token, user, trigger }) {
    if (user) {
      // Fresh login — check if 2FA is enabled
      token.userId = user.id;
      token.twoFactorEnabled = user.twoFactorEnabled;
      // Mark as not yet verified if 2FA is on
      token.twoFactorVerified = !user.twoFactorEnabled;
    }
    return token;
  },
  async session({ session, token }) {
    session.user.id = token.userId as string;
    session.user.twoFactorEnabled = token.twoFactorEnabled as boolean;
    session.user.twoFactorVerified = token.twoFactorVerified as boolean;
    return session;
  },
},

// middleware.ts
export function middleware(request: NextNextRequest) {
  const token = await getToken({ req: request });
  const { pathname } = request.nextUrl;
  
  const is2faRoute = pathname.startsWith('/auth/verify-2fa') ||
                     pathname.startsWith('/api/auth/2fa/verify');
  
  if (
    token?.twoFactorEnabled &&
    !token?.twoFactorVerified &&
    !is2faRoute
  ) {
    return NextResponse.redirect(new URL('/auth/verify-2fa', request.url));
  }
}

This approach means the user has a real JWT session, but middleware gates them until they complete the second factor. It's simple and it works with any NextAuth provider — email, OAuth, credentials, whatever.

Recovery Codes: Don't Skip This

People lose their phones. This is not a hypothetical. We've had users email us in a panic at 11pm because they got a new phone and didn't back up their authenticator app. Recovery codes are how you avoid being that support ticket.

The implementation is simple: generate 8 random codes when 2FA is enabled, show them once, store hashed versions. When a user tries to log in with a recovery code instead of a TOTP, check it against the hashed list and delete the used one.

// app/api/auth/2fa/verify/route.ts (handles both TOTP and recovery)
import crypto from 'crypto';

export async function POST(req: NextRequest) {
  const { token, type = 'totp' } = await req.json();
  // ... get user from half-authenticated session

  if (type === 'recovery') {
    const hashedInput = crypto
      .createHash('sha256')
      .update(token.toUpperCase())
      .digest('hex');
    
    const matchingCode = user.recoveryCodes.find(
      (code: string) => crypto.timingSafeEqual(
        Buffer.from(code),
        Buffer.from(hashedInput)
      )
    );
    
    if (!matchingCode) {
      return NextResponse.json({ error: 'Invalid recovery code' }, { status: 400 });
    }
    
    // Remove used code — each code works exactly once
    await db.user.update({
      where: { id: userId },
      data: {
        recoveryCodes: user.recoveryCodes.filter((c: string) => c !== matchingCode),
      },
    });
  } else {
    if (!verifyTOTP(user.twoFactorSecret, token)) {
      return NextResponse.json({ error: 'Invalid code' }, { status: 400 });
    }
  }
  
  // Mark 2FA as verified in the JWT
  // This depends on your session strategy — with NextAuth you'd update the token
  // via a custom endpoint that re-issues the JWT with twoFactorVerified: true
}

A Few Things We Got Wrong the First Time

  • Promoting the secret before verification — user scans QR, never confirms it works, and is now locked out because you saved the wrong secret. Always use a pending secret field.
  • Not handling clock skew — a window of ±1 (3 valid codes at any moment) handles 99% of phone time drift. Some guides say ±1 is too loose, but in practice it's fine.
  • Storing recovery codes in plain text — always hash them. They're basically passwords.
  • Forgetting to invalidate existing sessions when 2FA is disabled — if someone disables 2FA, force re-login. Otherwise they can disable it from one compromised session.
  • Not rate-limiting the verification endpoint — TOTP has 1,000,000 possible codes, so brute force is slow, but rate-limit anyway. 5 attempts per 10 minutes is reasonable.
The pending secret pattern is the one that bites people hardest. Never save the final twoFactorSecret until the user has proven they can generate valid codes from it.

Dependencies You Actually Need

To do everything in this post, here's your actual dependency footprint:

  • qrcode — generates the QR image. ~50KB, well-maintained, no drama.
  • Node's built-in crypto — handles HMAC-SHA1, random bytes, timing-safe comparison. No install required.
  • That's it.

No speakeasy, no otplib, no auth0-mfa-sdk. Those packages are fine if you need them, but for straightforward TOTP they're adding code you could write in an afternoon that you understand completely. When something breaks at 2am (and something always breaks at 2am), it's a lot better to be debugging 150 lines you wrote than 3,000 lines of someone else's library.

If you'd rather start with this already wired up — 2FA, recovery codes, rate limiting, and the login state machine — our auth-focused Next.js templates at peal.dev ship with this baked in. But honestly, building it yourself once is worth it. You'll know exactly what's happening when a user tells you their codes aren't working.

The whole TOTP flow is about 150 lines of TypeScript you'll understand completely. That's worth more than a well-documented black box when something goes sideways in production.
Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders