StackPractices
intermediate By Mathias Paulenko

Two-Factor Authentication (2FA / TOTP)

How to implement time-based one-time password (TOTP) two-factor authentication for secure user login.

Overview

A user gets phished and their password lands in a breach dump. With password-only auth, that account is gone for good. With TOTP enabled, the attacker still needs a code that changes every 30 seconds and lives on a physical device. The stolen password becomes useless on its own. That’s the whole point of two-factor authentication: something you know (password) plus something you’ve got (an authenticator generating time-based one-time passwords per RFC 6238).

This recipe gives you the complete enrollment-and-verify cycle in Python, JavaScript, and Java: generating secrets, building otpauth:// URIs, rendering QR codes, verifying tokens with a drift window, and issuing backup codes. It also covers the parts most examples skip: encrypting secrets at rest, rate-limiting verification, and what to do when a user loses their phone.

One thing TOTP isn’t: a substitute for decent password security. The two factors stack, and a weak link drags both down. A reused or unsalted password plus a strong second factor is still a weak system. Get Password Hashing right first, then add TOTP on top.

When to Use

Reach for this recipe when:

  • Protecting user accounts with a verification step beyond passwords
  • Building login flows for financial, healthcare, or admin applications
  • Migrating from password-only auth to multi-factor authentication (MFA). See Password Hashing for credential storage.
  • Supporting authenticator apps (Google Authenticator, Authy, Microsoft Authenticator)

Skip it for machine-to-machine APIs (use API keys or OAuth2 client credentials instead; see When Not to Use below) and for internal tools where everyone is trusted and network-segmented.

Solution

The enrollment flow has three server-side pieces: a POST /2fa/enroll endpoint that generates a secret and returns the provisioning URI plus QR, a POST /2fa/confirm endpoint that verifies the user’s first code before marking 2FA active (never store the secret as enabled before one code verifies, otherwise users can lock themselves out by scanning wrong), and a POST /2fa/verify step inside your login pipeline that runs after the password check. All three show up in the service below, one per language.

Python

import secrets
import pyotp
import qrcode
import io
import base64

class TOTPService:
    def generate_secret(self) -> str:
        return pyotp.random_base32()

    def get_provisioning_uri(self, secret: str, user_email: str, issuer: str) -> str:
        return pyotp.totp.TOTP(secret).provisioning_uri(
            name=user_email,
            issuer_name=issuer
        )

    def generate_qr_code(self, provisioning_uri: str) -> str:
        """Returns the QR image as a base64 PNG for embedding in <img src='data:...'>."""
        img = qrcode.make(provisioning_uri)
        buffer = io.BytesIO()
        img.save(buffer, format="PNG")
        return base64.b64encode(buffer.getvalue()).decode()

    def verify_token(self, secret: str, token: str, window: int = 1) -> bool:
        totp = pyotp.TOTP(secret)
        return totp.verify(token, valid_window=window)

    def generate_backup_codes(self, count: int = 10) -> list[str]:
        return [secrets.token_hex(4).upper() for _ in range(count)]

# Usage
service = TOTPService()
secret = service.generate_secret()
uri = service.get_provisioning_uri(secret, "user@example.com", "MyApp")
qr_b64 = service.generate_qr_code(uri)
is_valid = service.verify_token(secret, "123456")
backup_codes = service.generate_backup_codes()

JavaScript

import { authenticator } from "otplib";
import QRCode from "qrcode";
import crypto from "crypto";

class TOTPService {
  generateSecret() {
    return authenticator.generateSecret();
  }

  getProvisioningUri(secret, userEmail, issuer) {
    return authenticator.keyuri(userEmail, issuer, secret);
  }

  async generateQRCode(provisioningUri) {
    return QRCode.toDataURL(provisioningUri); // data:image/png;base64,... for <img src>
  }

  verifyToken(secret, token, window = 1) {
    return authenticator.verify({ token, secret, window });
  }

  generateBackupCodes(count = 10) {
    return Array.from({ length: count }, () =>
      crypto.randomBytes(4).toString("hex").toUpperCase()
    );
  }
}

// Usage
const service = new TOTPService();
const secret = service.generateSecret();
const uri = service.getProvisioningUri(secret, "user@example.com", "MyApp");
const qrDataUrl = await service.generateQRCode(uri);
const isValid = service.verifyToken(secret, "123456");
const backupCodes = service.generateBackupCodes();

Java

import com.google.zxing.BarcodeFormat;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import dev.samstevens.totp.code.*;
import dev.samstevens.totp.qr.*;
import dev.samstevens.totp.secret.*;
import dev.samstevens.totp.time.*;
import java.awt.image.BufferedImage;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.*;
import java.util.stream.*;

