Passwörter allein reichen nicht. Egal wie lang, egal wie komplex. Ein Phishing-Link, ein Datenleck, ein Keylogger, und das Passwort ist weg. Zwei-Faktor-Authentifizierung (2FA) ist die einfachste Maßnahme, die das Risiko drastisch senkt.
TOTP (Time-based One-Time Password) ist dabei der pragmatischste Einstieg: Kein externer Dienst nötig, keine SMS (die ohnehin unsicher sind), funktioniert mit jeder Authenticator-App. Und die Integration in eine bestehende Python-App? Überraschend wenig Code.
Server und Client teilen ein gemeinsames Geheimnis (Base32-String)
Beide berechnen aus dem Geheimnis + der aktuellen Zeit einen 6-stelligen Code
Der Code wechselt alle 30 Sekunden
Beim Login gibt der Nutzer neben dem Passwort den aktuellen Code ein
Da beide Seiten denselben Algorithmus verwenden (HMAC-SHA1 über einen Zeitstempel), braucht es keine Netzwerkverbindung zwischen Server und Authenticator-App.
Die Zutaten
Für eine Flask-App brauchen wir zwei Pakete:
1
pip install pyotp qrcode[pil]
pyotp: Generiert und verifiziert TOTP-Codes
qrcode: Erzeugt den QR-Code für die Authenticator-App
importpyotpimportqrcodeimportioimportbase64@app.route('/totp-setup',methods=['GET','POST'])@login_requireddeftotp_setup():user=get_current_user()ifrequest.method=='POST':secret=request.form.get('secret','')code=request.form.get('code','').strip()# Verifiziere den eingegebenen Codetotp=pyotp.TOTP(secret)iftotp.verify(code,valid_window=1):# 2FA aktivieren + Backup-Codes generierenbackup_plain=[secrets.token_hex(4).upper()for_inrange(10)]backup_hashed=[bcrypt.hashpw(c.encode(),bcrypt.gensalt()).decode()forcinbackup_plain]save_totp(user['id'],secret,True,backup_hashed)returnrender_template('totp_setup.html',backup_codes=backup_plain,setup_complete=True)flash('Ungültiger Code.')# GET: Neues Secret generierensecret=pyotp.random_base32()# QR-Code als Base64-PNGuri=pyotp.TOTP(secret).provisioning_uri(name=user['username'],issuer_name='Meine App')img=qrcode.make(uri)buf=io.BytesIO()img.save(buf,format='PNG')qr_b64=base64.b64encode(buf.getvalue()).decode()returnrender_template('totp_setup.html',secret=secret,qr_b64=qr_b64,setup_complete=False)
Wichtig: Das Secret wird erst gespeichert, nachdem der Nutzer einen gültigen Code eingegeben hat. So stellen wir sicher, dass die Authenticator-App korrekt eingerichtet ist.
{% if not setup_complete %}
<h2>2FA einrichten</h2><p>Scanne den QR-Code mit deiner Authenticator-App:</p><imgsrc="data:image/png;base64,{{ qr_b64 }}"alt="QR-Code"><p>Oder gib das Secret manuell ein: <code>{{ secret }}</code></p><formmethod="post"><inputtype="hidden"name="secret"value="{{ secret }}"><inputtype="text"name="code"placeholder="6-stelliger Code"pattern="[0-9]{6}"inputmode="numeric"autocomplete="one-time-code"><buttontype="submit">Aktivieren</button></form>{% else %}
<h2>2FA aktiviert! ✅</h2><p><strong>Backup-Codes</strong>, sicher aufbewahren:</p><ul> {% for code in backup_codes %}
<li><code>{{ code }}</code></li> {% endfor %}
</ul><p>⚠️ Diese Codes werden nur einmal angezeigt!</p>{% endif %}
Schritt 3: Login-Flow anpassen
Der Login bekommt einen Zwischenschritt. Nach korrektem Passwort kommt die TOTP-Abfrage:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@app.route('/login',methods=['GET','POST'])deflogin():ifrequest.method=='POST':username=request.form.get('username','').strip()password=request.form.get('password','')user=get_user_by_username(username)ifuserandcheck_password(password,user['password_hash']):ifuser.get('totp_enabled'):# Passwort stimmt, aber 2FA nötigsession['_totp_user_id']=user['id']returnredirect(url_for('totp_verify'))# Kein 2FA — direkt einloggenset_user_session(user)returnredirect(url_for('dashboard'))flash('Ungültiger Benutzername oder Passwort.')returnrender_template('login.html')
@app.route('/totp-verify',methods=['GET','POST'])deftotp_verify():uid=session.get('_totp_user_id')ifnotuid:returnredirect(url_for('login'))user=get_user_by_id(uid)ifnotuser:returnredirect(url_for('login'))ifrequest.method=='POST':code=request.form.get('code','').strip().replace(' ','')# Normaler TOTP-Code?totp=pyotp.TOTP(user['totp_secret'])iftotp.verify(code,valid_window=1):session.pop('_totp_user_id',None)set_user_session(user)returnredirect(url_for('dashboard'))# Backup-Code?ifuser.get('backup_codes'):codes=json.loads(user['backup_codes'])fori,hashedinenumerate(codes):ifbcrypt.checkpw(code.encode(),hashed.encode()):codes.pop(i)save_totp(user['id'],user['totp_secret'],True,codes)session.pop('_totp_user_id',None)set_user_session(user)flash(f'Backup-Code verwendet. 'f'Noch {len(codes)} übrig.','warning')returnredirect(url_for('dashboard'))flash('Ungültiger Code.')returnrender_template('totp_verify.html')
valid_window=1: Was bedeutet das?
pyotp.verify(code, valid_window=1) akzeptiert nicht nur den aktuellen 30-Sekunden-Code, sondern auch den davor und danach. Das fängt Uhrzeitabweichungen und langsame Eingaben ab, ohne die Sicherheit nennenswert zu senken.
Backup-Codes . Das Sicherheitsnetz
Was passiert, wenn jemand sein Handy verliert? Dafür gibt es Backup-Codes. Zehn zufällige Hex-Strings, die beim Setup einmalig angezeigt und dann gehasht gespeichert werden:
Warum hashen? Wenn die Datenbank kompromittiert wird, sind die Backup-Codes trotzdem nicht im Klartext lesbar. Gleiche Logik wie bei Passwörtern.
Warum bcrypt statt SHA-256? Weil bcrypt absichtlich langsam ist. Bei einem Datenleck kann ein Angreifer nicht millionenfach Codes pro Sekunde durchprobieren.
Jeder Backup-Code funktioniert genau einmal. Nach der Nutzung wird er aus dem Array entfernt.
2FA deaktivieren
Sollte ein Nutzer 2FA wieder deaktivieren wollen, reicht ein Passwort-Check:
Setup:
Secret generieren → QR-Code anzeigen → User scannt →
User gibt Code ein → Server verifiziert → Secret speichern → Backup-Codes zeigen
Login:
Username + Passwort → ✅ → 2FA aktiv? →
Ja → TOTP-Code abfragen → verifizieren → einloggen
Nein → direkt einloggen
Fazit
Der gesamte TOTP-Flow (Setup mit QR-Code, Verifizierung, Backup-Codes) ist in unter 100 Zeilen Python umgesetzt. Die Pakete pyotp und qrcode nehmen einem die Krypto-Arbeit ab, und Flask’s Session-System reicht für den Zwischenschritt beim Login.
Was es kostet: Zwei pip install-Befehle und drei Datenbank-Spalten.
Was es bringt: Eine Größenordnung mehr Sicherheit.
Im nächsten Artikel gehen wir einen Schritt weiter: Passkeys: Login ganz ohne Passwort, direkt mit dem Gerät. Noch sicherer, noch komfortabler, und ebenfalls überraschend gut in Python integrierbar.
Alle Code-Beispiele stammen aus einer produktiven Flask-Anwendung. Die relevanten Pakete: pyotp (MIT), qrcode (BSD), bcrypt (Apache 2.0).