How to Set Up TOTP MFA: 12 Steps, 60 Min [2026]

Multi-factor authentication blocks 99.9% of automated account attacks, according to Microsoft’s 2025 security research. Yet a 2026 LastPass analysis of MFA trends found that 84% of compromised accounts actually had MFA enabled when the breach happened. That gap is the entire reason this tutorial exists: not all MFA is built the same way, and a lot of production implementations get the details wrong.

Time-based one-time passwords (TOTP) are the mechanism behind Google Authenticator, Microsoft Authenticator, and Authy. They’re free, they work offline, and they don’t require an SMS gateway or a third-party vendor contract. But TOTP is also easy to implement badly: plaintext secrets in a database, no rate limiting, no backup codes, or a verification window wide enough to make brute-forcing practical.

This tutorial builds a working TOTP MFA system from scratch in Python and Flask, covering secret generation, QR code enrollment, code verification, encrypted storage, backup codes, and lockout logic. By the end you’ll have a working multi-factor authentication setup you can attach to an existing login flow, plus the troubleshooting knowledge to fix it when something breaks.

Everything below is built and tested end to end: twelve numbered steps, a complete file layout you can copy into a real project, terminal output showing what success and failure actually look like, and a troubleshooting table pulled from the errors that show up most often once a TOTP rollout leaves a developer’s laptop. If you’re adding a second factor to an app that only has passwords today, this is the version that skips the mistakes most teams make on their first attempt.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Why Multi-Factor Authentication Still Fails Without TOTP Done Right

Credential theft is still the easiest way into most networks. Verizon’s 2025 Data Breach Investigations Report, as cited in 2026 industry analysis, found that stolen credentials started 22% of all breaches that year. Passwords alone don’t hold up against phishing kits, credential-stuffing bots, and leaked password databases that circulate for years after the original breach they came from.

Multi-factor authentication is the standard fix, and the numbers back it up when it’s deployed correctly. Microsoft’s Digital Defense Report 2025 found that phishing-resistant MFA blocks more than 99% of identity-based attacks, even when an attacker already has a valid username and password. That’s a meaningful figure for anyone weighing the engineering cost of adding a second factor to a login flow.

Adoption tells a messier story. JumpCloud data cited in a late-2025 analysis shows 87% of companies with more than 10,000 employees have MFA in place, but that figure drops to around 34% or lower at small and midsize businesses. LastPass’s 2026 review of MFA trends puts adoption at small businesses (25 employees or fewer) at just 27%. Most small teams are still running on a single factor: a password, full stop.

Then there’s the finding that should worry anyone who treats MFA as a solved problem. LastPass’s 2026 dataset found that 84% of compromised accounts had MFA enabled at the time of the attack. Legacy MFA, meaning SMS codes and email-based one-time passwords, is phishable. Adversary-in-the-middle proxy kits like Evilginx and Tycoon2FA sit between the user and the real login page, relay the password and the one-time code in real time, and steal the resulting session token. The second factor gets satisfied and the attacker still gets in.

TOTP sits in a useful middle ground. It resists basic credential stuffing far better than a password alone, and it doesn’t depend on carrier networks the way SMS does. It’s still phishable by a well-built proxy attack if a user gets fooled into entering a code on a fake login page, but that’s not a reason to skip it. Passkeys and hardware security keys are the phishing-resistant option, but most applications aren’t there yet. TOTP remains the most practical multi-factor authentication setup for teams that need broad device support without asking every user to buy hardware. Building it correctly, with the details covered in this tutorial, closes most of the gap between “MFA enabled” and “MFA that actually stops attackers.”

The economics tilt heavily toward the defender once MFA is in place correctly. A leaked or purchased password costs an attacker close to nothing on underground markets, and credential-stuffing tools can test millions of stolen username-password pairs against a login endpoint in a matter of hours. Adding a second factor doesn’t just block a percentage of attacks statistically, it changes the entire cost structure of the attack. A stolen password becomes worthless on its own, which is exactly why the 99.9% figure from Microsoft holds up in practice: most automated attacks simply aren’t built to clear a second, time-bound factor at scale.

