Two-Factor Authentication (2FA) adds a critical security layer beyond passwords. One-Time Passwords (OTPs) generate temporary codes that expire quickly, reducing replay attack risks. This guide covers the two primary OTP standards with practical C# implementation examples.

📋 Overview of OTP Protocols

What Is OTP?

A One-Time Password is a unique code valid for only one login session or transaction. Unlike static passwords, OTPs cannot be reused if intercepted.

Two Main Standards: Complete C# Implementation

Protocol

RFC Standard

Trigger Mechanism

Typical Use Case

HOTP

RFC 4226

Counter increment

Hardware tokens, backup codes

TOTP

RFC 6238

Time interval

Mobile authenticator apps, desktop apps

TOTP extends HOTP by incorporating time, making both code and counter derivable from synchronized clocks rather than physical counters.

🔧 Core Building Block: Base32 Encoding

Before diving into HOTP/TOTP, we need Base32 encoding, a way to represent binary data using printable ASCII characters (A-Z, 2-7). This makes secrets human-readable and safe to transfer via QR codes or text.

using System.Text;
using System.Collections.Generic;

namespace Pxoqxo.Otp2fa
{
    internal static class Base32
    {
        private const string Base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

        /// <summary>
        /// Encodes byte array to Base32 string per RFC 4648
        /// </summary>
        internal static string ToBase32(byte[] bytes)
        {
            var result = new StringBuilder();
            int buffer = 0;
            int bitsLeft = 0;

            foreach (byte b in bytes)
            {
                buffer = (buffer << 8) | b;
                bitsLeft += 8;

                while (bitsLeft >= 5)
                {
                    int index = (buffer >> (bitsLeft - 5)) & 0x1F;
                    result.Append(Base32Chars[index]);
                    bitsLeft -= 5;
                }
            }

            // Handle remaining bits (padding not shown for simplicity)
            if (bitsLeft > 0)
            {
                int index = (buffer << (5 - bitsLeft)) & 0x1F;
                result.Append(Base32Chars[index]);
            }

            return result.ToString();
        }

        /// <summary>
        /// Decodes Base32 string back to byte array
        /// </summary>
        internal static byte[] FromBase32(string text)
        {
            string cleanText = text.ToUpper().Replace(" ", "").Replace("-", "");

            var result = new List<byte>();
            int buffer = 0;
            int bitsLeft = 0;

            foreach (char c in cleanText)
            {
                int index = Base32Chars.IndexOf(c);
                if (index < 0)
                {
                    throw new FormatException("Invalid Base32 character encountered.");
                }

                buffer = (buffer << 5) | index;
                bitsLeft += 5;

                if (bitsLeft >= 8)
                {
                    result.Add((byte)(buffer >> (bitsLeft - 8)));
                    bitsLeft -= 8;
                }
            }

            if (bitsLeft > 0)
            {
                result.Add((byte)(buffer << (8 - bitsLeft)));
            }

            return result.ToArray();
        }
    }
}

Key Learning Points

Concept

Explanation

5-bit chunks

Base32 encodes 5 bits per character (2⁵ = 32 possible values)

8-bit bytes

Input is processed in 8-bit bytes

Bit shifting

Buffer accumulates bits, then extracts 5-bit indices

Padding omitted

Production libraries add "=" padding per RFC 4648

🔢 HOTP (HMAC-Based One-Time Password)

Technical Background

HOTP generates codes using HMAC-SHA1 hashing:

HOTP(K, C) = Truncate(HMAC-SHA-1(K, C))

Where:

The algorithm applies modulo operation to produce the final code length.

Algorithm Visualization

┌─────────────┐     ┌──────────────┐     ┌──────────────┐     ┌─────────────┐
│  Secret Key │ +   │    Counter   │  →  │ HMAC-SHA1    │  →  │ Truncation  │
└─────────────┘     └──────────────┘     └──────────────┘     └─────────────┘
         │                                                                            │
         └────────────────────────────────────────────────────────────────────────────┘
                                                                                │
                                                                     ┌──────────▼──────────┐
                                                                     │ Modulo 10^digits    │
                                                                     └──────────┬──────────┘
                                                                                │
                                                                       ┌────────▼────────┐
                                                                       │   Final Code    │
                                                                       └─────────────────┘

Complete C# Implementation

using System.Security.Cryptography;

