Implementing Two-Factor Auth (2FA) from Scratch

Team 7 min read

#security

#totp

#authentication

#tutorial

Introduction

Two-Factor Authentication (2FA) adds a second line of defense on top of passwords. In this guide, you’ll learn how to implement a practical, TOTP-based 2FA flow from scratch. We’ll cover secret generation, provisioning via an authenticator app, code verification with time-based drift handling, and security considerations like backup codes and encryption at rest.

Why build 2FA from scratch?

  • You gain full control over user experience and security posture.
  • You can tailor enrollment, recovery, and auditing to your product.
  • You avoid vendor lock-in while still leveraging standard protocols (TOTP from RFC 6238).

Note: While building from scratch gives control, consider security reviews and the option to adopt standards-aligned libraries or WebAuthn as a stronger, phishing-resistant alternative in production.

Core concepts you’ll implement

  • Shared secret: a base32 secret stored for each user.
  • Provisioning: a URL (otpauth://) that lets a user scan a QR code with an authenticator app (e.g., Google Authenticator, Authy, 1Password).
  • Verification: a Time-Based One-Time Password (TOTP) calculated from the shared secret and the current time.
  • Drift tolerance: verification should allow a small window to account for clock skew.
  • Recovery: backup codes or alternative flows if a user can’t access their authenticator.
  • Security hygiene: encrypt secrets at rest, rate-limit verification attempts, and monitor for abuse.

Generating and storing a shared secret

The shared secret is a base32-encoded blob. You should store it encrypted at rest (e.g., with a KMS) and only decrypt it when needed for verification.

  • Secret length: typically 16–20 bytes before encoding.
  • Encoding: RFC 4648 Base32 (A-Z2-7).

Key points:

  • Do not display or log the secret after enrollment.
  • Treat the secret as highly sensitive data.

Code sketch (Node.js, self-contained, no external libraries beyond Node’s crypto):

// totp-from-scratch.js
const crypto = require('crypto');

// RFC4648 Base32 alphabet
const BASE32_ALPH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';

function toBase32(bytes) {
  let bits = 0;
  let value = 0;
  let output = '';
  for (let i = 0; i < bytes.length; i++) {
    value = (value << 8) | bytes[i];
    bits += 8;
    while (bits >= 5) {
      output += BASE32_ALPH[(value >>> (bits - 5)) & 0x1f];
      bits -= 5;
    }
  }
  if (bits > 0) {
    output += BASE32_ALPH[(value << (5 - bits)) & 0x1f];
  }
  return output;
}

function fromBase32(str) {
  const clean = str.toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, '');
  const alphabet = BASE32_ALPH;
  let bits = 0, value = 0;
  const bytes = [];
  for (let i = 0; i < clean.length; i++) {
    const idx = alphabet.indexOf(clean[i]);
    if (idx < 0) throw new Error('Invalid base32 character');
    value = (value << 5) | idx;
    bits += 5;
    if (bits >= 8) {
      bytes.push((value >>> (bits - 8)) & 0xff);
      bits -= 8;
    }
  }
  return Buffer.from(bytes);
}

function generateSecretBytes(len = 20) {
  return crypto.randomBytes(len);
}

function generateSecretBase32(len = 20) {
  const secretBytes = generateSecretBytes(len);
  return toBase32(secretBytes);
}

This yields a base32 secret suitable for provisioning with common authenticator apps.

Provisioning URL and QR code

To enroll a user, you provide an otpauth URL that encodes the issuer, account, and secret. The authenticator app scans a QR code generated from this URL.

  • otpauth URL format: otpauth://totp/{issuer}:{account}?secret={secret}&issuer={issuer}&algorithm=SHA1&digits=6&period=30

  • Rendering a QR code: use any QR code generator you like (server-side or client-side). Many apps render a QR image from the provisioning URL.

Example function to build the provisioning URI:

function provisioningUri(issuer, account, secret, digits = 6, period = 30, algorithm = 'SHA1') {
  const label = encodeURIComponent(`${issuer}:${account}`);
  return `otpauth://totp/${label}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&digits=${digits}&algorithm=${algorithm}&period=${period}`;
}

Once the user scans the QR code, your server should store the secret for that user (in encrypted form) and mark 2FA as enabled for the account.

Verifying a user’s TOTP

Verification involves computing the TOTP for the shared secret at the current time and within a small drift window, then comparing it to the user-provided code.

  • Time step: 30 seconds (typical; can be 15–60s)
  • Digits: 6 (commonly used)
  • Drift tolerance: +/- 1 or 2 steps to account for clock skew