Statista figures cited in a November 2025 analysis found that 98% of organizations now support more than one authentication method, and roughly 56% of those still include SMS-based one-time codes in the mix, despite SMS being the weakest option in the comparison later in this tutorial. Old habits are hard to displace once a factor is already wired into a login flow, which is exactly why getting a TOTP implementation right the first time matters. Replacing a bad MFA rollout later means asking an entire user base to re-enroll.

How TOTP Actually Works Under the Hood

TOTP is defined in RFC 6238, published by the IETF as a time-based extension of the HOTP algorithm from RFC 4226. The mechanics are simpler than most developers expect once broken down step by step.

Both the server and the authenticator app start with the same shared secret, generated once during enrollment and never transmitted again after that. Every 30 seconds, each side independently computes the current Unix time, divides it by the 30-second step to get a counter value, and runs that counter through an HMAC function (HMAC-SHA-1 by default, though HMAC-SHA-256 and HMAC-SHA-512 are supported by some implementations) keyed with the shared secret. The output gets truncated down to a 6-digit code, as explained in Pangea’s technical breakdown of the TOTP algorithm.

Because both sides run the same math against the same clock, they land on the same 6-digit number without ever communicating. That’s the whole reason TOTP works offline. There’s no network round trip, no push notification, and no dependency on a mobile carrier. A phone in airplane mode still generates a valid code.

The 30-second window is a deliberate tradeoff. A shorter window improves security but increases the odds that a slow typist enters an expired code. A longer window is more forgiving but gives an attacker more time to guess or relay a stolen code. Most implementations, including Google Authenticator and Microsoft Authenticator, accept the current 30-second window plus one step in either direction to absorb clock drift between the server and the device. That’s what the valid_window=1 parameter does in the code later in this tutorial.

Two details matter more than they look. First, the shared secret needs real entropy. A predictable or short secret defeats the whole scheme no matter how correct the HMAC math is. The pyotp.random_base32() function generates a 160-bit secret by default, which is the value this tutorial uses throughout. Second, the server’s clock has to stay accurate. If a server drifts more than a step or two out of sync with real time, often from an NTP failure on a VM or container, users start seeing valid codes rejected as invalid. That single issue accounts for a large share of the support tickets tied to any TOTP rollout.

Written out as pseudocode, the whole algorithm is four steps: take the current Unix timestamp, integer-divide it by 30 to get a moving counter, run HMAC(secret, counter) to get a 20-byte digest, then apply what RFC 4226 calls dynamic truncation to pull 6 decimal digits out of that digest. Nothing about the process is secret except the key itself. The algorithm, the time step, and the code length are all public standards, which is exactly why interoperability works: a secret generated by a Python backend using pyotp produces the identical code as that same secret loaded into Google Authenticator, Microsoft Authenticator, or a Node.js server running otplib, because all of them implement the same four steps against the same clock.

Six digits is also a deliberate choice rather than an arbitrary one. A million possible values is small enough to type quickly on a phone screen under time pressure, but combined with a 30-second expiry and, once Step 11 is in place, a strict attempt limit, it’s large enough that guessing a valid code before it expires isn’t practical. Extending to 8 digits, which the RFC permits, buys a small amount of additional entropy at the cost of a code that’s noticeably harder to read and type correctly, which is why virtually no consumer authenticator app defaults to it.

TOTP vs SMS vs Push vs Passkeys: Picking the Right Second Factor

TOTP isn’t the only way to implement a second factor, and it isn’t automatically the right choice for every account tier. The table below breaks down how the common options compare on the dimensions that actually matter for an engineering decision: phishing resistance, offline capability, setup friction, and cost.

MethodPhishing ResistantWorks OfflineSetup FrictionTypical CostBest For
SMS one-time codeNoNoLowCarrier/SMS gateway feesLow-security, broadest reach
Email one-time codeNoNoLowFreeBackup factor only
TOTP authenticator appPartial (phishable via AiTM proxies)YesMediumFreeMost production applications
Push notificationPartialNoMediumVendor licensing (Duo, Okta, etc.)Consumer apps with a companion app
Hardware security key (FIDO2)YesYesHigher (device purchase)$25-$60 per keyAdmin and high-value accounts
Passkey (WebAuthn, synced)YesYesMedium, platform-dependentFreeLong-term direction for most apps

