TOTP Two-Factor Authentication

Authentication & Authorization intermediate Building with AI ~35 min read

Overview

Two-factor authentication (2FA) adds a second layer of identity verification beyond passwords. Time-based One-Time Passwords (TOTP), defined in RFC 6238, are the most widely adopted 2FA mechanism for web applications. The user installs an authenticator app (Google Authenticator, Authy, 1Password) that generates a six-digit code every 30 seconds. This code is derived from a shared secret and the current time—the server computes the same code independently, and if they match, the user has proven possession of the secret.

TOTP is attractive because it requires no network connectivity for code generation (unlike SMS-based 2FA), has no per-verification cost (unlike SMS), and is resistant to phishing when combined with domain-bound authentication. The cryptographic foundation is HMAC-SHA1 applied to a time counter, producing a truncated numeric code. The simplicity of the algorithm means it can be implemented in any language in under 50 lines, yet it provides meaningful security improvement against credential stuffing and password reuse attacks.

The platform implements TOTP 2FA with QR code provisioning, backup recovery codes, and per-user enforcement policies. The implementation uses the pquerna/otp library in Go for TOTP generation and validation, stores encrypted secrets in PostgreSQL, and generates QR codes server-side using the otpauth:// URI scheme that authenticator apps understand natively.

Core Concepts

HOTP: The Foundation

TOTP builds on HOTP (HMAC-based One-Time Password, RFC 4226). HOTP computes a one-time password from a shared secret K and a counter C: HOTP(K, C) = Truncate(HMAC-SHA1(K, C)). The truncation function extracts a 4-byte dynamic offset from the HMAC output and converts it to a 6–8 digit decimal number. The counter increments with each authentication attempt, keeping client and server in sync.

Key Concept: Time as Counter

TOTP replaces HOTP’s event counter with a time counter: T = floor(current_unix_time / time_step). With a 30-second time step, the counter increments every 30 seconds, and both client and server derive the same code from the same time window. This eliminates synchronization issues—the only requirement is that both clocks are reasonably accurate.

The Shared Secret

The shared secret is a random byte string (typically 20 bytes / 160 bits) generated server-side during TOTP enrollment. It is encoded as Base32 for display and QR code embedding, using the otpauth://totp/ URI scheme. The secret must be stored securely on the server (encrypted at rest) because anyone with the secret can generate valid codes. The user’s authenticator app stores it locally, protected by the device’s security (biometrics, device encryption).

Time Window and Skew

Because network latency and clock drift can cause the server to receive a code just after the time window changes, servers typically accept codes from the current window plus one or two adjacent windows. A skew of ±1 means the server checks three time windows (the previous, current, and next 30-second period), giving the user up to 90 seconds of tolerance. Larger skew values reduce failed authentications but increase the attack window.

Key Concept: Recovery Codes

If a user loses their authenticator device, they lose access to their TOTP secret. Recovery codes are single-use backup codes generated during 2FA enrollment that bypass TOTP. They must be stored hashed (like passwords) on the server and presented to the user exactly once—typically as a downloadable file or printable list.

How It Works

TOTP Algorithm Step by Step

The TOTP computation follows these steps: (1) Compute the time counter T = floor(unix_time / 30). (2) Encode T as an 8-byte big-endian integer. (3) Compute HMAC-SHA1(secret, T_bytes) producing a 20-byte hash. (4) Extract a 4-byte dynamic binary code using the last nibble as an offset. (5) Convert to a 31-bit unsigned integer and take modulo 10^digits to produce the final code.

// Raw TOTP implementation (educational - use a library in production)
func generateTOTP(secret []byte, t time.Time, digits int) string {
    // Step 1: compute time counter
    counter := uint64(t.Unix()) / 30

    // Step 2: encode as 8-byte big-endian
    buf := make([]byte, 8)
    binary.BigEndian.PutUint64(buf, counter)

    // Step 3: HMAC-SHA1
    mac := hmac.New(sha1.New, secret)
    mac.Write(buf)
    hash := mac.Sum(nil)

    // Step 4: dynamic truncation
    offset := hash[len(hash)-1] & 0x0f
    code := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7fffffff

    // Step 5: modulo for digit count
    mod := uint32(1)
    for i := 0; i < digits; i++ {
        mod *= 10
    }

    return fmt.Sprintf("%0*d", digits, code%mod)
}

Enrollment Flow

TOTP enrollment in the platform follows this sequence: (1) User requests 2FA setup. (2) Server generates a cryptographically random 20-byte secret. (3) Server creates an otpauth:// URI and encodes it as a QR code. (4) User scans the QR code with their authenticator app. (5) User enters the current TOTP code to verify enrollment. (6) Server stores the encrypted secret and generates recovery codes. (7) User saves recovery codes.

// otpauth URI format
otpauth://totp/ExampleApp:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=ExampleApp&algorithm=SHA1&digits=6&period=30

Verification Flow

During login: (1) User enters username and password. (2) Server validates credentials. (3) If 2FA is enabled, server prompts for TOTP code. (4) User enters the 6-digit code from their authenticator. (5) Server computes expected codes for the current time window ± skew. (6) If any computed code matches, authentication succeeds. (7) Server records the used time step to prevent replay attacks within the same window.

Key Concept: Replay Prevention

A TOTP code is valid for the entire 30-second window. Without replay prevention, an attacker who intercepts a code could reuse it within that window. The server must track the last successfully used time step per user and reject codes from the same or earlier step. This is a simple database column update but is critical for security.

Practical Applications

The Platform’s 2FA Implementation

The platform’s authentication flow integrates TOTP as an optional second factor. The implementation stores TOTP state in a user_totp table: the encrypted secret, whether 2FA is active, the last used counter (for replay prevention), and the creation timestamp. Recovery codes are stored in a separate user_recovery_codes table, hashed with bcrypt.

type TOTPConfig struct {
    UserID        string    `db:"user_id"`
    EncryptedKey  []byte    `db:"encrypted_key"`
    Active        bool      `db:"active"`
    LastUsedStep  int64     `db:"last_used_step"`
    CreatedAt     time.Time `db:"created_at"`
}

type RecoveryCode struct {
    UserID   string `db:"user_id"`
    CodeHash string `db:"code_hash"`
    Used     bool   `db:"used"`
}

func (s *AuthService) BeginTOTPSetup(ctx context.Context, userID string) (*TOTPSetupResponse, error) {
    // Generate TOTP key
    key, err := totp.Generate(totp.GenerateOpts{
        Issuer:      "ExampleApp",
        AccountName: userID,
        Period:      30,
        Digits:      otp.DigitsSix,
        Algorithm:   otp.AlgorithmSHA1,
    })
    if err != nil {
        return nil, fmt.Errorf("generating TOTP key: %w", err)
    }

    // Encrypt the secret before storage
    encrypted, err := s.cipher.Encrypt([]byte(key.Secret()))
    if err != nil {
        return nil, fmt.Errorf("encrypting secret: %w", err)
    }

    // Store in pending state (not yet active)
    config := &TOTPConfig{
        UserID:       userID,
        EncryptedKey: encrypted,
        Active:       false,
    }
    if err := s.store.SaveTOTPConfig(ctx, config); err != nil {
        return nil, err
    }

    // Generate QR code image
    var qrBuf bytes.Buffer
    img, err := key.Image(200, 200)
    if err != nil {
        return nil, fmt.Errorf("generating QR image: %w", err)
    }
    png.Encode(&qrBuf, img)

    return &TOTPSetupResponse{
        Secret:     key.Secret(),
        QRCodePNG:  qrBuf.Bytes(),
        OTPAuthURL: key.URL(),
    }, nil
}

Enforcement Policies

The platform supports per-workspace 2FA enforcement. Workspace admins can require all members to enable TOTP before accessing workspace resources. The enforcement check runs in the authentication middleware: if the workspace requires 2FA and the user hasn’t enrolled, they’re redirected to the setup flow. This is separate from the Casbin authorization layer—2FA is an authentication concern, not an authorization concern.

Implementation Patterns

TOTP Validation with Skew

func (s *AuthService) ValidateTOTP(ctx context.Context, userID, code string) error {
    config, err := s.store.GetTOTPConfig(ctx, userID)
    if err != nil {
        return fmt.Errorf("fetching TOTP config: %w", err)
    }
    if !config.Active {
        return errors.New("TOTP not active for user")
    }

    // Decrypt the stored secret
    secret, err := s.cipher.Decrypt(config.EncryptedKey)
    if err != nil {
        return fmt.Errorf("decrypting secret: %w", err)
    }

    // Validate with skew of 1 (checks t-1, t, t+1)
    valid, err := totp.ValidateCustom(code, string(secret), time.Now(), totp.ValidateOpts{
        Period:    30,
        Skew:     1,
        Digits:   otp.DigitsSix,
        Algorithm: otp.AlgorithmSHA1,
    })
    if err != nil {
        return fmt.Errorf("validating TOTP: %w", err)
    }
    if !valid {
        return errors.New("invalid TOTP code")
    }

    // Replay prevention: check and update last used step
    currentStep := time.Now().Unix() / 30
    if currentStep <= config.LastUsedStep {
        return errors.New("TOTP code already used")
    }
    return s.store.UpdateLastUsedStep(ctx, userID, currentStep)
}

Recovery Code Generation and Verification

func GenerateRecoveryCodes(count int) ([]string, error) {
    codes := make([]string, count)
    for i := range codes {
        // Generate 10 random bytes, encode as hex with dashes for readability
        b := make([]byte, 5)
        if _, err := rand.Read(b); err != nil {
            return nil, err
        }
        hex := fmt.Sprintf("%x", b)
        codes[i] = hex[:5] + "-" + hex[5:]
    }
    return codes, nil
}

func (s *AuthService) UseRecoveryCode(ctx context.Context, userID, code string) error {
    storedCodes, err := s.store.GetRecoveryCodes(ctx, userID)
    if err != nil {
        return err
    }

    for _, stored := range storedCodes {
        if stored.Used {
            continue
        }
        if err := bcrypt.CompareHashAndPassword([]byte(stored.CodeHash), []byte(code)); err == nil {
            // Mark code as used
            return s.store.MarkRecoveryCodeUsed(ctx, userID, stored.CodeHash)
        }
    }
    return errors.New("invalid recovery code")
}
Key Concept: Secret Encryption at Rest

TOTP secrets are the “password equivalent” for the second factor. If an attacker dumps the database, plaintext TOTP secrets would let them bypass 2FA entirely. Encrypt secrets with AES-256-GCM using a key stored outside the database (environment variable, KMS, or hardware security module). This means a database breach alone doesn’t compromise the second factor.

Common Pitfalls

Further Reading