public class TOTPService {
  private final SecretGenerator secretGenerator = new DefaultSecretGenerator();
  private final TimeProvider timeProvider = new SystemTimeProvider();
  private final CodeGenerator codeGenerator = new DefaultCodeGenerator();
  private final CodeVerifier verifier = new DefaultCodeVerifier(codeGenerator, timeProvider);

  public String generateSecret() {
    return secretGenerator.generate();
  }

  public String getProvisioningUri(String secret, String userEmail, String issuer) {
    // Encode user-controlled values — a raw email with & or = breaks the URI
    String enc = StandardCharsets.UTF_8.toString();
    return "otpauth://totp/" + URLEncoder.encode(issuer + ":" + userEmail, enc) +
           "?secret=" + secret + "&issuer=" + URLEncoder.encode(issuer, enc);
  }

  public BufferedImage generateQRCode(String provisioningUri) throws Exception {
    QRCodeWriter writer = new QRCodeWriter();
    BitMatrix matrix = writer.encode(provisioningUri, BarcodeFormat.QR_CODE, 200, 200);
    return MatrixToImageWriter.toBufferedImage(matrix); // BufferedImage, not byte[]
  }

  public boolean verifyToken(String secret, String token) {
    return verifier.isValidCode(secret, token);
  }

  public List<String> generateBackupCodes(int count) {
    SecureRandom random = new SecureRandom();
    return IntStream.range(0, count)
      .mapToObj(i -> String.format("%08X", random.nextInt()))
      .toList();
  }
}

Explanation

Two flows matter: enrollment happens once, verification happens at every login.

