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

Building 2FA in Next.js From Scratch — No Library Required

TOTP-based two-factor authentication isn't magic. Here's how to build it yourself in Next.js using Web Crypto API and a bit of math.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Building 2FA in Next.js From Scratch — No Library Required

Every auth tutorial eventually says 'add 2FA with this library' and then links to a package with 47 transitive dependencies you'll never audit. We went down that path, got nervous about what was actually happening inside those black boxes, and decided to just implement TOTP ourselves. Turns out it's maybe 150 lines of code. Here's all of it.

How TOTP Actually Works

TOTP stands for Time-based One-Time Password and it's defined in RFC 6238. The whole thing is surprisingly simple: you share a secret between the server and the user's authenticator app (Google Authenticator, Authy, whatever). Every 30 seconds, both sides run the same HMAC-SHA1 operation on that secret plus the current time window. If the results match, the user proved they have the secret. That's it. No network call to verify. No database lookup for a code. Pure math.

The secret is usually a random 20-byte value encoded as base32 (because authenticator apps expect that format). The QR code you scan is just a URI encoding of that secret along with your app name and the user's email. Once scanned, the secret lives in the authenticator app and on your server — never transmitted again.

Generating the Secret

We'll use the Web Crypto API which is available in Node.js 18+ and in the Edge runtime. No external dependencies needed. First, let's generate a random secret and encode it as base32:

// lib/totp.ts

const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

export function generateSecret(length = 20): string {
  const bytes = crypto.getRandomValues(new Uint8Array(length));
  let result = '';
  
  // Base32 encodes 5 bits per character
  let buffer = 0;
  let bitsLeft = 0;
  
  for (const byte of bytes) {
    buffer = (buffer << 8) | byte;
    bitsLeft += 8;
    while (bitsLeft >= 5) {
      bitsLeft -= 5;
      result += BASE32_CHARS[(buffer >> bitsLeft) & 31];
    }
  }
  
  if (bitsLeft > 0) {
    result += BASE32_CHARS[(buffer << (5 - bitsLeft)) & 31];
  }
  
  return result;
}

export function base32Decode(input: string): Uint8Array {
  const str = input.toUpperCase().replace(/=+$/, '');
  const bytes: number[] = [];
  let buffer = 0;
  let bitsLeft = 0;
  
  for (const char of str) {
    const value = BASE32_CHARS.indexOf(char);
    if (value === -1) throw new Error(`Invalid base32 character: ${char}`);
    buffer = (buffer << 5) | value;
    bitsLeft += 5;
    if (bitsLeft >= 8) {
      bitsLeft -= 8;
      bytes.push((buffer >> bitsLeft) & 255);
    }
  }
  
  return new Uint8Array(bytes);
}

The TOTP Algorithm

Now the actual TOTP verification. We compute an HMAC-SHA1 of the current 30-second time window using the secret, then extract a 6-digit code from it using a technique called dynamic truncation. We also check the previous and next time windows to account for clock skew — users who type slowly will thank you for this.

// lib/totp.ts (continued)

async function hmacSHA1(key: Uint8Array, data: Uint8Array): Promise<Uint8Array> {
  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    key,
    { name: 'HMAC', hash: 'SHA-1' },
    false,
    ['sign']
  );
  const signature = await crypto.subtle.sign('HMAC', cryptoKey, data);
  return new Uint8Array(signature);
}

function timeWindow(date = new Date()): number {
  return Math.floor(date.getTime() / 1000 / 30);
}

async function generateTOTP(secret: string, counter: number): Promise<string> {
  const secretBytes = base32Decode(secret);
  
  // Counter as big-endian 8-byte buffer
  const counterBuffer = new Uint8Array(8);
  let c = counter;
  for (let i = 7; i >= 0; i--) {
    counterBuffer[i] = c & 0xff;
    c = Math.floor(c / 256);
  }
  
  const hmac = await hmacSHA1(secretBytes, counterBuffer);
  
  // Dynamic truncation
  const offset = hmac[19] & 0xf;
  const code = (
    ((hmac[offset] & 0x7f) << 24) |
    ((hmac[offset + 1] & 0xff) << 16) |
    ((hmac[offset + 2] & 0xff) << 8) |
    (hmac[offset + 3] & 0xff)
  ) % 1_000_000;
  
  return code.toString().padStart(6, '0');
}

