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.
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
- 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. - Type that code into the confirm form. You should get "2FA enabled."
- Go to
/login, enter your username, password, and the current code from the app. You should land on the dashboard. - 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 statuson Linux) and check your server's timezone handling isn't skewing things. - QR code won't scan: double-check you installed
qrcode[pil], not bareqrcode. Without Pillow,qrcode.make()can't produce a PNG at all andimg.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 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
No comments yet
Be the first to weigh in.