namespace Pxoqxo.Otp2fa
{
    public static class Hotp
    {
        /// <summary>
        /// Generates a cryptographically secure random secret
        /// </summary>
        public static string GenerateSecret(int length = 20)
        {
            byte[] bytes = RandomNumberGenerator.GetBytes(length);
            return Base32.ToBase32(bytes);
        }

        /// <summary>
        /// Generates HOTP code per RFC 4226
        /// </summary>
        /// <param name="secret">Shared secret (base32)</param>
        /// <param name="counter">Current counter value</param>
        /// <param name="digits">Code length (typically 6)</param>
        public static string GenerateCode(string secret, long counter, int digits)
        {
            byte[] key = Base32.FromBase32(secret);
            return Compute(key, counter, digits);
        }

        /// <summary>
        /// Verifies HOTP code (basic version without drift)
        /// </summary>
        public static bool Verify(string secret, string code, long counter, int digits)
        {
            byte[] key = Base32.FromBase32(secret);

            string computedCode = Compute(key, counter, digits);
            if (CodeEquals(computedCode, code))
            {
                return true;
            }

            return false;
        }

        /// <summary>
        /// Core HOTP computation per RFC 4226
        /// </summary>
        private static string Compute(byte[] key, long counter, int digits)
        {
            // Convert counter to byte array (big-endian)
            byte[] counterBytes = BitConverter.GetBytes(counter);
            if (BitConverter.IsLittleEndian)
            {
                Array.Reverse(counterBytes);
            }

            using (var hmac = new HMACSHA1(key))
            {
                byte[] hash = hmac.ComputeHash(counterBytes);

                // Dynamic truncation per RFC 4226 Section 5.3
                int offset = hash[hash.Length - 1] & 0x0F;
                int binary = ((hash[offset] & 0x7F) << 24) |
                             ((hash[offset + 1] & 0xFF) << 16) |
                             ((hash[offset + 2] & 0xFF) << 8) |
                             (hash[offset + 3] & 0xFF);

                int otp = binary % (int)Math.Pow(10, digits);
                return otp.ToString($"D{digits}");
            }
        }

        /// <summary>
        /// Constant-time string comparison prevents timing attacks
        /// </summary>
        private static bool CodeEquals(string a, string b)
        {
            if (a.Length != b.Length)
            {
                return false;
            }

            int diff = 0;
            for (int i = 0; i < a.Length; i++)
            {
                diff |= a[i] ^ b[i];
            }

            return diff == 0;
        }
    }
}

HOTP Step-by-Step Example

class Program
{
    static void Main()
    {
        // Phase 1: Enrollment (One-Time Setup)
        string secret = Hotp.GenerateSecret(20);
        Console.WriteLine($"Secret (save securely): {secret}");
        // Expected output: JBSWY3DPEHPK3PXP (or similar 32-char base32 string)

        // Initialize counter (persist this in your database!)
        long currentCounter = 0;

        // Phase 2: Code Generation
        string code = Hotp.GenerateCode(secret, currentCounter, 6);
        Console.WriteLine($"Generated code #{currentCounter}: {code}");
        // Example output: 123456

        // Phase 3: User Verification
        Console.WriteLine("Enter the 6-digit code:");
        string userInput = Console.ReadLine();

        bool isValid = Hotp.Verify(secret, userInput, currentCounter, 6);

        if (isValid)
        {
            Console.WriteLine("✓ Authentication successful!");

            // IMPORTANT: Increment counter after success
            currentCounter++;
            SaveCounterToDatabase(currentCounter);
        }
        else
        {
            Console.WriteLine("✗ Invalid code.");
        }
    }

    static void SaveCounterToDatabase(long counter)
    {
        // Persist to your database/storage
        Console.WriteLine($"New counter stored: {counter}");
    }
}

HOTP Security Considerations

Issue

Solution

Counter desynchronization

Implement drift window (+/- 5 attempts)

Counter rollback

Reject if new code uses old counter

Timing attacks

Use constant-time comparison (already implemented)

Brute force

Rate limit verification attempts

🕒 TOTP (Time-Based One-Time Password)

Technical Background

TOTP (RFC 6238) is HOTP modified to use time as the moving factor:

TOTP(K, T) = HOTP(K, T / X)

Where:

The formula becomes:

Counter = floor((CurrentUnixTime - T₀) / TimeStep)

Where T₀ is typically Unix epoch (0).

Timeline Visualization

Timeline (30-second steps):
├──────────00:00:00─┼──────────00:00:30─┼──────────00:01:00─┤
   Code A            Code B            Code C
   Valid             Valid             Valid
   (expired)         (current)         (future)

User can enter Code B within ~30 second window (+/- 1 step for drift)

Complete C# Implementation

namespace Pxoqxo.Otp2fa
{
    public static class Totp
    {
        /// <summary>
        /// Generates a TOTP-compatible secret (same as HOTP)
        /// </summary>
        public static string GenerateSecret(int length = 20)
        {
            return Hotp.GenerateSecret(length);
        }

        /// <summary>
        /// Generates TOTP code per RFC 6238
        /// </summary>
        /// <param name="secret">Shared secret (base32)</param>
        /// <param name="timeStep">Time step in seconds (default 30)</param>
        /// <param name="digits">Code length (default 6)</param>
        /// <param name="dateTime">Timestamp (uses UTC now if null)</param>
        public static string GenerateCode(
            string secret,
            int timeStep = 30,
            int digits = 6,
            DateTime dateTime = default)
        {
            // Handle default DateTime
            if (dateTime == default)
            {
                dateTime = DateTime.UtcNow;
            }

            long counter = GetCounter(timeStep, dateTime);
            return Hotp.GenerateCode(secret, counter, digits);
        }

        /// <summary>
        /// Verifies TOTP code (exact time match)
        /// </summary>
        public static bool Verify(
            string secret,
            string code,
            int timeStep = 30,
            int digits = 6,
            DateTime dateTime = default)
        {
            if (dateTime == default)
            {
                dateTime = DateTime.UtcNow;
            }

            long counter = GetCounter(timeStep, dateTime);
            return Hotp.Verify(secret, code, counter, digits);
        }

        /// <summary>
        /// Converts DateTime to HOTP-style counter per RFC 6238
        /// </summary>
        private static long GetCounter(int timeStep, DateTime dateTime)
        {
            // Ensure UTC for consistent results across timezones
            long unixTime = new DateTimeOffset(dateTime.ToUniversalTime()).ToUnixTimeSeconds();
            return unixTime / timeStep;
        }
    }
}

Enhanced TOTP with Drift Tolerance

public static class Totp
{
    // ... previous methods ...

    /// <summary>
    /// Verifies TOTP with clock drift tolerance (recommended for production)
    /// </summary>
    /// <param name="secret">Shared secret</param>
    /// <param name="code">User-provided code</param>
    /// <param name="timeStep">Time step in seconds</param>
    /// <param name="digits">Number of digits</param>
    /// <param name="allowedDrift">Number of time steps before/after to accept</param>
    public static bool VerifyWithDrift(
        string secret,
        string code,
        int timeStep = 30,
        int digits = 6,
        int allowedDrift = 1)
    {
        long baseCounter = GetCounter(timeStep, DateTime.UtcNow);

        // Check window around current time step
        for (int i = -allowedDrift; i <= allowedDrift; i++)
        {
            long testCounter = baseCounter + i;
            if (Hotp.Verify(secret, code, testCounter, digits))
            {
                return true;
            }
        }

        return false;
    }

    /// <summary>
    /// Returns remaining seconds until current code expires
    /// </summary>
    public static int GetRemainingValiditySeconds(int timeStep = 30)
    {
        long unixTime = new DateTimeOffset(DateTime.UtcNow).ToUnixTimeSeconds();
        long elapsed = unixTime % timeStep;
        return (int)(timeStep - elapsed);
    }

    /// <summary>
    /// Generates otpauth:// URI for QR code generation
    /// </summary>
    public static string GenerateProvisioningUri(
        string accountName,
        string issuer,
        string secret,
        int digits = 6)
    {
        return $"otpauth://totp/{Uri.EscapeDataString(issuer)}:{Uri.EscapeDataString(accountName)}" +
               $"?secret={secret}&issuer={Uri.EscapeDataString(issuer)}&algorithm=SHA1&" +
               $"digits={digits}&period={30}";
    }
}

TOTP Live Demo Example