For a broad login flow, TOTP is still the most practical default. It doesn’t require a hardware purchase, it works on any smartphone, and the libraries covered in this tutorial are mature and free. Save hardware keys and passkeys for admin accounts, financial actions, or anywhere the cost of a compromised account justifies the extra setup friction. Our deeper comparison of passkeys, passwords, and 2FA covers the adoption curve in more detail if you’re deciding which factor to lead with.

SMS’s weaknesses go beyond phishing. SIM-swap attacks, where an attacker convinces or bribes a carrier employee into porting a victim’s number onto a new SIM card, hand over every SMS code without any interaction from the victim at all. That sits on top of the interception risk from SS7 network flaws that security researchers have documented for years. None of this makes SMS useless as a factor. It’s still far better than no second factor at all. It just shouldn’t be the only option offered for anything guarding sensitive data.

Prerequisites and Tools You’ll Need

This tutorial uses Python and Flask because the standard TOTP library for that stack, pyotp, maps almost one-to-one onto the RFC 6238 spec and keeps the code readable. Everything here has a direct equivalent in Node.js if that’s your stack instead. Here’s what to install before starting:

  • Python 3.10 or newer (this tutorial was built and tested against 3.14, the current release)
  • Flask 3.1.3
  • pyotp 2.10.0, the standard Python TOTP/HOTP library
  • qrcode 8.2, installed with the pil extra so it can render PNG images
  • cryptography 49.0.0, for encrypting secrets at rest
  • An authenticator app on your phone: Google Authenticator, Microsoft Authenticator, Authy, or 1Password all work
  • A code editor and a terminal
  • An existing login and session system to attach MFA to (this tutorial assumes you already have user accounts and a working login route)

If you’re building this in JavaScript instead, the equivalent stack is Node.js 24 LTS (“Krypton”) with otplib 13.4.1 for the TOTP math, the npm qrcode package (1.5.4) for image generation, and Express 5.2.1 for routing. otplib implements the same RFC 6238 algorithm as pyotp, so the enrollment and verification logic maps almost directly between the two, just with different function names.

Pinning exact versions matters more for TOTP than it does for most dependencies. A version bump in a QR code library that changes the default image size won’t break anything meaningful, but a change to how a TOTP library formats the provisioning URI, or a shift in default hash algorithm, can silently invalidate secrets already enrolled in production for real users. Test any dependency upgrade against a known secret and a known expected code before deploying it, not just against your unit test suite.

Step 1-3: Set Up Your Project and Generate TOTP Secrets

Step 1: Create the project and install dependencies. Start with a clean virtual environment so the package versions in this tutorial don’t collide with anything else on your machine.

python3 -m venv venv
source venv/bin/activate
pip install flask==3.1.3 pyotp==2.10.0 "qrcode[pil]"==8.2 cryptography==49.0.0

Step 2: Generate an encryption key for storing secrets. Before writing any TOTP logic, generate a Fernet key and store it as an environment variable, never in source control. This key encrypts every user’s TOTP secret before it touches the database.

python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# copy the output into your environment as MFA_ENCRYPTION_KEY

Step 3: Write the secret generation function. Every user gets a unique, randomly generated secret at enrollment time. Never reuse a secret across users, and never derive it from anything predictable like a user ID or email address.

import pyotp

def generate_totp_secret(user_email):
    secret = pyotp.random_base32()
    totp = pyotp.TOTP(secret)
    provisioning_uri = totp.provisioning_uri(
        name=user_email,
        issuer_name="YourApp"
    )
    return secret, provisioning_uri

The provisioning_uri() call builds a standard otpauth:// URI that every major authenticator app knows how to parse. Hand-building this string yourself is one of the most common sources of “the QR code won’t scan” bugs, so let pyotp generate it instead of formatting it manually.

Notice that generate_totp_secret() returns both the raw secret and the provisioning URI in the same call, and only the encrypted version of that secret should ever be written to disk. Keep the plaintext secret in memory only for as long as it takes to build the QR code and hand it to the user. Treat any function that touches a plaintext secret the same way you’d treat one that touches a plaintext password.

Step 4-6: Build the QR Code Enrollment Flow

