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.
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.
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 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).
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.
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.
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)
}
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
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.
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.
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
}
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.
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)
}
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")
}
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.