Key functions (Node.js, self-contained, using the secret in base32):

function intToBuffer(counter) {
  const buf = Buffer.alloc(8);
  // write big-endian 64-bit counter
  buf.writeUInt32BE(Math.floor(counter / 0x100000000), 0);
  buf.writeUInt32BE(counter & 0xffffffff, 4);
  return buf;
}

function HOTP(secretBase32, counter) {
  const key = fromBase32(secretBase32);
  const msg = intToBuffer(counter);
  const hmac = crypto.createHmac('sha1', key).update(msg).digest();
  const offset = hmac[hmac.length - 1] & 0x0f;
  const binCode = (hmac.readUInt32BE(offset) & 0x7fffffff);
  const otp = binCode % 1000000;
  return otp.toString().padStart(6, '0');
}

function TOTPVerify(token, secretBase32, options = {}) {
  const { timeStep = 30, digits = 6, tolerance = 1, now = Date.now() } = options;
  const epoch = Math.floor(now / 1000);
  const counter = Math.floor(epoch / timeStep);
  // check a window around the current counter
  for (let i = -tolerance; i <= tolerance; i++) {
    const candidate = HOTP(secretBase32, counter + i);
    if (candidate === token) return true;
  }
  return false;
}

Usage example:

const secret = 'JBSWY3DPEHPK3PXP'; // example secret from provisioning; in practice, store per-user secret
const token = '123456'; // user-provided code

const isValid = TOTPVerify(token, secret, { tolerance: 1 });
console.log(isValid); // true or false

Notes:

  • In a real system, you’d fetch the user’s secret from encrypted storage, then verify with the helper above.
  • Do not log tokens or secrets.
  • Consider clock skew on the server and provide a clear error message if verification fails.

Enrollment and login flow (end-to-end)

  1. Enrollment
  • User provides an account (email/username) and verifies it if needed.
  • Server generates a new secret (base32) for the user and stores it encrypted.
  • Provisioning URI is created, and a QR code is rendered for the user to scan.
  • Optional: provide backup codes as a fallback.
  1. Login with 2FA
  • User enters username and password (first factor).
  • If 2FA is enabled for the account, prompt for the TOTP code.
  • Server verifies the code with the user’s secret and the TOTP verification logic.
  • On success, complete the login; on failure, implement rate limiting and lockout policies.
  1. Recovery
  • Support backup codes or WebAuthn as alternative second factors.
  • Provide a secure recovery workflow for lost devices (multi-channel verification, admin approval, etc.).

Security considerations and best practices

  • Secret storage: Encrypt the base32 secret at rest and restrict access with strict IAM policies.
  • Time synchronization: Account for clock drift; keep tolerance modest (e.g., +/- 1 or 2 steps).
  • Rate limiting: Apply login attempt limits to reduce brute-force risk on TOTPs.
  • Backups: Provide backup codes or another factor (e.g., WebAuthn) for device loss.
  • User education: Explain how 2FA works and emphasize safeguarding the authenticator app.
  • Migration: If you migrate secrets, plan a secure re-enrollment path for users.
  • Audit: Log enrollment, verification successes/failures, and admin actions for incident response.

Recovery codes and backup factors

  • Backup codes: Generate a set (e.g., 10 one-time codes) that can be used when the authenticator is unavailable.
  • WebAuthn: Consider offering hardware security keys as a phishing-resistant second factor in addition to TOTP.
  • Device management: Allow users to re-provision 2FA from a trusted device with proper verification.

Testing strategies

  • Unit tests for HOTP/TOTP generation and verification.
  • Integration tests covering enrollment, provisioning URL generation, QR rendering, and verification flows.
  • Chaos testing for clock skew to ensure tolerance handling behaves correctly.
  • Security tests for rate limiting, failed attempts, and recovery flows.

A quick starter checklist

  • Generate per-user 2FA secret (base32) and store encrypted.
  • Create provisioning URL and render a QR code for enrollment.
  • Implement TOTP verification with clock drift tolerance.
  • Add backup codes and optional WebAuthn as alternatives.
  • Enforce rate limiting and monitor for suspicious activity.
  • Document recovery flow and educate users on 2FA security.

Conclusion

Implementing 2FA from scratch gives you precise control over enrollment, verification, and recovery experiences. By following a standards-based approach with TOTP, careful secret handling, and robust security practices, you can significantly strengthen your users’ account security without sacrificing usability. As your product matures, consider layered defenses like WebAuthn for phishing resistance and a solid incident response workflow to manage device loss and recovery.