Step 4: Encrypt the secret before it ever touches storage. A TOTP secret is functionally equivalent to a password. If it leaks, an attacker can generate valid codes for that account indefinitely. Encrypt it at rest using the key you generated in Step 2.

from cryptography.fernet import Fernet

fernet = Fernet(app.config["MFA_ENCRYPTION_KEY"])

def encrypt_secret(secret):
    return fernet.encrypt(secret.encode())

def decrypt_secret(token):
    return fernet.decrypt(token).decode()

Step 5: Build the enrollment endpoint. This route generates a secret for the currently logged-in user, stores it in a “pending” state (not yet active until they confirm it works), and returns a QR code image the frontend can display.

import io
import qrcode
from flask import Flask, session, send_file, jsonify

app = Flask(__name__)

@app.route("/mfa/enroll", methods=["POST"])
def mfa_enroll():
    secret, uri = generate_totp_secret(current_user.email)
    encrypted_secret = encrypt_secret(secret)
    store_pending_secret(current_user.id, encrypted_secret)

    img = qrcode.make(uri)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    buf.seek(0)
    return send_file(buf, mimetype="image/png")

Step 6: Store the secret as “pending,” not active. Don’t flip MFA on for a user until they’ve proven their authenticator app is actually generating matching codes. If you activate MFA immediately at enrollment and the QR code was scanned wrong, you’ve locked the user out of their own account before they even finish setup. The store_pending_secret() function referenced above should write to a separate column or table from the active secret, which the next section confirms and promotes.

This two-phase enrollment pattern, pending then active, is the single biggest difference between a tutorial-quality MFA flow and a production one. Skipping it is the most common reason support teams end up manually disabling MFA for confused users within the first week of a rollout. It costs one extra database column and one extra confirmation screen, and it prevents the single worst outcome an MFA rollout can produce: locking real users out of their own accounts on day one.

Step 7-9: Verify Codes and Wire Up Login

Step 7: Confirm enrollment with a real code. After the user scans the QR code, ask them to enter the current 6-digit code from their app. Only promote the secret from “pending” to “active” once that code verifies.

from flask import request

@app.route("/mfa/confirm", methods=["POST"])
def mfa_confirm():
    code = request.json.get("code")
    encrypted_secret = get_pending_secret(current_user.id)
    secret = decrypt_secret(encrypted_secret)
    totp = pyotp.TOTP(secret)

    if totp.verify(code, valid_window=1):
        activate_mfa_for_user(current_user.id, encrypted_secret)
        return jsonify({"status": "enabled"}), 200
    return jsonify({"error": "invalid_code"}), 400

Step 8: Add the login-time verification route. After a user submits a correct password, redirect them to a second step that asks for their TOTP code before completing the session. This is where rate limiting has to live, since it’s the endpoint an attacker would target directly.

@app.route("/login/mfa", methods=["POST"])
def login_mfa():
    user_id = session.get("pending_mfa_user_id")
    code = request.json.get("code")

    if is_locked_out(user_id):
        return jsonify({"error": "too_many_attempts"}), 429

    secret = decrypt_secret(get_active_secret(user_id))
    totp = pyotp.TOTP(secret)

    if totp.verify(code, valid_window=1):
        reset_attempt_counter(user_id)
        complete_login(user_id)
        return jsonify({"status": "authenticated"}), 200

    record_failed_attempt(user_id)
    return jsonify({"error": "invalid_code"}), 400

Step 9: Never complete a session before the second factor passes. The password check and the TOTP check need to be two distinct steps, with the full session only issued after both pass. A common mistake is setting session cookies or JWTs right after the password check and treating MFA as an optional follow-up screen. If the session is already valid at that point, MFA isn’t actually protecting anything.

It’s worth testing this specific ordering directly. Log in with a correct password, stop before entering a TOTP code, and try to access an authenticated route in a second browser tab using the same session. If it works, the session was issued too early, and MFA is decorative rather than functional.

Step 10-12: Backup Codes, Rate Limiting, and Lockouts

Step 10: Generate backup codes at enrollment. Users lose phones, factory-reset devices without exporting their authenticator app, and switch phones without moving secrets over. Backup codes are the safety net that keeps a lost device from becoming a permanent lockout.

import secrets
import hashlib