class TotpDemo
{
    static async Task Main()
    {
        Console.WriteLine("=== TOTP Code Generator Demo ===\n");

        // Enrollment phase
        string secret = Totp.GenerateSecret(20);
        Console.WriteLine($"Your secret: {secret}\n");

        // Generate provisioning URI for QR code
        string uri = Totp.GenerateProvisioningUri("[email protected]", "MyApp", secret);
        Console.WriteLine($"QR URL (scan with authenticator app):\n{uri}\n");
        Console.WriteLine("Press Ctrl+C to exit\n");

        // Display codes continuously
        try
        {
            while (true)
            {
                string currentCode = Totp.GenerateCode(secret);
                int remaining = Totp.GetRemainingValiditySeconds();

                Console.Write(
                    $"\r[{DateTime.Now:HH:mm:ss}] Code: {currentCode} | " +
                    $"Expires in: {remaining:00}s  ");

                await Task.Delay(1000);
            }
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("\n\nStopping demo...");
        }

        // Verification test
        Console.WriteLine("\nEnter a code to verify:");
        string userInput = Console.ReadLine();
        bool isValid = Totp.VerifyWithDrift(secret, userInput);

        Console.WriteLine(
            isValid
                ? "✓ Verified successfully!"
                : "✗ Invalid or expired code");
    }
}

⚙️ Configuration Reference

Parameter

HOTP Default

TOTP Default

Security Note

Hash Algorithm

SHA-1

SHA-1

SHA-256/512 available but less compatible

Code Digits

6

6

8 digits provides more entropy but harder to enter

Time Step

N/A

30 sec

Shorter = more secure but more expiration failures

Allowed Drift

±5 counters

±1 step

Wider window increases brute force surface

Secret Length

20 bytes

20 bytes

Minimum 128 bits recommended

Production-Grade Configuration

// Recommended hardening for production systems
public class OtpConfig
{
    public HashAlgorithmType HashAlgorithm = HashAlgorithmType.SHA256;
    public int CodeDigits = 6;
    public int TimeStep = 30;
    public int AllowedClockDrift = 1;
    public int SecretByteLength = 20;  // 160-bit minimum
    public int MaxVerificationAttempts = 3;
    public TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
}

🔒 Security Best Practices

Secret Storage

// ❌ BAD: Plain text storage
File.WriteAllText("secrets.txt", secret);

// ✅ GOOD: Encrypt at rest using OS facilities
protectedSecret = ProtectedData.Protect(
    Encoding.UTF8.GetBytes(secret),
    null,
    DataProtectionScope.CurrentUser
);

// ✅ BEST: Use dedicated secret management
// Azure Key Vault, AWS Secrets Manager, HashiCorp Vault

Prevent Brute Force Attacks

public class SecureOtpVerifier
{
    private readonly Dictionary<string, int> _failedAttempts = new();
    private const int MaxAttempts = 5;

    public bool VerifyWithRateLimit(string userId, string secret, string code)
    {
        if (_IsAccountLocked(userId))
            throw new AccountLockedException("Too many failed attempts");

        if (Totp.VerifyWithDrift(secret, code))
        {
            _ResetFailedAttempts(userId);
            return true;
        }
        else
        {
            _IncrementFailedAttempts(userId);
            return false;
        }
    }

    private void _IncrementFailedAttempts(string userId)
    {
        _failedAttempts.TryGetValue(userId, out int count);
        _failedAttempts[userId] = count + 1;
        // Implement account lockout logic when threshold reached
    }
}

Recovery Strategy

/// <summary>
/// Generate one-time-use backup codes for account recovery
/// </summary>
public static List<string> GenerateRecoveryCodes(int count = 10)
{
    var codes = new List<string>();

    for (int i = 0; i < count; i++)
    {
        // Generate random 8-character codes
        string recovery = Guid.NewGuid().ToString()[..8].ToUpper();
        codes.Add(recovery);
    }

    return codes;
}

Summary

Two-Factor Authentication (2FA) adds a critical security layer beyond passwords. One-Time Passwords (OTPs) generate temporary codes that expire quickly, reducing replay attack risks. HOTP generates codes using a counter-based moving factor, while TOTP modifies HOTP to use time as the moving factor. Base32 encoding provides a way to represent binary secrets using printable ASCII characters, and security considerations include counter synchronization, clock drift tolerance, constant-time comparison, rate limiting, secure secret storage, and recovery strategies.