TOTP-basierte 2FA in Python: So einfach geht's

Warum 2FA keine Option mehr ist, sondern Pflicht

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.

Was ist TOTP?

TOTP basiert auf RFC 6238. Das Prinzip:

  1. Server und Client teilen ein gemeinsames Geheimnis (Base32-String)
  2. Beide berechnen aus dem Geheimnis + der aktuellen Zeit einen 6-stelligen Code
  3. Der Code wechselt alle 30 Sekunden
  4. 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

Schritt 1: Datenbank erweitern

Die Users-Tabelle braucht drei neue Spalten:

1
2
3
4
ALTER TABLE users
    ADD COLUMN totp_secret VARCHAR(32) DEFAULT NULL,
    ADD COLUMN totp_enabled TINYINT(1) DEFAULT 0,
    ADD COLUMN backup_codes TEXT DEFAULT NULL;
  • totp_secret: Das gemeinsame Geheimnis (Base32)
  • totp_enabled: Ist 2FA aktiv?
  • backup_codes: JSON-Array mit gehashten Backup-Codes (dazu gleich mehr)

Schritt 2: Setup-Route: QR-Code generieren

Wenn ein Nutzer 2FA aktivieren will, generieren wir ein Geheimnis und zeigen es als QR-Code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import pyotp
import qrcode
import io
import base64

@app.route('/totp-setup', methods=['GET', 'POST'])
@login_required
def totp_setup():
    user = get_current_user()

    if request.method == 'POST':
        secret = request.form.get('secret', '')
        code = request.form.get('code', '').strip()

        # Verifiziere den eingegebenen Code
        totp = pyotp.TOTP(secret)
        if totp.verify(code, valid_window=1):
            # 2FA aktivieren + Backup-Codes generieren
            backup_plain = [secrets.token_hex(4).upper() for _ in range(10)]
            backup_hashed = [
                bcrypt.hashpw(c.encode(), bcrypt.gensalt()).decode()
                for c in backup_plain
            ]
            save_totp(user['id'], secret, True, backup_hashed)
            return render_template('totp_setup.html',
                                   backup_codes=backup_plain,
                                   setup_complete=True)
        flash('Ungültiger Code.')

    # GET: Neues Secret generieren
    secret = pyotp.random_base32()

    # QR-Code als Base64-PNG
    uri = 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()

    return render_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.

Das Template dazu

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
{% if not setup_complete %}
<h2>2FA einrichten</h2>
<p>Scanne den QR-Code mit deiner Authenticator-App:</p>
<img src="data:image/png;base64,{{ qr_b64 }}" alt="QR-Code">
<p>Oder gib das Secret manuell ein: <code>{{ secret }}</code></p>

<form method="post">
    <input type="hidden" name="secret" value="{{ secret }}">
    <input type="text" name="code" placeholder="6-stelliger Code"
           pattern="[0-9]{6}" inputmode="numeric" autocomplete="one-time-code">
    <button type="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'])
def login():
    if request.method == 'POST':
        username = request.form.get('username', '').strip()
        password = request.form.get('password', '')
        user = get_user_by_username(username)

        if user and check_password(password, user['password_hash']):
            if user.get('totp_enabled'):
                # Passwort stimmt, aber 2FA nötig
                session['_totp_user_id'] = user['id']
                return redirect(url_for('totp_verify'))
            # Kein 2FA — direkt einloggen
            set_user_session(user)
            return redirect(url_for('dashboard'))

        flash('Ungültiger Benutzername oder Passwort.')
    return render_template('login.html')

Schritt 4: TOTP verifizieren

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@app.route('/totp-verify', methods=['GET', 'POST'])
def totp_verify():
    uid = session.get('_totp_user_id')
    if not uid:
        return redirect(url_for('login'))

    user = get_user_by_id(uid)
    if not user:
        return redirect(url_for('login'))

    if request.method == 'POST':
        code = request.form.get('code', '').strip().replace(' ', '')

        # Normaler TOTP-Code?
        totp = pyotp.TOTP(user['totp_secret'])
        if totp.verify(code, valid_window=1):
            session.pop('_totp_user_id', None)
            set_user_session(user)
            return redirect(url_for('dashboard'))

        # Backup-Code?
        if user.get('backup_codes'):
            codes = json.loads(user['backup_codes'])
            for i, hashed in enumerate(codes):
                if bcrypt.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')
                    return redirect(url_for('dashboard'))

        flash('Ungültiger Code.')

    return render_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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import secrets
import bcrypt

# Generieren (beim Setup)
backup_plain = [secrets.token_hex(4).upper() for _ in range(10)]
# → ['A3F8B21C', '7E09D4A5', ...]

# Hashen (für die Datenbank)
backup_hashed = [
    bcrypt.hashpw(code.encode(), bcrypt.gensalt()).decode()
    for code in backup_plain
]

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@app.route('/totp-disable', methods=['POST'])
@login_required
def totp_disable():
    password = request.form.get('password', '')
    user = get_current_user()
    if check_password(password, user['password_hash']):
        reset_totp(user['id'])  # secret=NULL, enabled=0, codes=NULL
        flash('2FA deaktiviert.')
    else:
        flash('Falsches Passwort.')
    return redirect(url_for('settings'))

Der gesamte Flow im Überblick

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).