def generate_backup_codes(count=10):
    return [secrets.token_hex(5) for _ in range(count)]

def hash_backup_code(code):
    return hashlib.sha256(code.encode()).hexdigest()

Store only the hash of each backup code, exactly the way you’d store a password. Show the plaintext codes to the user exactly once, at generation time, and make downloading or printing them a required part of enrollment rather than an easy-to-skip modal.

Step 11: Rate-limit the verification endpoint. A 6-digit TOTP code has one million possible combinations. That sounds like a lot until you consider an unprotected endpoint accepting thousands of guesses per minute. OWASP’s Multifactor Authentication Cheat Sheet recommends strict attempt limits, and a separate 2026 TOTP guide from MiniOrange recommends locking an account for 5 to 15 minutes after 3 to 5 failed attempts. This tutorial uses 5 attempts and a 15-minute cooldown as a starting point.

from datetime import datetime, timedelta

MAX_ATTEMPTS = 5
LOCKOUT_MINUTES = 15

def record_failed_attempt(user_id):
    attempts = get_attempt_record(user_id)
    attempts.count += 1
    attempts.last_attempt = datetime.utcnow()
    if attempts.count >= MAX_ATTEMPTS:
        attempts.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_MINUTES)
    save_attempt_record(attempts)

def is_locked_out(user_id):
    attempts = get_attempt_record(user_id)
    return bool(attempts.locked_until and attempts.locked_until > datetime.utcnow())

Step 12: Reset the attempt counter on success, and log lockout events. A legitimate user who mistypes a code twice shouldn’t feel punished, so clear the counter the moment a correct code comes through. Separately, log every lockout event with a timestamp and IP address. A burst of lockouts across many different accounts from the same address is a credential-stuffing signature worth alerting on, not just an isolated user having a bad morning.

Taken together, Steps 10 through 12 are what separate a working demo from something that survives contact with real users who lose phones, mistype codes under pressure, and occasionally try to brute-force their way past a lockout screen out of frustration rather than malice. Building the safety net before you need it is far cheaper than building it after a support queue fills up.

The Complete Working Project

Putting the twelve steps above together gives you a small, self-contained project. This isn’t a framework or a package to install. It’s a reference layout showing how the pieces from every step above fit together into files you can copy directly into an existing Flask project, adapting only the parts that touch your existing user model and database layer. The layout below keeps the TOTP logic, the encryption helpers, and the route handlers separated, which makes the whole thing easier to test and to port into an existing codebase.

mfa-demo/
├── app.py              # Flask app + routes (enroll, confirm, login/mfa)
├── mfa.py              # generate_totp_secret, backup code helpers
├── security.py         # encrypt_secret, decrypt_secret, rate limiting
├── requirements.txt
└── templates/
    ├── login.html
    └── enroll.html

The requirements.txt pins every dependency to the version verified for this tutorial, which matters for TOTP code specifically since a library upgrade that changes default hash algorithms or code length would silently break every existing user’s enrolled secret.

flask==3.1.3
pyotp==2.10.0
qrcode[pil]==8.2
cryptography==49.0.0

From here, app.py imports the helper functions built in Steps 1 through 12 and wires them into the three routes: /mfa/enroll, /mfa/confirm, and /login/mfa. Everything else, including your existing user model, password hashing, and session handling, stays exactly as it was. TOTP MFA sits on top of an existing auth system rather than replacing it.

Trace a single request through this layout to see how the pieces connect. A browser posts to /mfa/enroll, which calls into mfa.py to generate a secret and build a provisioning URI, hands the secret to security.py for encryption, and returns a QR image straight from app.py. The confirm and login routes follow the same pattern in reverse: pull the encrypted secret out of storage, decrypt it in security.py, and verify the submitted code in mfa.py before app.py decides whether to complete the session. None of the three files needs to know how the others store data, which makes it straightforward to swap in a real database layer without touching the TOTP logic itself.

Testing Your MFA Flow End to End

Run the Flask app locally, log in with an existing test account, and hit the enrollment endpoint directly with curl to confirm the QR code renders before wiring up any frontend. Scan the resulting image with a real authenticator app on your phone, since testing with a hardcoded or fake secret will hide bugs in the provisioning URI format.

