Skip to content
Security Beginner Tutorial

Add TOTP-Based Two-Factor Authentication to Your Web App

Build a working Flask app that generates a QR-code enrollment flow and enforces rotating 6-digit codes on login, using pyotp for the TOTP math.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Jul 15, 2026 · 9 min read
Add TOTP-Based Two-Factor Authentication to Your Web App

What you'll build

A working enrollment and login flow where users scan a QR code with Google Authenticator (or Authy, 1Password, etc.), confirm a 6-digit code to activate 2FA, then need that rotating code on every future login.

Prerequisites

  • Python 3.9 or later (python3 --version)
  • pip and a virtual environment tool (venv is built in)
  • Basic familiarity with Flask routes and forms
  • An authenticator app on your phone: Google Authenticator, Authy, or 1Password all work fine, they all speak the same TOTP standard (RFC 6238)

This tutorial uses an in-memory Python dict instead of a real database to keep the focus on the 2FA mechanics. Swap it for your actual user table before shipping anything.

Set up the project

mkdir totp-demo && cd totp-demo
python3 -m venv venv
source venv/bin/activate   # Windows: venv\Scripts\activate
pip install flask pyotp "qrcode[pil]"

pyotp handles the TOTP math and secret generation. qrcode with the [pil] extra renders the enrollment QR code as an actual image instead of just text.

Build the app

Create app.py:

import io
import base64
import pyotp
import qrcode
from flask import Flask, request, session, redirect, url_for
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)
app.secret_key = "change-me-to-a-random-secret"  # load from an env var in real deployments, never commit it

# In-memory store for demo purposes only. Use a real database + encrypted
# column for totp_secret in production.
users = {}

@app.route("/register", methods=["GET", "POST"])
def register():
    if request.method == "POST":
        username = request.form["username"]
        if username in users:
            return "That username is already taken.", 400
        users[username] = {
            "password_hash": generate_password_hash(request.form["password"]),
            "totp_secret": None,
            "totp_confirmed": False,
        }
        session["pending_user"] = username
        return redirect(url_for("enroll_2fa"))
    return """
    <form method="post">
      Username: <input name="username"><br>
      Password: <input name="password" type="password"><br>
      <button type="submit">Register</button>
    </form>
    """

@app.route("/enroll-2fa")
def enroll_2fa():
    username = session.get("pending_user")
    if not username or username not in users:
        return redirect(url_for("register"))

    user = users[username]
    if not user["totp_secret"]:
        user["totp_secret"] = pyotp.random_base32()

    totp = pyotp.TOTP(user["totp_secret"])
    uri = totp.provisioning_uri(name=username, issuer_name="SourceFeedDemo")

    img = qrcode.make(uri)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    qr_b64 = base64.b64encode(buf.getvalue()).decode()

    return f"""
    <h3>Scan this with your authenticator app</h3>
    <img src="data:image/png;base64,{qr_b64}"><br>
    <p>Or enter manually: <code>{user['totp_secret']}</code></p>
    <form method="post" action="/confirm-2fa">
      6-digit code: <input name="token">
      <button type="submit">Confirm</button>
    </form>
    """

@app.route("/confirm-2fa", methods=["POST"])
def confirm_2fa():
    username = session.get("pending_user")
    user = users.get(username)
    if not user:
        return redirect(url_for("register"))

    totp = pyotp.TOTP(user["totp_secret"])
    if totp.verify(request.form["token"], valid_window=1):
        user["totp_confirmed"] = True
        session.pop("pending_user")
        return "2FA enabled. <a href='/login'>Log in</a>"
    return "Invalid code, try again. <a href='/enroll-2fa'>Back</a>", 400

@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        user = users.get(request.form["username"])
        if not user or not check_password_hash(user["password_hash"], request.form["password"]):
            return "Invalid credentials", 401

        if user["totp_confirmed"]:
            totp = pyotp.TOTP(user["totp_secret"])
            if not totp.verify(request.form.get("token", ""), valid_window=1):
                return "Invalid or expired 2FA code", 401

        session["user"] = request.form["username"]
        return redirect(url_for("dashboard"))

    return """
    <form method="post">
      Username: <input name="username"><br>
      Password: <input name="password" type="password"><br>
      2FA code (if enabled): <input name="token"><br>
      <button type="submit">Log in</button>
    </form>
    """

@app.route("/dashboard")
def dashboard():
    if "user" not in session:
        return redirect(url_for("login"))
    return f"Welcome, {session['user']}!"

if __name__ == "__main__":
    app.run(debug=True)

A few things worth calling out. Passwords go through generate_password_hash on the way in and check_password_hash on the way out, never compare raw strings against what a user typed, that's a timing side channel and a plaintext-breach waiting to happen the moment your dict becomes a database. The register route also rejects a duplicate username before creating an account. Without that check, someone could re-register an existing username, silently wipe out the original password hash and TOTP secret, and take over the account, worth remembering any time you build registration by hand.

On the TOTP side: pyotp.random_base32() gives you a base32-encoded secret, which is the format authenticator apps expect. provisioning_uri() builds the otpauth:// URL the QR code encodes, and issuer_name is what shows up as the account label in the app, so use your actual product name. valid_window=1 tells verify() to also accept the code from one 30-second step before or after the current one, which absorbs minor clock drift between the server and the user's phone without opening a huge window for guessing.

Run it

python app.py

Visit http://127.0.0.1:5000/register, create an account, and you'll land on the enrollment page with a QR code. debug=True is fine for this local run, but never ship it, it exposes an interactive debugger that lets anyone hitting an error page execute arbitrary code.

Verify it works

  1. Scan the QR code with your authenticator app. You should see a new entry labeled SourceFeedDemo (yourusername) with a 6-digit code that refreshes every 30 seconds.
  2. Type that code into the confirm form. You should get "2FA enabled."
  3. Go to /login, enter your username, password, and the current code from the app. You should land on the dashboard.
  4. Try logging in again with a stale or wrong code. You should get a 401 with "Invalid or expired 2FA code."

Troubleshooting

  • "Invalid code" even though it looks right: almost always clock drift. TOTP is time-based, so if your server's system clock is off by more than a minute, codes won't validate even with valid_window=1. Make sure NTP is running (timedatectl status on Linux) and check your server's timezone handling isn't skewing things.
  • QR code won't scan: double-check you installed qrcode[pil], not bare qrcode. Without Pillow, qrcode.make() can't produce a PNG at all and img.save() will fail.
  • Secret leaking into logs: never log provisioning_uri() output or the raw secret. If you're debugging, print only the confirmation status, not the secret itself.
  • Users losing their phone: this demo has no recovery path. Real implementations need backup codes generated at enrollment time, hashed and stored the same way you'd store passwords.

Next steps

Before this goes anywhere near production: move users to a real database, encrypt totp_secret at rest, load app.secret_key from an environment variable instead of hardcoding it, and add rate limiting on /login and /confirm-2fa so someone can't brute-force a 6-digit code. Generate one-time backup codes during enrollment. Once TOTP is solid, look at WebAuthn/passkeys as a stronger, phishing-resistant alternative or addition, most auth libraries (Django's django-otp, or hosted providers like Auth0) support both side by side.

Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading