Time-based One-Time Passwords (TOTP): A Hands-On Guide

Published on: April 28, 2025

You've probably used Google Authenticator, Microsoft Authenticator, or a similar app (or even hardware) which produces a time-senstive code as 2-factor authentication (2FA).

Have you ever wondered how they work?

Time-based One-Time Password (TOTP) is powerful and a cornerstone of two-factor authentication (2FA).

In this article, we'll dig into TOTP, implement it from scratch in C# .NET 9, and build a mini-project to solidify your understanding.

This guide will equip you with practical skills and a working solution.

Our mini-project will consist of three console projects in a single .NET solution:

  • TotpShared: A shared library containing the TOTP logic and secret key.
  • TotpGenerator: A console app that generates a TOTP code every 30 seconds and displays the time remaining.
  • TotpClient: A console app that prompts for a code and validates it, showing if it's valid or expired.

By the end, you'll have two consoles running simultaneously: one producing codes, the other validating them. Plus a deep understanding of TOTP's mechanics, pitfalls, and real-world applications.

Let's dive in!

What is TOTP?

TOTP is a standard algorithm (defined in RFC 6238) that generates time-sensitive, one-time codes for authentication.

It's widely used in 2FA to add security beyond passwords. Here's the gist:

  • A shared secret key is established between the client (e.g., your authenticator app) and the server.
  • Both use this key and the current time to generate identical 6- or 8-digit codes.
  • Codes are valid for a short window (typically 30 seconds) and refresh automatically.
  • The server validates a user's code by recomputing it with the same key and time.

TOTP's elegance lies in its simplicity: codes are generated offline, and the server only stores the secret key, not the codes.

However, nuances like clock synchronization and byte ordering require careful handling.

Deep Dive: Why TOTP Matters
TOTP is an evolution of the HMAC-based One-Time Password (HOTP) algorithm, which uses a counter instead of time. TOTP's time-based nature makes it ideal for scenarios where codes must expire quickly, like securing web logins or mobile banking apps. Its offline capability and short validity period make it resilient against interception and replay attacks.

Understanding the TOTP Algorithm

Before coding, let's outline TOTP's steps:

  1. Shared Secret: A random key (e.g., 20 bytes) is shared during setup, often via a QR code.
  2. Time Input: The current Unix timestamp (seconds since January 1, 1970, UTC) is divided by the time step (usually 30 seconds): floor(current_time / 30).
  3. HMAC-SHA1: The secret key and time counter are fed into HMAC-SHA1, producing a 20-byte hash.
  4. Dynamic Truncation: A 4-byte chunk of the hash is extracted (based on an offset) and converted to a number.
  5. Code Generation: The number is modulo 10^digits (e.g., 10^6 for 6 digits) to produce the code (e.g., 123456).
  6. Validation: The server recomputes the code using the same key and time. If they match, authentication succeeds.

Visual flow:

Secret Key + Time Counter → HMAC-SHA1 → Truncate → 6-/8-Digit Code

Deep Dive: Why HMAC-SHA1?
HMAC-SHA1 is chosen for its cryptographic security, speed, and widespread support. HMAC ensures the hash can't be reversed to reveal the secret key. While SHA1 has vulnerabilities in some contexts, it's still secure for TOTP due to the short-lived codes and random secret key.

Project Overview

Our solution will have three projects:

  • TotpShared: A class library with the TOTP logic (TotpService) and a hardcoded secret key (Secrets).
  • TotpGenerator: Generates TOTP codes every 30 seconds, showing the code and time remaining.
  • TotpClient: Accepts user-entered codes and validates them.

Both console apps reference TotpShared for shared logic and the secret key.

You'll run them in separate console windows to simulate a 2FA flow: one generating codes, the other validating them.

Step 1: Setting Up the Solution

Let's create the solution and projects. You can use the followin CLI commands or do it using your preferred IDE.

  1. Create a directory for the project:

    mkdir TotpDemo
    cd TotpDemo
    
  2. Create a new solution:

    dotnet new sln -n TotpDemo
    
  3. Create the shared library project:

    dotnet new classlib -n TotpShared
    dotnet sln add TotpShared/TotpShared.csproj
    
  4. Create the TOTP Generator project:

    dotnet new console -n TotpGenerator
    dotnet sln add TotpGenerator/TotpGenerator.csproj
    
  5. Create the TOTP Client project:

    dotnet new console -n TotpClient
    dotnet sln add TotpClient/TotpClient.csproj
    
  6. Add references to TotpShared:

    dotnet add TotpGenerator/TotpGenerator.csproj reference TotpShared/TotpShared.csproj
    dotnet add TotpClient/TotpClient.csproj reference TotpShared/TotpShared.csproj
    

Your directory structure might look something similar to this:

TotpDemo/
├── TotpDemo.sln
├── TotpShared/
│   ├── TotpShared.csproj
│   ├── TotpService.cs
│   ├── Secrets.cs
├── TotpGenerator/
│   ├── TotpGenerator.csproj
│   ├── Program.cs
├── TotpClient/
│   ├── TotpClient.csproj
│   ├── Program.cs

Step 2: Implementing the TOTP Logic

The TotpShared project contains two classes:

  • Secrets: Stores the hardcoded Base32 secret key.
  • TotpService: Handles TOTP code generation, validation, and time calculations.

Creating the Secrets Class

In TotpShared/Secrets.cs, add:

namespace TotpDemo
{
    // This class contains the hardcoded Base32 secret key used for TOTP generation and validation.
    // In production, this key should be securely stored and managed.
    public static class Secrets
    {
        public const string SecretKey = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";
    }
}

Note: This 32-character Base32 string represents a 20-byte secret key. In a real app, store it securely (e.g., in a secrets manager like Azure Key Vault).

Creating the TotpService Class

The TotpService class is the heart of our TOTP implementation.

It's dense, so we'll build it step-by-step, explaining each component to make it clear and digestible.

Create TotpShared/TotpService.cs and follow along.

Step 2.1: Define the Class and Constructor

Start with the class structure and constructor to initialize the TOTP parameters.

using System.Security.Cryptography;

namespace TotpDemo
{
    public class TotpService
    {
        private readonly byte[] _secretKey;
        private readonly int _timeStepSeconds;
        private readonly int _codeDigits;

        public TotpService(string base32Secret, int timeStepSeconds = 30, int codeDigits = 6)
        {
            _secretKey = Base32Decode(base32Secret);
            _timeStepSeconds = timeStepSeconds;
            _codeDigits = codeDigits;
        }
    }
}