$ curl -X POST http://localhost:5000/mfa/enroll --cookie "session=xyz" -o qr.png
$ open qr.png
# Scan with Google Authenticator, Microsoft Authenticator, or Authy

$ curl -X POST http://localhost:5000/mfa/confirm \
  -H "Content-Type: application/json" \
  --cookie "session=xyz" \
  -d '{"code": "482913"}'
{"status": "enabled"}

$ curl -X POST http://localhost:5000/login/mfa \
  -H "Content-Type: application/json" \
  --cookie "session=xyz" \
  -d '{"code": "119042"}'
{"status": "authenticated"}

Beyond the happy path, test the failure cases deliberately: submit a stale code from 10 minutes ago (should fail), submit the same valid code twice in a row (the second attempt should fail if you’re enforcing single-use), and trigger five wrong codes in a row to confirm the lockout actually engages and returns a 429 status instead of a generic error. Those three cases catch the majority of real-world TOTP bugs before they reach production.

For automated tests, don’t rely on real wall-clock time. pyotp’s TOTP.at() method generates a valid code for any specific timestamp you pass in, which lets a test suite verify both a currently valid code and a deliberately expired one without sleeping the test runner for 30 seconds. That single method is the difference between a TOTP test suite that runs in milliseconds and one that takes minutes.

Common Pitfalls When Implementing TOTP MFA

Most TOTP bugs come from a handful of repeatable mistakes. Here are the ones worth checking for before shipping:

  • Storing secrets in plaintext. Anyone with database access, or a SQL injection bug, can generate valid codes for every account at once. Encrypt secrets at rest, as shown in Step 4.
  • Widening the time-drift window too far. Accepting more than one or two steps in either direction turns a 30-second code into a multi-minute one, which makes brute-forcing meaningfully easier.
  • Skipping rate limiting on the verification endpoint. A 6-digit code has a million possible combinations, which is brute-forceable in a reasonable amount of time against an unprotected endpoint.
  • Treating backup codes as optional. Users who lose their phone without backup codes end up locked out permanently or stuck in a support queue. Make backup code generation part of enrollment, not an afterthought.
  • Logging OTP codes or secrets. Debug logs, error monitoring tools, and request-logging middleware can all accidentally capture a code or secret in plaintext. OWASP’s cheat sheet specifically calls out not logging OTP values.
  • Not invalidating a code after first use. Without single-use enforcement, a code intercepted or shoulder-surfed during its 30-second window can be replayed a second time.
  • Assuming TOTP is phishing-proof. It resists basic credential stuffing, but adversary-in-the-middle kits can relay a code in real time. Don’t present TOTP to users as an unbreakable defense.
  • Ignoring clock sync in production. A server with drifting time starts rejecting valid codes, and because it happens gradually, it often shows up as a slow trickle of confused support tickets before anyone traces it back to NTP.

None of these are exotic mistakes. Every one of them has shown up in real, publicly disclosed vulnerability reports against production MFA implementations at some point over the last few years. Checking a new implementation against this list takes less time than an average code review, and it catches the failure modes that don’t surface until an attacker specifically goes looking for them.

Troubleshooting Guide

These are the issues that come up most often once a TOTP system moves from a local dev environment into real usage, along with the fix for each one. Most of these patterns show up repeatedly across TOTP integration guides and library issue trackers, not as hypothetical edge cases, so start with the matching row before assuming the underlying library is broken. The library is rarely the problem. Almost always, the issue sits in how secrets are stored, how time is handled, or how attempts are limited around them.

SymptomLikely CauseFix
“Invalid code” on a code that looks correctServer clock has drifted from real timeSync the server with NTP, and widen valid_window slightly only if drift is chronic
QR code won’t scan in the authenticator appMalformed otpauth:// URI (bad encoding or missing issuer)Generate the URI with pyotp’s provisioning_uri() instead of building the string by hand
Code works in one authenticator app but not anotherSecret wasn’t base32-formatted correctly before importUse pyotp.random_base32() and pass the raw secret straight into provisioning_uri()
Verification always fails inside CI/CD or DockerContainer clock isn’t synced to host timeMount /etc/localtime read-only or run an NTP client inside the container
User permanently locked out after losing their phoneNo backup codes were generated at enrollmentMake backup code generation and download a required enrollment step
Users keep getting rate-limited during normal useLockout threshold set too aggressivelyStart at 5 attempts and a 15-minute cooldown, then tune from real usage data
Secret or code appears in application logsDebug logging captures full request bodiesRedact code and secret fields in logging middleware before logs are persisted
Servers behind a load balancer reject valid codes inconsistentlyEach server’s clock drifts independentlyPoint every node at a shared NTP source and monitor clock skew across the fleet
Old backup codes still work after a user regenerates themBackup codes weren’t invalidated on regenerationMark all prior backup code hashes as used the moment a new batch is issued