flowchart diagram: subgraph enroll[

TOTP derives a 6-digit code from a shared secret and the current timestamp sliced into 30-second steps (defined in RFC 6238, building on HOTP’s counter mechanism in RFC 4226). Both sides compute the same HMAC-SHA1 — as long as the secrets match and the clocks are close, the codes match too.

QR code provisioning wraps the secret in an otpauth://totp/Issuer:user@example.com?secret=BASE32&issuer=Issuer URI. The authenticator app scans it, stores the secret, and starts generating codes. The secret crosses the wire exactly once, in that QR, so treat the enrollment page as sensitive.

Verification window accepts codes from adjacent time steps to absorb clock drift. window=1 means ±30 seconds, which covers nearly every real phone. Going wider makes brute force marginally easier and hides real NTP problems.

Backup codes are single-use recovery codes. Generate 8-10, show them once, store only their hashes (same treatment as passwords; see Password Hashing), and burn each one on use.

The URI itself deserves a second look. otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp has four parts: the scheme (otpauth://totp/), the label (Issuer:account), the base32 secret, and parameters. The label and issuer are display strings for the app. The secret is the only part that matters cryptographically, which is why leaking the QR means leaking the factor.

Storing the secret: after the confirm step succeeds, persist user_id → encrypted_secret with the encryption key outside the database (KMS, vault, or at minimum an env-injected key). If you must choose between encrypting secrets and hashing them: encrypt. You need the plaintext back at every verify, so reversible encryption is required; it’s not a password.

Choosing the drift window: valid_window=1 accepts codes from the previous, current, and next 30-second step, so three valid codes at any instant instead of one. That triples the brute-force surface per attempt, which is why window stays at 1 and the rate limit does the real work.

Variants

MethodLibrary / StandardBest For
TOTPpyotp, otplib, samstevens/totpMost applications: free, offline, app-based
SMS OTPTwilio, AWS SNSUsers without smartphones (vulnerable to SIM-swap)
WebAuthn / FIDO2py_webauthn, fido2-libPhishing-resistant hardware keys
Push NotificationFirebase, OneSignalFrictionless approval on trusted devices
Email OTPCustom implementationFallback when TOTP is unavailable

TOTP is the default for good reason: it costs nothing, works offline, needs no third-party account, and every major authenticator speaks it. SMS is the weakest mainstream option: SIM-swapping defeats it, and NIST has deprecated it as a second factor. WebAuthn is the strongest but adds enrollment friction and browser/hardware requirements. Most production systems offer TOTP plus backup codes, with WebAuthn as the premium tier.

Two more distinctions worth knowing. TOTP vs HOTP: TOTP is HOTP with the counter replaced by a timestamp; same math, different moving part. HOTP codes stay valid until consumed, which makes them better for event-driven flows (a button-press key fob) and worse for anything typed over a network where the code might sit unused. TOTP vs push: push notifications move the decision out-of-band. The user approves on a trusted device rather than typing a code. Less phishable than TOTP but requires a vendor dependency and a device that trusts the push channel.

What Works — and What Breaks

Do:

  1. Encrypt secrets at rest — AES-256-GCM or a secrets vault, never plaintext. A leaked DB row with a readable secret hands the attacker both factors.
  2. Rate limit verification attempts: 5 tries per 5 minutes, then lock or delay. Six digits is only 1M combinations, so unlimited attempts end in brute force.
  3. Provide backup codes on enrollment: 8-10 single-use codes, shown once, stored hashed.
  4. Allow re-enrollment: users switch phones. Gate re-enrollment behind email confirmation so a stolen session can’t silently rotate the secret.
  5. Log 2FA events: enrollment, verify success/failure, backup code use, disable events. This is the audit trail that spots account-takeover attempts.

Don’t:

  1. Store secrets in plaintext — the most common mistake on this list and the worst one to recover from.
  2. Skip token format validation. Verify it’s 6 digits before calling the verifier; garbage input wastes a rate-limit slot anyway.
  3. Allow unlimited verification attempts; the code is only 1M combinations.
  4. Reuse backup codes. Mark each one consumed the moment it verifies.
  5. Send the secret over insecure channels. The QR enrollment page must be HTTPS and session-bound.
  6. Make SMS the primary factor without warning users about SIM-swapping.
  7. Show TOTP errors that distinguish “user not found” from “wrong code”; that’s account enumeration.
  8. Trust window > 1 casually. Every extra window opens up one more code that’s valid right now.

Production Checklist

  • TOTP secrets encrypted at rest (AES-256-GCM or vault)
  • Verification endpoint rate limited (≤5 attempts / 5 min per account)
  • Backup codes generated at enrollment, stored hashed, single-use
  • Backup code consumption logged and alerted
  • valid_window=1 (or justified wider) documented
  • Server clock synced via NTP; drift monitoring in place
  • Re-enrollment requires email confirmation
  • 2FA disable requires re-authentication or identity verification
  • Enrollment page HTTPS-only, session-bound, secret never logged
  • 2FA events (enroll/verify/backup-use/disable) in the audit log
  • Recovery flow documented and tested (lost device + lost backup codes)

Security Considerations

  • Brute force on 6 digits: 1M combinations sounds like a lot until you allow unlimited tries. Rate-limit verification hard; OWASP’s MFA cheat sheet recommends lockouts plus alerting.
  • Code replay: burn every code on use. Mark the counter/timestamp as consumed after a successful verify, otherwise an intercepted code replays within its 30-90s window.
  • Secret at rest: keep it encrypted, keep it out of logs, keep it out of error messages. The secret is the second factor; a DB dump containing plaintext secrets defeats it entirely.
  • SIM-swapping on SMS fallback: if SMS is a backup method, an attacker who ports the number gets the code. Prefer TOTP backup codes over SMS recovery.
  • Phishing: TOTP codes can still be phished in real time (the attacker relays them instantly). Only WebAuthn is phishing-resistant; that’s the upgrade path.
  • Recovery abuse: the “I lost my phone” flow is the real attack surface. Require identity proofing proportionate to the account’s value. A weak recovery flow bypasses strong 2FA.
  • Enrollment tampering: an attacker with a stolen session who can silently re-enroll a new device owns the account permanently. Re-enrollment must email the user.
  • QR page exposure: the QR contains the secret. Don’t cache it, don’t log it, don’t serve it without authentication.
  • Shared-secret model limits: unlike asymmetric schemes (WebAuthn’s public keys), a stolen server-side secret lets an attacker mint codes offline. That asymmetry is why TOTP deployment checklists obsess over at-rest encryption: the breach of your DB is the breach of every enrolled factor at once.
  • Backup code storage: hashing backup codes like passwords isn’t optional polish. An attacker who reads your users’ plaintext backup codes has a recovery path that bypasses the rate-limited verify endpoint entirely.

Performance and Cost

TOTP verification is nearly free: an HMAC-SHA1 over a 30-second counter costs microseconds, so a single core verifies tens of thousands of codes per second. QR generation (~50ms for a 200×200 PNG) happens once per enrollment. The real cost isn’t compute:

ComponentCostNotes
TOTP libraries (pyotp, otplib)$0Open source, no per-request fee
Secret storage~$0A few hundred bytes per user in your existing DB
Rate-limit counter (Redis)$10-$25/monthOnly if you don’t already run Redis
SMS OTP (Twilio)~$0.05/messageWhy SMS should be a fallback, not the default
Auth0/Okta hosted MFA$35-$800+/monthvs. implementing TOTP yourself for ~$0

For most teams, self-hosted TOTP is a rounding error. The cost decision is whether to pay an identity provider for the whole auth stack, not for 2FA specifically.

Monitoring and Testing

Watch three signals. Failed verify rate: a spike per-user means brute force or a confused user; a spike per-IP means an attack. Backup code usage: legitimate use is rare; a burst suggests compromised codes or recovery abuse. 2FA enrollment rate: below ~30% of active users, your second factor isn’t protecting the population that matters; nudge at login.

Test four scenarios. Enrollment round-trip: generate a secret, build the URI, and verify a real TOTP code derived from that same secret (pyotp and otplib both expose totp.now() for this). Window boundary: a code from the previous 30-second step passes with window=1; one from two steps back must fail. Rate limiting: the 6th rapid attempt gets a 429, not a verdict on the code. Backup code: works once, then the same code replays as a rejection.

Troubleshooting

  • Every code rejected. Almost always clock drift. Check NTP on the server first. If the server is right, the user’s phone clock is off; widen valid_window to 1 (if it isn’t) or guide them to re-sync.
  • QR code won’t scan. The URI is malformed. Check URL-encoding of issuer/email (spaces, @, & break naive string building; the Java example above encodes them).
  • Codes work on one device but not another. The two devices are holding different secrets. The user enrolled twice or scanned an old QR. Re-enroll cleanly and invalidate the previous secret.
  • User locked out entirely. Backup codes first; if those are gone too, the recovery flow decides whether the account is recoverable at all — that flow must verify identity harder than the 2FA it replaces.
  • TOTP verify is slow under load. It’s not the HMAC; it’s the rate-limit counter or DB lookup on the verify path. Cache the secret fetch; keep the attempt counter in Redis.
  • Enrollment works but codes never match. The server stored the secret incorrectly. A common cause is storing the provisioning URI instead of the raw base32 secret, or double-encoding it. Store exactly what generate_secret() returned.

When Not to Use This Approach

  • Machine-to-machine APIs — a service doesn’t have a phone to read a code from. Use API keys, mTLS, or OAuth2 client credentials.
  • Internal tools on trusted networks — when access is already gated by VPN and everyone is trusted, 2FA adds friction without cutting much risk.
  • Read-only public APIs — no user accounts means nothing to protect with a second factor.
  • Prototypes and MVPs — get the auth core right first; bolting 2FA onto an insecure login flow protects nothing.
  • High-frequency session flows — if your users re-authenticate dozens of times per day, TOTP-per-login destroys the experience. Gate the factor on new devices, new IPs, or step-up actions (payments, exports, settings changes) instead of every login.

See Also

Companion code: 2FA companion resources — enroll + verify endpoints, hashed backup codes, and a pytest suite covering the scenarios above.

Frequently Asked Questions

How do I handle clock drift between server and client?

Use valid_window=1 (±30 seconds). It absorbs nearly every real phone's drift. Keep your server on NTP. If drift still exceeds a window, the user's device clock is badly off; ask them to re-sync or fall back to a backup code.

Is TOTP or SMS better for 2FA?

TOTP, no contest. SMS is phishable in transit, defeatable via SIM-swap, costs per message, and NIST deprecated it as a second factor. TOTP costs nothing, works offline, and lives in an app. SMS survives only as a fallback for users without smartphones.

Can I use the same TOTP secret across multiple devices?

Yes — scanning the same QR enrolls the same secret on each device. For security, track each enrolled device in the user account and make them individually revocable; losing one phone shouldn't require re-enrolling all of them.

What happens if a user loses their authenticator device?

Backup codes, generated at enrollment. If those are lost too, the recovery flow must verify identity more rigorously than the 2FA it replaces; Magic Links plus a delay and notification is the common pattern. A weak recovery flow is the most common real-world 2FA bypass.

Is TOTP phishing-resistant?

No. A phishing site running in real time can relay the code the moment you type it. TOTP stops credential-stuffing and passive leaks, not active phishing. If phishing resistance is the requirement, that's WebAuthn/FIDO2 territory: hardware-bound keys that can't be replayed on another origin.

Should TOTP secrets be rotated?

Not routinely. A secret stays safe because of where you keep it, not how long it's been there. Rotate on enrollment events (new device), suspected compromise, or when migrating the secret store. Routine rotation trains users to expect enrollment prompts, which attackers exploit.