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:
K: Shared secret key (base32 encoded typically)
C: Moving factor (counter value)
Truncate: Extracts 4 bytes from HMAC result, converts to 31-bit integer
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:
T: Current Unix timestamp in seconds
X: Time step (default 30 seconds)
T/X: Counter derived from elapsed time intervals
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.

Join the conversation! Your thoughts help the community grow.