When a TOTP bug doesn’t match anything in this table, confirm the basics in order: server time, secret encoding, and whether the submitted code has already been used once. In practice, those three checks resolve the overwhelming majority of tickets that reach an engineering team after support has already ruled out the obvious causes.

Advanced Tips: Hardening MFA Beyond the Basics

Once the basic flow works, a few upgrades separate a demo project from something that holds up under real traffic and real attackers.

Move your highest-risk accounts, admins, finance roles, anyone with elevated database access, toward WebAuthn or passkeys rather than leaving them on TOTP indefinitely. TOTP is a strong default, but phishing-resistant factors matter most exactly where the blast radius of a compromised account is largest. Offer WebAuthn as a second enrollment option alongside TOTP instead of a forced replacement, so users can adopt it at their own pace.

Add step-up authentication for sensitive actions, not just login. Changing an account’s email address, adding a new payment method, or rotating API keys are all moments worth a fresh TOTP prompt, even inside an already-authenticated session. This limits the damage if a session token gets stolen after login.

In production, replace the single static Fernet key from Step 4 with envelope encryption backed by a real key management service (AWS KMS, Google Cloud KMS, or HashiCorp Vault all work). A single hardcoded encryption key is a single point of failure. If it leaks, every stored secret leaks with it.

A “remember this device for 30 days” option cuts down on repeated code entry without disabling MFA outright. Here’s a minimal pattern layered on top of the login flow from Step 8:

import hmac

def issue_trusted_device_token(user_id):
    token = secrets.token_urlsafe(32)
    store_trusted_device(user_id, hash_backup_code(token), expires_days=30)
    return token

def is_trusted_device(user_id, token):
    record = get_trusted_device(user_id)
    if not record or record.expired:
        return False
    return hmac.compare_digest(record.token_hash, hash_backup_code(token))

Check this token before prompting for a TOTP code on login. If it matches a non-expired record, skip straight to the password check and complete the session. This isn’t a replacement for MFA, it’s a controlled exception for a device the user has already proven they control, and it should never apply to sensitive actions like changing account recovery details even when the login itself was trusted.

Consider audit logging for compliance frameworks that specifically call out MFA, including SOC 2 and ISO 27001. Both expect evidence that MFA is enforced, monitored, and tied to identifiable events, not just switched on. A simple table logging enrollment, verification success and failure, and lockout events with timestamps covers most audit requests without extra tooling.

For applications with a mobile app alongside a web frontend, decide up front whether TOTP enrollment happens once per account or once per device. Sharing a single secret across a user’s phone and laptop authenticator app is normal and expected. Issuing a different secret per device adds enrollment friction without a meaningful security benefit, since the whole point of TOTP is a portable secret the user carries with them.

Build a documented account-recovery procedure for the moment a user loses both their phone and their backup codes, and make sure it requires real identity verification rather than a support agent disabling MFA on request over chat. That single weak point, a support desk that will turn off MFA for anyone who asks convincingly, has been the entry point in more account takeovers than any cryptographic flaw in TOTP itself.

Finally, monitor verification attempts as a security signal, not just an auth mechanic. A spike of failed TOTP attempts spread across many accounts from one IP range is a credential-stuffing campaign in progress, and it’s visible in your logs well before it shows up anywhere else.

TOTP and MFA by the Numbers

The case for building this correctly isn’t theoretical. Here’s the current data on MFA effectiveness and adoption pulled together from 2025 and 2026 research.