export async function verifyTOTP(
  secret: string,
  token: string,
  windowSize = 1 // check ±1 windows for clock skew
): Promise<boolean> {
  const currentWindow = timeWindow();
  
  for (let i = -windowSize; i <= windowSize; i++) {
    const expected = await generateTOTP(secret, currentWindow + i);
    // Constant-time comparison to prevent timing attacks
    if (timingSafeEqual(expected, token)) return true;
  }
  
  return false;
}

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let result = 0;
  for (let i = 0; i < a.length; i++) {
    result |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return result === 0;
}
The timing-safe comparison matters. A naive `a === b` can leak information about how many characters matched because JavaScript engines can short-circuit the comparison. XOR-ing every byte and checking the accumulated result takes the same time regardless of where strings diverge.

Building the Setup Flow

The setup flow has three steps: generate a secret, show it as a QR code, verify the user actually enrolled successfully. Don't skip that third step. We once shipped 2FA setup without a confirmation step and had users locked out of accounts because they thought they'd set it up but hadn't scanned the QR code properly.

For the QR code, generate an otpauth URI and render it with any QR code library. The URI format is standardized so every authenticator app understands it:

// app/api/2fa/setup/route.ts
import { generateSecret } from '@/lib/totp';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';

export async function POST() {
  const session = await getSession();
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });
  
  // Generate fresh secret — don't save it yet, user might not complete setup
  const secret = generateSecret();
  
  // Store temporarily in session or short-lived DB record
  // We store in DB with a 'pending' flag so it survives page refreshes
  await db.user.update({
    where: { id: session.userId },
    data: { totpSecretPending: secret }
  });
  
  const appName = encodeURIComponent('YourApp');
  const email = encodeURIComponent(session.email);
  const otpauthUri = `otpauth://totp/${appName}:${email}?secret=${secret}&issuer=${appName}&algorithm=SHA1&digits=6&period=30`;
  
  return Response.json({ secret, otpauthUri });
}

// app/api/2fa/verify-setup/route.ts
import { verifyTOTP } from '@/lib/totp';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';

export async function POST(request: Request) {
  const session = await getSession();
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });
  
  const { token } = await request.json();
  
  const user = await db.user.findUnique({
    where: { id: session.userId },
    select: { totpSecretPending: true }
  });
  
  if (!user?.totpSecretPending) {
    return Response.json({ error: 'No pending 2FA setup' }, { status: 400 });
  }
  
  const valid = await verifyTOTP(user.totpSecretPending, token);
  
  if (!valid) {
    return Response.json({ error: 'Invalid code' }, { status: 400 });
  }
  
  // Confirmed — move pending secret to active
  await db.user.update({
    where: { id: session.userId },
    data: {
      totpSecret: user.totpSecretPending,
      totpSecretPending: null,
      totpEnabled: true
    }
  });
  
  // Generate and return backup codes while we're here
  const backupCodes = generateBackupCodes();
  await saveHashedBackupCodes(session.userId, backupCodes);
  
  return Response.json({ success: true, backupCodes });
}

function generateBackupCodes(count = 8): string[] {
  return Array.from({ length: count }, () => {
    const bytes = crypto.getRandomValues(new Uint8Array(5));
    return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('').toUpperCase();
  });
}

The Login Challenge Flow

Once setup is done, modify your login flow to check whether 2FA is enabled. Don't issue a full session after password verification — issue a temporary 'awaiting 2FA' state. We do this with a short-lived JWT that only grants access to the 2FA challenge endpoint. Some people use a separate session flag, both approaches work.

The key thing is that the temporary state should expire. We use 5 minutes. If someone verifies their password and then walks away, their half-authenticated session shouldn't be valid when they return an hour later.

// app/api/auth/login/route.ts (simplified)
import { verifyPassword } from '@/lib/auth';
import { signJWT } from '@/lib/jwt';
import { db } from '@/lib/db';

export async function POST(request: Request) {
  const { email, password } = await request.json();
  
  const user = await db.user.findUnique({ where: { email } });
  if (!user || !await verifyPassword(password, user.passwordHash)) {
    // Same error message whether email or password is wrong
    return Response.json({ error: 'Invalid credentials' }, { status: 401 });
  }
  
  if (user.totpEnabled) {
    // Issue a scoped, short-lived token — not a full session
    const challengeToken = await signJWT(
      { userId: user.id, purpose: '2fa-challenge' },
      { expiresIn: '5m' }
    );
    return Response.json({ requires2FA: true, challengeToken });
  }
  
  // No 2FA — issue full session as normal
  const sessionToken = await createSession(user.id);
  return Response.json({ sessionToken });
}

// app/api/auth/2fa-challenge/route.ts
import { verifyTOTP } from '@/lib/totp';
import { verifyJWT } from '@/lib/jwt';
import { db } from '@/lib/db';

export async function POST(request: Request) {
  const { challengeToken, totpToken } = await request.json();
  
  const payload = await verifyJWT(challengeToken);
  if (!payload || payload.purpose !== '2fa-challenge') {
    return Response.json({ error: 'Invalid or expired challenge' }, { status: 401 });
  }
  
  const user = await db.user.findUnique({
    where: { id: payload.userId },
    select: { totpSecret: true, totpEnabled: true }
  });
  
  if (!user?.totpEnabled || !user.totpSecret) {
    return Response.json({ error: 'User does not have 2FA enabled' }, { status: 400 });
  }
  
  const valid = await verifyTOTP(user.totpSecret, totpToken);
  
  if (!valid) {
    // Consider rate limiting here — max 5 attempts per challenge
    return Response.json({ error: 'Invalid code' }, { status: 401 });
  }
  
  // TOTP passed — now issue the real session
  const sessionToken = await createSession(payload.userId);
  return Response.json({ sessionToken });
}

Backup Codes and Recovery

Don't ship 2FA without backup codes. Someone will lose their phone. They will email you at 11pm on a Friday. You will feel terrible. Backup codes are single-use codes stored as bcrypt hashes (or argon2 if you want to be fancy). When a user tries to use one, you hash it, check it against all stored hashes, and if it matches, invalidate that specific code.

  • Generate 8-10 backup codes at 2FA setup time
  • Show them once — user must save them
  • Hash them with bcrypt before storing (they're passwords, treat them like passwords)
  • Each code can only be used once — delete it after use
  • Let users regenerate backup codes, but make them re-authenticate first
  • Email the user when a backup code is used — could be someone else

Things That Will Bite You

A few edge cases that are worth knowing about before you hit them in production rather than after:

  • Clock skew: server time drifts or user's phone is out of sync. The ±1 window we check covers most cases but some phones are badly wrong. Consider checking ±2 windows if you're seeing valid users fail.
  • Token reuse: a TOTP code is valid for the entire 30-second window. Record the last used counter and reject codes from the same or earlier window to prevent replay attacks.
  • Rate limiting: without it, someone can brute force all 1,000,000 possible codes in under 10 minutes. Limit to 5 attempts per 10-minute window per user.
  • Disabling 2FA: require password re-verification before disabling. Never just 'are you sure?' modals — those are useless if someone has the user's session.
  • Storing the secret: encrypt it at rest. If your DB leaks and the secrets are plaintext, attackers can generate valid codes. Use a KMS or at minimum AES-256 encryption with a key stored outside the DB.
We learned the rate limiting lesson not from an attack but from a user who fat-fingered their code 20 times and convinced themselves our app was broken. Same result either way — add rate limiting.

What About Passkeys and WebAuthn?

TOTP is the right call for most applications right now. It works on every phone, every user understands it, and it doesn't require HTTPS-only origins or fiddling with relying party IDs. WebAuthn/passkeys are genuinely better from a security standpoint — phishing-resistant by design — but the implementation is significantly more involved and adoption on the user side is still uneven. We're building WebAuthn support into some of our peal.dev templates but we always include TOTP as a fallback because not everyone has a hardware key or a face ID enrolled.

The good news is that if you implement 2FA the way we've shown here — secret in DB, verify on challenge, full session after both factors pass — adding passkeys later is just another verification path that leads to the same `createSession` call.

The Practical Takeaway

TOTP is one of those things that sounds like it needs a library until you actually look at what the library does. It's HMAC-SHA1, some bit manipulation, and base32 encoding. All of that is available in the Web Crypto API. The 150 lines you write yourself are lines you understand, lines you can audit, and lines that don't have a supply chain. The next time a 2FA library has a CVE, you won't be scrambling to patch it at 2am because you just won't be using it.

If you want the full working version including the React components, QR code rendering, backup code management, and rate limiting middleware, it's all wired up and ready to deploy. But if you're rolling your own — now you have everything you need.

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