Explanation:

  • Fields:
    • _secretKey: Stores the decoded secret key as a byte array for HMAC calculations.
    • _timeStepSeconds: The time window for each code (default: 30 seconds).
    • _codeDigits: The length of the TOTP code (default: 6 digits).
  • Constructor: Takes a Base32-encoded secret (e.g., from a QR code), time step, and code length. It decodes the Base32 secret using Base32Decode method (we'll add it later).
  • Why Base32?: TOTP secrets are typically Base32-encoded for human readability and QR code compatibility.

Step 2.2: Base32 Decoding

Add the Base32Decode method to convert Base32 strings to bytes.

private static byte[] Base32Decode(string base32)
{
    const string base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";

    base32 = base32.ToUpper().Replace("=", "");

    var bytes = new byte[base32.Length * 5 / 8];
    var byteIndex = 0;
    var bits = 0;
    var bitCount = 0;

    foreach (var c in base32)
    {
        var value = base32Chars.IndexOf(c);

        if (value < 0)
        {
            throw new ArgumentException("Invalid Base32 character.");
        }

        bits = bits << 5 | value;
        bitCount += 5;

        while (bitCount >= 8)
        {
            bytes[byteIndex++] = (byte)(bits >> (bitCount - 8));
            bitCount -= 8;
        }
    }

    return bytes;
}

Explanation:

  • Purpose: Converts a Base32 string (e.g., "JBSWY3DPEHPK3PXP") to a byte array for HMAC.
  • Base32 Alphabet: Uses A-Z and 2-7 (32 characters). Each character represents 5 bits.
  • Process:
    • Strips padding (=) and converts to uppercase.
    • Iterates through each character, mapping it to a 5-bit value.
    • Accumulates bits and extracts 8-bit bytes when enough bits are available.
  • Output: A 20-byte array for a 32-character Base32 string (32 * 5 / 8 = 20 bytes).
  • Error Handling: Throws an exception for invalid characters (e.g., 1 or lowercase letters).

Gotcha: Base32 is case-insensitive, but our code enforces uppercase for simplicity. In production, validate input strictly.

Let's analyze this further and answer some questions

  1. Why the characters ABCDEFGHIJKLMNOPQRSTUVWXYZ234567?

The choice of these chars is not arbitrary, it's defined by the Base32 encoding standards in RFC 4648.

  • 32 Characters for 5 Bits: Base32 encodes binary data into a set of 32 distict characters, where each character refresents a 5-bit value (since 2^5 = 32). This allows efficient packing of binary data into a human-readable format.
  • Standardization: FRC 4648 defines two Base32 alphabets:
    • Standard Base32: ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 (used in TOTP and most applications).
    • Base43hex: 0123456789ABCDEFGHIJKLMNOPQRSTUV (less common, used in some niche cases).
  • Avoiding Confusion: The digits, 0, 1, 8, and 9 are excluded to prevent confusion with letters (O, I, B, etc.). The range 2-7 is unambiguos and sufficient to complete the 32-character set.

In our code, const string base32Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; defines this standard alphabet, and base32Chars.IndexOf(c) maps each character to its 5-bit value (e.g., A=0, B=1, ..., 7=31).

Deep Dive: Why Base32 for TOTP Secrets? TOTP secrets are Base32-encoded because it balances human readability and data density. Compared to Base64 (which uses 64 characters, including / and +, for 6-bit values), Base32 avoids special characters, making it easier to display in QR codes or type manually. A 20-byte secret (160 bits) becomes a 32-character Base32 string (160 ÷ 5 = 32), which is manageable for users.

  1. Why Map Each Character to a 5-Bit Value?

Each character in the Base32 alphabet represents a 5-bit value because Base32 is designed to encode binary data using 32 distinct symbols, and 2^5 = 32. Let's explore why this is the case and how it works in the decoding process.

5 Bits?

  • Number of Symbols: Base32 uses 32 characters (A-Z, 2-7), so each character must represent one of 32 possible values. Again, since 2^5 = 32, each character naturally encodes 5 bits of data.
  • Mapping: In our code, base32Chars.IndexOf(c) returns the 5-bit value for each character:
    • A → 0 (00000 in binary)
    • B → 1 (00001)
    • ...
    • Z → 25 (11001)
    • 2 → 26 (11010)
    • ...
    • 7 → 31 (11111)

How It Works in the Code?

  • Character Lookup: For each character c in the Base32 string, base32Chars.IndexOf(c) finds its index (0–31), which is a 5-bit value.
  • Bit Accumulation: The line bits = bits << 5 | value; does two things:
    • Shifts the current bits left by 5 positions (<< 5) to make room for the new 5-bit value.
    • Combines the new value using a bitwise OR (|), effectively appending the 5 bits to the bit stream.
  • Example:
    • For A (00000), bits is shifted and 00000 is ORed.
    • For B (00001), bits is shifted again, and 00001 is ORed, building a continuous bit stream.

This process collects the 5-bit values from each character into a single bit stream, which we'll later extract as 8-bit bytes.

Deep Dive: The term "ORed" describes the action of using the | operator to merge the new 5-bit value into the bit stream stored in bits.

  1. Why Extract 8-Bit Bytes?

After mapping each Base32 character to a 5-bit value, we extract 8-bit bytes because the output of Base32Decode must be a byte array (where each byte is 8 bits) to match the requirements of HMAC-SHA1 and other cryptographic operations.

Let's unpack why and how this happens.

Why 8-Bit Bytes?

  • Byte-Based Systems: Computers and cryptographic algorithms (like HMAC-SHA1) operate on bytes (8 bits). The secret key for HMAC-SHA1 must be a byte array, so we need to convert the 5-bit Base32 data into 8-bit bytes.
  • Bit Stream Conversion: Base32 provides data in 5-bit chunks, but we need to regroup these bits into 8-bit units to produce a usable byte array. This requires accumulating bits and extracting them whenever we have enough (8 or more).
  • Data Integrity: The Base32 encoding ensures that the total number of bits is a multiple of 40 (since 8 characters = 40 bits = 5 bytes), so no data is lost when regrouping into 8-bit bytes.

Step 2.3: Time Counter Calculation

Add methods to compute the time counter and seconds remaining.

private long GetTimeCounter(DateTime utcTime)
{
    return (long)(utcTime - DateTime.UnixEpoch).TotalSeconds / _timeStepSeconds;
}

public int GetSecondsRemaining(DateTime? utcNow = null)
{
    utcNow ??= DateTime.UtcNow;
    var secondsSinceEpoch = (long)(utcNow.Value - DateTime.UnixEpoch).TotalSeconds;
    return _timeStepSeconds - (int)(secondsSinceEpoch % _timeStepSeconds);
}

Explanation:

  • GetTimeCounter:
    • Converts a UTC DateTime to seconds since the Unix epoch (January 1, 1970).
    • Divides by _timeStepSeconds (30) to get the current time step (e.g., floor(seconds / 30)).
    • Returns a 64-bit integer for HMAC input.
  • GetSecondsRemaining:
    • Calculates seconds left in the current time step using modulo (seconds % 30).
    • Uses nullable DateTime for testability (defaults to DateTime.UtcNow).
  • Why UTC?: TOTP requires UTC to ensure client and server time alignment.

Pro Tip: For testing, pass a fixed utcNow to simulate different times without waiting.

Step 2.4: Generating TOTP Codes

Add the GenerateCode method to compute TOTP codes.

public string GenerateCode(DateTime? utcNow = null)
{
    utcNow ??= DateTime.UtcNow;

    var timeCounter = GetTimeCounter(utcNow.Value);
    var timeBytes = BitConverter.GetBytes(timeCounter);

    if (BitConverter.IsLittleEndian)
    {
        Array.Reverse(timeBytes);
    }

    using var hmac = new HMACSHA1(_secretKey);

    var hash = hmac.ComputeHash(timeBytes);
    var offset = hash[^1] & 0x0F; // Last byte determines offset
    var binary = (hash[offset] & 0x7F) << 24 |
                    hash[offset + 1] << 16 |
                    hash[offset + 2] << 8 |
                    hash[offset + 3];

    var code = binary % (int)Math.Pow(10, _codeDigits);

    return code.ToString().PadLeft(_codeDigits, '0');
}

Explanation:

  • Time Counter: Gets the current time step using GetTimeCounter.
  • Byte Conversion: Converts the 64-bit timeCounter to an 8-byte array with BitConverter.GetBytes.
  • Endianness: Reverses bytes if the system is little-endian to meet TOTP’s big-endian requirement (see deep dive below).
  • HMAC-SHA1:
    • Creates an HMACSHA1 instance with _secretKey.
    • Computes a 20-byte hash from timeBytes.
  • Dynamic Truncation:
    • Uses the last byte's low nibble (hash[^1] & 0x0F) as an offset (0–15).
    • Extracts 4 bytes starting at offset.
    • Clears the most significant bit of the first byte (& 0x7F) to ensure a positive number.
    • Combines bytes into a 32-bit integer using bit shifts.
  • Code Generation:
    • Takes the integer modulo 10^_codeDigits (e.g., 10^6 = 1,000,000) to get a 6-digit number.
    • Pads with leading zeros (e.g., 123 becomes 000123).
  • Disposable: Uses using var to ensure HMACSHA1 is disposed properly.

Gotcha: The bit shift operations (<< 24, etc.) assume the hash bytes are read in big-endian order, as produced by HMACSHA1.

Explanation:

  • Time Window: Checks three time steps (current, previous, next) by offsetting utcNow by ±_timeStepSeconds (30 seconds). This handles clock drift.
  • Code Computation: Repeats the same HMAC and truncation logic as GenerateCode for each time step.
  • Comparison: Compares the computed code (padded) with the user's input. Returns true if any match.
  • Efficiency: Stops at the first match to minimize computation.

Best Practice: The ±1 time step tolerance is standard but can be adjusted (e.g., ±2) for more leniency, at the cost of security.

Deep Dive: Big-Endian and Endianness
Endianness refers to the order in which bytes of a multi-byte value (like a 64-bit integer) are stored or transmitted. There are two main types:

  • Big-Endian: The most significant byte (the "big" end) is stored first. For example, the 64-bit number 0x0123456789ABCDEF is stored as 01 23 45 67 89 AB CD EF.
  • Little-Endian: The least significant byte is first: EF CD AB 89 67 45 23 01.

In TOTP, the time counter (a 64-bit integer) must be converted to a byte array in big-endian order before being passed to HMAC-SHA1, as specified in RFC 6238. This ensures consistent HMAC outputs across platforms. However, most modern CPUs (e.g., x86, x64) are little-endian, so .NET's BitConverter.GetBytes produces little-endian byte arrays. Our code checks BitConverter.IsLittleEndian and reverses the bytes (Array.Reverse) to make them big-endian.

Why Big-Endian? Big-endian is standard in many cryptographic protocols and network standards (e.g., TCP/IP) because it matches how humans read numbers (most significant digits first). A byte order mismatch would produce incorrect HMAC hashes, leading to invalid TOTP codes. For debugging, log the byte array with BitConverter.ToString(timeBytes) to verify the order (e.g., 00-00-00-00-00-00-04-B0 for big-endian).

Walkthrough: Step by Step

Since this is the core logic, let's walt throught it with an example, using:

  • Secret Key: The Base32 string JBSWY3DPEHPK3PXP (decoded to a 20-byte array).
  • Time: April 28, 2025, 12:00:00 UTC (Unix timestamp: 1,743,897,600 seconds).
  • Time Step: 30 seconds (default _timeStepSeconds).
  • Code Digits: 6 (default _codeDigits).

2.4.1: Get the Current Time

utcNow ??= DateTime.UtcNow;
  • Assume utcNow is April 28, 2025, 12:00:00 UTC.
  • Unix timestamp = 1,743,897,600 seconds.

2.4.2: Calculate the Time Counter

var timeCounter = GetTimeCounter(utcNow.Value);
  • Timestamp = 1,743,897,600 seconds.
  • _timeStepSeconds = 30.
  • timeCounter = 1,743,897,600 / 30 = 58,129,920.

Why This Matters: The time counter ensures codes change every 30 seconds, as each window produces a unique HMAC input.

2.4.3: Convert Time Counter to Bytes

var timeBytes = BitConverter.GetBytes(timeCounter);
  • timeCounter = 58,129,920 (hex: 0x0376_8000).
  • In little-endian (typical for x86/x64):
    • BitConverter.GetBytes(58,129,920) = [0x00, 0x80, 0x76, 0x03, 0x00, 0x00, 0x00, 0x00].
    • Least significant byte (0x00) is first.

2.4.4: Ensure Big-Endian Byte Order

if (BitConverter.IsLittleEndian)
{
    Array.Reverse(timeBytes);
}
  • Input: [0x00, 0x80, 0x76, 0x03, 0x00, 0x00, 0x00, 0x00] (little-endian).
  • After Array.Reverse: [0x00, 0x00, 0x00, 0x00, 0x03, 0x76, 0x80, 0x00] (big-endian).
  • This matches 0x0000_0000_0376_8000, the correct representation of 58,129,920.

2.4.5: Compute HMAC-SHA1 Hash

using var hmac = new HMACSHA1(_secretKey);
var hash = hmac.ComputeHash(timeBytes);
  • _secretKey: Assume decoded from JBSWY3DPEHPK3PXP to a 20-byte array (e.g., [0x10, 0x11, ..., 0x1F] for simplicity; actual values depend on Base32 decoding).
  • timeBytes: [0x00, 0x00, 0x00, 0x00, 0x03, 0x76, 0x80, 0x00].
  • hmac.ComputeHash produces a 20-byte hash. Let's assume a sample output (since HMAC-SHA1 is deterministic but complex):
    • hash = [0x1A, 0x2B, 0x3C, 0x4D, 0x5E, 0x6F, 0x70, 0x81, 0x92, 0xA3, 0xB4, 0xC5, 0xD6, 0xE7, 0xF8, 0x09, 0x1A, 0x2B, 0x3C, 0x0D].
    • Last byte: 0x0D (13 in decimal).

Note: The actual hash depends on the secret key and time counter. For brevity, we use a placeholder hash, but you can log hash with BitConverter.ToString(hash) to see the real output.

2.4.6: Determine the Truncation Offset

var offset = hash[^1] & 0x0F;
  • Last byte: hash[19] = 0x0D (13 in decimal).
  • offset = 0x0D & 0x0F = 0x0D (13).
  • The offset is 13, so we'll extract bytes hash [13..17].

Why This Step?: Dynamic truncation (using the hash's last byte) ensures the code isn't derived from a predictable part of the hash, enhancing security.

2.4.7: Extract and Combine 4 Bytes

var binary = (hash[offset] & 0x7F) << 24 |
                hash[offset + 1] << 16 |
                hash[offset + 2] << 8 |
                hash[offset + 3];
  • Bytes at offset = 13: hash[13..17] = [0xE7, 0xF8, 0x09, 0x1A].
  • First byte: hash[13] & 0x7F = 0xE7 & 0x7F = 0x67 (clearing bit 7).
  • Combine:
    • (0x67 << 24) = 0x6700_0000
    • (0xF8 << 16) = 0x00F8_0000
    • (0x09 << 8) = 0x0000_0900
    • (0x1A) = 0x0000_001A
  • Bitwise OR: 0x6700_0000 | 0x00F8_0000 | 0x0000_0900 | 0x0000_001A = 0x67F8_091A.
  • binary = 0x67F8_091A (1,736,131,610 in decimal).

Gotcha: The & 0x7F ensures the result fits in 31 bits, avoiding negative numbers in some implementations. Misaligning shifts can corrupt the integer.

2.4.8: Generate the TOTP Code

var code = binary % (int)Math.Pow(10, _codeDigits);
  • binary = 1,736,131,610.
  • _codeDigits = 6, so Math.Pow(10, 6) = 1,000,000.
  • code = 1,736,131,610 % 1,000,000 = 131,610.

Why Modulo?: The modulo operation truncates the large binary value to fit the desired digit count, preserving randomness from the HMAC hash.

2.4.9: Format the Code

return code.ToString().PadLeft(_codeDigits, '0');
  • code = 131,610.
  • code.ToString() = "131610".
  • PadLeft(6, '0') = "131610" (already 6 digits, so no padding needed).
  • If code = 456, it would be "000456".
  • Output: "131610".

Pro Tip: Padding ensures consistent output, critical for user display and validation (e.g., authenticator apps always show 6 digits).

Step 2.5: Validating TOTP Codes

Add the ValidateCode method to check user-entered codes.

public bool ValidateCode(string code, DateTime? utcNow = null)
{
    utcNow ??= DateTime.UtcNow;

    // A code generated at time T will be valid for T, T-1, and T+1
    for (var i = -1; i <= 1; i++)
    {
        var timeCounter = GetTimeCounter(utcNow.Value.AddSeconds(i * _timeStepSeconds));
        var timeBytes = BitConverter.GetBytes(timeCounter);

        if (BitConverter.IsLittleEndian)
        {
            Array.Reverse(timeBytes);
        }

        using var hmac = new HMACSHA1(_secretKey);

        var hash = hmac.ComputeHash(timeBytes);
        var offset = hash[^1] & 0x0F;
        var binary = (hash[offset] & 0x7F) << 24 |
                            hash[offset + 1] << 16 |
                            hash[offset + 2] << 8 |
                            hash[offset + 3];

        var expectedCode = binary % (int)Math.Pow(10, _codeDigits);

        if (expectedCode.ToString().PadLeft(_codeDigits, '0') == code)
        {
            return true;
        }
    }

    return false;
}

Step 3: Building the TOTP Generator

The generator runs in a loop, displaying a new code every 30 seconds.

In TotpGenerator/Program.cs, add:

using TotpDemo;

namespace TotpGenerator
{
    static class Program
    {
        private static void Main()
        {
            Console.Title = "===== TOTP Generator =====";
            Console.WriteLine("===== TOTP Generator =====");
            Console.WriteLine("TOTP Generator started. Press Ctrl+C to exit.\n\n");

            var totp = new TotpService(Secrets.SecretKey);

            // Default TOTP time step in seconds. This might go into a configuration file in a real application.
            const int timeStepSeconds = 30;

            while (true)
            {
                var code = totp.GenerateCode();
                var secondsRemaining = totp.GetSecondsRemaining();

                Console.SetCursorPosition(0, Console.CursorTop - 1);
                Console.WriteLine($"Current Code: {code} (Valid for {secondsRemaining} seconds)");

                Thread.Sleep(1000); // Update every second

                if (secondsRemaining == timeStepSeconds)
                {
                    // New time step; clear the previous line and show new code
                    Console.SetCursorPosition(0, Console.CursorTop );
                }
            }
        }
    }
}

What’s Happening?

  • Uses Secrets.SecretKey from TotpShared.
  • Generates and displays a code with a countdown, updating every second.
  • Overwrites the previous line when a new time step begins for a clean UI.
  • The console title distinguishes this window.

Best Practice: In production, avoid hardcoding time steps. Use configuration (e.g., IConfiguration) for flexibility.

Step 4: Building the TOTP Client

The client prompts for a code and validates it.

In TotpClient/Program.cs, add:

using TotpDemo;

namespace TotpClient
{
    static class Program
    {
        static void Main()
        {
            var totp = new TotpService(Secrets.SecretKey);

            Console.Title = "TOTP Client";
            Console.WriteLine("===== TOTP Client =====");
            Console.WriteLine("TOTP Client started. Enter a code to validate (or 'exit' to quit).\n");

            while (true)
            {
                Console.Write("Enter TOTP code: ");

                var input = Console.ReadLine()?.Trim()!;

                if (string.IsNullOrEmpty(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
                {
                    break;
                }

                if (input.Length != 6 || !int.TryParse(input, out _))
                {
                    Console.WriteLine("Error: Please enter a 6-digit code.");

                    continue;
                }

                var isValid = totp.ValidateCode(input);

                Console.WriteLine(isValid ? "Code is valid!" : "Code is invalid or expired.");
                Console.WriteLine();
            }
        }
    }
}

What’s Happening?

  • Uses Secrets.SecretKey from TotpShared.
  • Validates 6-digit codes, handling invalid input gracefully.
  • Exits on "exit" or empty input.
  • The console title distinguishes this window.

Pro Tip: Add rate-limiting in production to prevent brute-force attacks (e.g., limit attempts per minute).

Step 5: Running the Projects

To run both projects simultaneously:

  1. Open two terminal windows in the TotpDemo directory.
  2. In the first terminal, run the generator:
    cd TotpGenerator/
    dotnet run
    
  3. In the second terminal, run the client:
    cd TotpClient/
    dotnet run
    

You'll see:

  • Generator Console: Shows a new code every 30 seconds (e.g., "Current Code: 123456 (Valid for 25 seconds)"), updating each second.
  • Client Console: Prompts for a code. Enter the generator's current code to see "Code is valid!" or try an old code to see "Code is invalid or expired."

Troubleshooting Tip: If codes don't validate, ensure your system clock is synced to UTC (most OSes use NTP). Check the secret key matches in both projects.

Phew! Quite a bit of code!

If you prefer, you can grab the solution from this repo that I prepared for you. Feel free to clone/fork/download it and play with it.

Step 6: Testing the Solution

Test these scenarios:

  • Valid Code: Enter the generator's current code in the client. It should validate.
  • Expired Code: Wait 60 seconds, then enter a code from two time steps ago. It should fail (remember that we allow ±1 time step).
  • Invalid Code: Enter a random 6-digit number. It should fail.
  • Clock Drift: Adjust your system clock by 2 minutes. Validation may fail unless you expand the time step tolerance in ValidateCode.

Common Errors and Gotchas

  1. Clock Drift: Significant clock differences break validation. Sync clocks or increase the validation window (e.g., ±2 time steps).
  2. Mismatched Secrets: Ensure Secrets.SecretKey is identical in both projects.
  3. Endianness Errors: Incorrect byte order for HMAC produces wrong codes. Our code handles this with Array.Reverse.
  4. Base32 Issues: Invalid Base32 characters (e.g., 1 vs. I) cause decoding errors. Use A-Z, 2-7.
  5. Console Flicker: Rapid console updates in the generator may flicker. Our Thread.Sleep(1000) helps, but consider a UI library for production.

Security Considerations

Our demo is educational, but production systems need:

  • Secret Storage: Store keys in a secure vault (e.g., AWS Secrets Manager), not hardcoded.
  • Key Length: Use 20-byte (160-bit) secrets for strength.
  • Rate Limiting: Thwart brute-force attacks with validation limits.
  • Phishing: Pair TOTP with passwords and educate users on phishing risks.
  • Clock Sync: Use reliable time sources (e.g., NTP).

Performance Notes

  • HMAC-SHA1: Fast and suitable for real-time use. Our implementation is lightweight.
  • Validation: Checking ±1 time steps adds minimal overhead. Cache time counters for high-traffic servers.
  • Scalability: TOTP is stateless (no code storage), making it highly scalable.

Alternative Approaches

  • Libraries: Use Google.Authenticator or OtpSharp for production. They're tested and include QR code helpers but hide internals.
  • HOTP: Use counter-based OTPs (RFC 4226) for unreliable time sync (e.g., hardware tokens).
  • Push Notifications: For mobile apps, push-based 2FA (approve/deny prompts) offers better UX but needs a network.

Why We Built from Scratch: Manual implementation teaches the algorithm's details, aiding debugging and customization.

Recommended Learning Resources

Conclusion

You've built a robust TOTP system in C# .NET 9, with a shared library and two console apps simulating a 2FA flow.

You've learned:

  • TOTP's HMAC-SHA1-based code generation, step by step.
  • Implementing TOTP from scratch, including Base32 and endianness handling.
  • Security, performance, and real-world applications.

Your solution is running: one console generating codes, another validating them.

Experiment with codes, tweak time steps, or integrate into a web app.

Bookmark this guide, share it, and keep exploring secure authentication!


Happy Coding! ⚡


Cyber SecurityEncryptionHashing2FA