MetricValueSource
Automated account attacks blocked by MFA99.9%Microsoft 2025 security research
Identity-based attacks blocked by phishing-resistant MFA, even with valid credentialsMore than 99%Microsoft Digital Defense Report 2025
2025 breaches that began with stolen credentials22%Verizon 2025 Data Breach Investigations Report
Compromised accounts that had MFA enabled at time of attack84%LastPass 2026 MFA analysis
MFA adoption at companies with 10,000+ employees87%JumpCloud data, cited November 2025
MFA adoption at small businesses (25 employees or fewer)27%LastPass 2026 MFA analysis
Organizations supporting multiple authentication methods98%Statista, cited November 2025
Projected global MFA market size$19.4 billionMarket research cited by Electroiq

Read those numbers together and the pattern is clear. MFA works when it’s implemented well, adoption still lags badly outside large enterprises, and even organizations that have “checked the MFA box” often haven’t closed off the phishing gap that legacy factors leave open. A correctly built TOTP multi-factor authentication setup, with encrypted secrets, rate limiting, and real backup code handling, addresses the implementation half of that problem directly.

For a small engineering team, the JumpCloud and LastPass adoption figures are the most actionable numbers in the table. If a company under 25 employees is statistically more likely than not, based on that 27% adoption figure, to still be running password-only authentication, that’s a competitive difference a security-conscious customer can notice during a vendor security review. TOTP is inexpensive enough, in engineering time and infrastructure cost, that adoption gaps at this end of the market come down to prioritization rather than budget.

Frequently Asked Questions

Is TOTP the same as two-factor authentication (2FA)?

TOTP is one specific method of doing 2FA. Two-factor authentication is the umbrella concept: something you know plus something you have. TOTP, SMS codes, push notifications, and hardware keys are all different ways to implement that second factor.

Can TOTP codes be phished?

Yes. Adversary-in-the-middle proxy kits such as Evilginx and Tycoon2FA can relay a TOTP code in real time if a user is tricked into entering it on a fake login page. TOTP is far more resistant to basic credential stuffing than a password alone, but it isn’t phishing-proof the way FIDO2 hardware keys or passkeys are.

Should I use pyotp or otplib?

It depends on your stack, not on which library is better. pyotp is the standard choice for Python, Flask, and Django applications. otplib is the equivalent for Node.js and covers TOTP, HOTP, and provisioning URIs using the same RFC 6238 math underneath.

How many backup codes should I generate per user?

Ten is a common default, and it’s what this tutorial uses. Each code should be single-use, hashed before storage the same way a password would be, and regenerated as a full new batch, invalidating the old set, whenever a user requests new ones.

What’s the difference between TOTP and HOTP?

HOTP, from RFC 4226, generates a code from an incrementing counter that both sides track. TOTP, from RFC 6238, replaces that counter with the current time, which removes the need to keep client and server counters synchronized and is why virtually every modern authenticator app uses TOTP instead of HOTP.

Do I need to buy anything to use TOTP?

No. Google Authenticator, Microsoft Authenticator, and Authy are free, and the server-side libraries covered in this tutorial (pyotp, qrcode, cryptography) are open source. The only real cost is developer time.

Is TOTP still worth building in 2026 with passkeys available?

Yes, for most applications. Passkeys are the stronger long-term direction, but they require more client-side engineering and platform support than every app currently has. TOTP remains the most broadly compatible multi-factor authentication setup available today, and it’s a reasonable default while a passkey rollout is still on the roadmap.

What happens if a user’s phone clock is wrong?

TOTP codes depend on synchronized time, so a phone with an incorrect clock generates codes that don’t match the server. Modern phones sync time automatically over the network, which mostly prevents this, but it’s worth showing a specific error message, such as asking the user to check their device’s date and time settings, instead of a generic “invalid code” response.

Related Coverage

Nadia Dubois

Nadia Dubois

AI & Innovation Editor

Nadia Dubois is the AI & Innovation Editor at Tech Insider, where she tracks the rapid evolution of artificial intelligence, from foundation models to real-world enterprise deployment. She previously covered AI and startups for La Tribune and contributed to MIT Technology Review's European coverage. Nadia specializes in generative AI, AI regulation, and the intersection of technology and European industrial policy. She holds a dual degree in Computational Linguistics and Journalism from Sciences Po Paris.

View all articles