Two‑factor authentication (2FA) is the gold standard for protecting user accounts against credential theft. If you’ve ever wondered how to add this extra layer of security to a Python‑based web app, you’re in the right place. In this guide we’ll walk through everything you need—from installing the right libraries to handling edge cases—so you can ship a robust 2FA solution with confidence.
What You’ll Need
- Python 3.8+ installed locally or on a server
- A virtual environment (venv or virtualenv)
- pip for package management
- A web framework (Flask or Django; examples use Flask)
- Access to an SMTP server or a service like SendGrid for email delivery
- Google Authenticator, Authy, or any TOTP‑compatible app for testing
Step 1: Set Up Your Project and Install Dependencies
Start by creating a fresh project folder and initializing a virtual environment:
mkdir py2fa-demo
cd py2fa-demo
python -m venv venv
source venv/bin/activate # On Windows use venvScriptsactivate Now install Flask, pyotp (for generating Time‑Based One‑Time Passwords), and Flask-Mail for email delivery:
pip install Flask pyotp Flask-Mail These packages give us the web server, the cryptographic core, and a simple way to send verification codes via email.
Step 2: Create a Basic Flask App with User Registration
For the sake of brevity we’ll store users in an in‑memory dictionary. In production you’d use a proper database and hash passwords with bcrypt or argon2.
from flask import Flask, request, render_template_string, redirect, url_for, session
from flask_mail import Mail, Message
import pyotp, os
app = Flask(__name__)
app.secret_key = os.urandom(24)
# Mail configuration – replace with your SMTP details
app.config.update(
MAIL_SERVER='smtp.example.com',
MAIL_PORT=587,
MAIL_USE_TLS=True,
MAIL_USERNAME='[email protected]',
MAIL_PASSWORD='yourpassword'
)
mail = Mail(app)
# Simple user store: {username: {password: 'hashed', totp_secret: 'base32'}}
users = {}
Now add routes for registration and login. The registration step generates a unique TOTP secret for each user and stores it.
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = request.form['password'] # In real code, hash this!
if username in users:
return 'User already exists', 400
# Generate a base32 secret for the user
secret = pyotp.random_base32()
users[username] = {'password': password, 'totp_secret': secret}
# Show QR code URL for the user to scan
totp_uri = pyotp.totp.TOTP(secret).provisioning_uri(name=username, issuer_name='Py2FA Demo')
return f"Registration successful. Scan this QR code with your authenticator app: QR Link"
return '''
Username:
Password:
'''
When a user registers, they receive a QR‑code link that can be turned into an actual QR image using a service like https://api.qrserver.com/v1/create-qr-code/. For simplicity we just provide the URI.
Step 3: Build the Login Flow – First Factor (Password)
The login route validates the password and, if correct, stores the username in the session and redirects to the 2FA verification page.
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
user = users.get(username)
if not user or user['password'] != password:
return 'Invalid credentials', 401
session['pre_2fa_user'] = username
return redirect(url_for('two_factor'))
return '''
Username:
Password:
'''
This step is identical to any standard password check; the important part is that we don’t mark the user as fully authenticated yet.
Step 4: Generate and Send a One‑Time Code (Second Factor)
There are two common ways to deliver the second factor: time‑based codes generated by an authenticator app, or a one‑time password (OTP) sent via email/SMS. We’ll implement the email approach because it’s easy to test locally.
@app.route('/two-factor', methods=['GET', 'POST'])
def two_factor():
username = session.get('pre_2fa_user')
if not username:
return redirect(url_for('login'))
user = users[username]
if request.method == 'POST':
token = request.form['token']
totp = pyotp.TOTP(user['totp_secret'])
if totp.verify(token):
session.pop('pre_2fa_user')
session['user'] = username
return 'Login successful!'
else:
return 'Invalid or expired token', 401
# Generate a fresh token and email it
totp = pyotp.TOTP(user['totp_secret'])
token = totp.now()
msg = Message('Your 2FA Code', recipients=[f'{username}@example.com'])
msg.body = f'Your verification code is: {token}nIt expires in 30 seconds.'
mail.send(msg)
return '''
A verification code has been sent to your email.
Code:
'''
Notice the use of totp.now() to generate a 6‑digit code that is valid for 30 seconds by default. The email step can be swapped for an SMS gateway with minimal changes.
Step 5: Protect Routes with a Simple Decorator
Once a user has passed both factors, we want to guard sensitive endpoints. A lightweight decorator keeps the code readable.
from functools import wraps
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
@app.route('/dashboard')
@login_required
def dashboard():
return f'Welcome, {session["user"]}! This is your protected dashboard.'
Any route wrapped with @login_required will automatically redirect unauthenticated users to the login page.
Step 6: Add Recovery Options and Remember‑Device Feature (Optional)
Real‑world implementations often include:
- Backup codes that users can store securely.
- A “Remember this device” checkbox that sets a long‑lived cookie, reducing friction on trusted machines.
- Rate‑limiting to prevent brute‑force attempts on the OTP field.
Below is a quick example of generating five backup codes and storing them in the user record.
import secrets
def generate_backup_codes(num=5):
return [secrets.token_hex(4) for _ in range(num)]
# During registration, add backup codes
users[username]['backup_codes'] = generate_backup_codes()
When the user submits a token, you can check the backup list before falling back to the TOTP verification. Remember to delete a backup code after it’s used.
Common Mistakes to Avoid
1 Storing secrets in plain text. Never write the TOTP secret or backup codes to a file without encryption. Use a vault or at least hash them with a strong algorithm.
2 Using a static time window. The default 30‑second window works for most apps, but if you notice frequent “expired code” complaints, consider increasing the window with totp.verify(token, valid_window=1).
3 Skipping rate limiting. Attackers can hammer the /two-factor endpoint. Implement a limit of 5 attempts per 10 minutes per IP or user.
4 Not verifying the session state. Always clear pre_2fa_user after successful verification; otherwise a user could skip the second factor by manually navigating to a protected URL.
5 Hard‑coding SMTP credentials. Move them to environment variables or a secrets manager to keep them out of source control.
Tips and Tricks
Use QR‑code libraries. Packages like qrcode let you embed the QR image directly in the registration page, improving UX.
Leverage Flask‑Login. For larger projects, Flask‑Login handles session management and can be extended with a custom UserMixin that checks the 2FA flag.
Test with multiple authenticator apps. Google Authenticator, Microsoft Authenticator, and Authy all follow the RFC 6238 standard, but UI quirks differ.
Store timestamps. Recording the time of the last successful 2FA can help detect suspicious activity, such as a sudden spike in logins from new locations.
Frequently Asked Questions
Is TOTP more secure than email‑based OTP?
Generally, yes. TOTP never travels over the network, so it can’t be intercepted. Email OTP is convenient but relies on the security of the email provider and can be vulnerable to phishing.
Can I use the same secret for multiple users?
No. Each user must have a unique base32 secret. Reusing a secret defeats the purpose of per‑account isolation and makes brute‑force attacks easier.
What if a user loses their authenticator device?
Provide a fallback flow: either use pre‑generated backup codes, send a recovery email with a temporary link, or require identity verification through support.
Conclusion
Adding two‑factor authentication to a Python application doesn’t have to be a daunting task. By following the six steps above—setting up the environment, generating per‑user TOTP secrets, delivering a one‑time code, protecting routes, and handling edge cases—you’ll dramatically increase the security posture of your service. Remember to avoid common pitfalls, keep secrets out of source control, and give users a reliable recovery path. With these practices in place, you’ll be ready to defend against credential‑stuffing attacks and give your users peace of mind.
Photo by Towfiqu barbhuiya on Unsplash





