Back to blog

Implementing TOTP Authentication: A Deep Dive

Complete guide to implementing Time-based One-Time Password authentication with proper security, clock drift handling, and QR code generation.

#authentication#security#totp#2fa

What is TOTP?

Time-based One-Time Password (TOTP) is an algorithm that generates a one-time password using the current time as a source of uniqueness. It's the technology behind authenticator apps like Google Authenticator, Authy, and Microsoft Authenticator.

Why TOTP Over SMS?

SMS 2FA has several vulnerabilities:

  • SIM swapping attacks - Attackers can hijack phone numbers
  • SS7 protocol vulnerabilities - Network-level attacks
  • Phishing - Users can be tricked into sharing codes
  • Delivery delays - SMS can be delayed or never arrive

TOTP solves these issues by generating codes locally on the user's device using a shared secret.

The TOTP Algorithm

TOTP is defined in RFC 6238 and builds on HMAC-based One-Time Password (HOTP, RFC 4226). Here's how it works:

Step 1: Generate a Shared Secret

import crypto from 'crypto';
import speakeasy from 'speakeasy';
 
function generateSecret(userId: string) {
  const secret = speakeasy.generateSecret({
    length: 32,
    name: `Aegis2FA (${userId})`,
    issuer: 'Aegis2FA',
  });
 
  return {
    base32: secret.base32,
    otpauthUrl: secret.otpauth_url,
  };
}

The secret is a random 32-byte value encoded in Base32. This secret is shared between the server and the client app.

Step 2: Generate QR Code

import QRCode from 'qrcode';
 
async function generateQRCode(otpauthUrl: string): Promise<string> {
  // Generate data URL for QR code
  const qrDataUrl = await QRCode.toDataURL(otpauthUrl, {
    errorCorrectionLevel: 'H',
    type: 'image/png',
    width: 300,
    margin: 1,
  });
 
  return qrDataUrl;
}

The otpauth:// URL format contains:

  • Protocol: otpauth://totp/
  • Issuer: Your app name
  • Account: User identifier
  • Secret: The Base32 secret
  • Algorithm: SHA1 (default), SHA256, or SHA512

Step 3: Verify TOTP Tokens

function verifyTotp(
  userSecret: string,
  token: string,
  window: number = 1
): boolean {
  return speakeasy.totp.verify({
    secret: userSecret,
    encoding: 'base32',
    token,
    window, // Allow ±30 seconds
  });
}

Handling Clock Drift

One of the biggest challenges with TOTP is clock synchronization. User devices may have incorrect time settings.

The Window Parameter

The window parameter allows tokens from adjacent time periods to be valid:

  • window = 0: Only current time period (30 seconds)
  • window = 1: Current period ± 1 period (90 seconds total)
  • window = 2: Current period ± 2 periods (150 seconds total)

Recommendation: Use window = 1 for a balance between security and usability.

Implementation Details

interface TotpVerificationResult {
  valid: boolean;
  delta?: number; // Time periods away from current
}
 
function verifyTotpWithDelta(
  secret: string,
  token: string
): TotpVerificationResult {
  const result = speakeasy.totp.verifyDelta({
    secret,
    encoding: 'base32',
    token,
    window: 2,
  });
 
  if (result && typeof result === 'object') {
    return { valid: true, delta: result.delta };
  }
 
  return { valid: false };
}

Security Considerations

1. Rate Limiting

Implement aggressive rate limiting on TOTP verification:

import rateLimit from 'express-rate-limit';
 
const totpRateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 attempts per window
  message: 'Too many verification attempts, please try again later',
  standardHeaders: true,
  legacyHeaders: false,
});
 
router.post('/verify-totp', totpRateLimiter, verifyTotpHandler);

2. Secret Storage

Never store TOTP secrets in plaintext:

import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
 
const ALGORITHM = 'aes-256-gcm';
const KEY = process.env.ENCRYPTION_KEY; // 32 bytes
 
function encryptSecret(secret: string): {
  encrypted: string;
  iv: string;
  tag: string;
} {
  const iv = randomBytes(16);
  const cipher = createCipheriv(ALGORITHM, Buffer.from(KEY, 'hex'), iv);
 
  let encrypted = cipher.update(secret, 'utf8', 'hex');
  encrypted += cipher.final('hex');
 
  const tag = cipher.getAuthTag();
 
  return {
    encrypted,
    iv: iv.toString('hex'),
    tag: tag.toString('hex'),
  };
}
 
function decryptSecret(
  encrypted: string,
  iv: string,
  tag: string
): string {
  const decipher = createDecipheriv(
    ALGORITHM,
    Buffer.from(KEY, 'hex'),
    Buffer.from(iv, 'hex')
  );
 
  decipher.setAuthTag(Buffer.from(tag, 'hex'));
 
  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');
 
  return decrypted;
}

3. Token Reuse Prevention

Implement a token cache to prevent replay attacks:

import Redis from 'ioredis';
 
const redis = new Redis(process.env.REDIS_URL);
 
async function isTokenUsed(
  userId: string,
  token: string
): Promise<boolean> {
  const key = `totp:${userId}:${token}`;
  const exists = await redis.exists(key);
  return exists === 1;
}
 
async function markTokenAsUsed(
  userId: string,
  token: string
): Promise<void> {
  const key = `totp:${userId}:${token}`;
  // Expire after 90 seconds (window = 1)
  await redis.setex(key, 90, '1');
}
 
async function verifyTotpOnce(
  userId: string,
  secret: string,
  token: string
): Promise<boolean> {
  // Check if token already used
  if (await isTokenUsed(userId, token)) {
    return false;
  }
 
  // Verify token
  const valid = verifyTotp(secret, token);
 
  if (valid) {
    // Mark as used
    await markTokenAsUsed(userId, token);
  }
 
  return valid;
}

Testing TOTP Implementation

Unit Tests

import { describe, it, expect } from 'vitest';
 
describe('TOTP Verification', () => {
  it('should verify valid token', () => {
    const secret = 'JBSWY3DPEHPK3PXP';
    const token = speakeasy.totp({
      secret,
      encoding: 'base32',
    });
 
    const result = verifyTotp(secret, token);
    expect(result).toBe(true);
  });
 
  it('should reject old token', () => {
    const secret = 'JBSWY3DPEHPK3PXP';
    const oldToken = speakeasy.totp({
      secret,
      encoding: 'base32',
      time: Date.now() / 1000 - 120, // 2 minutes ago
    });
 
    const result = verifyTotp(secret, oldToken, 1);
    expect(result).toBe(false);
  });
 
  it('should accept token within window', () => {
    const secret = 'JBSWY3DPEHPK3PXP';
    const nearToken = speakeasy.totp({
      secret,
      encoding: 'base32',
      time: Date.now() / 1000 - 35, // 35 seconds ago
    });
 
    const result = verifyTotp(secret, nearToken, 1);
    expect(result).toBe(true);
  });
});

Common Pitfalls

1. Not Handling Backup Codes

Always provide backup codes when enabling TOTP:

function generateBackupCodes(count: number = 10): string[] {
  const codes: string[] = [];
 
  for (let i = 0; i < count; i++) {
    const code = crypto
      .randomBytes(4)
      .toString('hex')
      .match(/.{1,4}/g)
      ?.join('-') || '';
    codes.push(code);
  }
 
  return codes;
}

2. Poor User Experience

Show time remaining for current token:

function getTimeRemaining(): number {
  const epoch = Math.floor(Date.now() / 1000);
  const period = 30;
  return period - (epoch % period);
}

3. Not Testing With Real Authenticator Apps

Always test with actual apps (Google Authenticator, Authy) before deploying.

Lessons Learned

After implementing TOTP in production:

Set window to 1, not 2: Window of 2 (150 seconds) is too permissive and reduces security significantly.

Always provide backup codes: 15-20% of users will lose access to their authenticator app.

Show visual feedback: Display the QR code immediately and provide a manual entry option.

Log failed attempts: Track failed TOTP attempts for security monitoring.

Next Steps

In the next post, I'll cover implementing backup codes and recovery flows for when users lose access to their authenticator apps.


Questions about TOTP implementation? Found an edge case I missed? Reach out via email or open an issue on GitHub.