Passkeys in Python: Login ohne Passwort mit Flask und WebAuthn

Passwörter haben ausgedient

Im letzten Artikel haben wir TOTP als zweiten Faktor eingebaut. Das ist gut und deutlich besser als nur ein Passwort. Aber TOTP hat Schwächen: Der Code kann abgephisht werden, das Shared Secret liegt auf dem Server, und der Nutzer muss immer noch ein Passwort haben.

Passkeys lösen das grundsätzlich anders: Kein Passwort, kein Shared Secret, kein Code zum Abtippen. Stattdessen asymmetrische Kryptographie. Der private Schlüssel verlässt nie das Gerät des Nutzers.

Wie funktionieren Passkeys?

Passkeys basieren auf dem WebAuthn-Standard (Web Authentication API). Das Prinzip:

  1. Registration: Der Server schickt eine Challenge. Das Gerät (Laptop, Handy, Security Key) erzeugt ein Schlüsselpaar. Der öffentliche Schlüssel geht an den Server, der private bleibt auf dem Gerät.
  2. Authentication: Der Server schickt erneut eine Challenge. Das Gerät signiert sie mit dem privaten Schlüssel. Der Server prüft die Signatur mit dem gespeicherten öffentlichen Schlüssel.

Warum ist das sicherer?

  • Kein Shared Secret: Bei einem Datenleck ist der öffentliche Schlüssel wertlos
  • Phishing-resistent: Der Browser bindet die Signatur an die Domain; eine Fake-Seite bekommt keine gültige Signatur
  • Kein Code zum Abfangen: Kein TOTP, keine SMS, kein MitM-Angriff möglich

Die Zutaten

1
pip install py-webauthn

Ein einziges Paket. py_webauthn implementiert den gesamten WebAuthn-Standard serverseitig.

Schritt 1: Konfiguration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
from webauthn import (
    generate_registration_options,
    verify_registration_response,
    generate_authentication_options,
    verify_authentication_response,
    options_to_json,
)
from webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria,
    ResidentKeyRequirement,
    UserVerificationRequirement,
    PublicKeyCredentialDescriptor,
)
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes

# Deine Domain: MUSS mit der tatsächlichen URL übereinstimmen!
RP_ID = 'meine-app.example.com'
RP_NAME = 'Meine App'
RP_ORIGIN = f'https://{RP_ID}'

Wichtig: RP_ID (Relying Party ID) muss exakt die Domain sein, unter der die App läuft. WebAuthn bindet Credentials an die Domain . Das ist der Phishing-Schutz.

Schritt 2: Datenbank

Die Passkey-Tabelle speichert den öffentlichen Schlüssel pro Credential:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
CREATE TABLE passkeys (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    credential_id VARBINARY(1024) NOT NULL,
    public_key VARBINARY(2048) NOT NULL,
    sign_count INT UNSIGNED DEFAULT 0,
    name VARCHAR(100) DEFAULT 'Passkey',
    transports TEXT,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    last_used DATETIME,
    UNIQUE KEY (credential_id(255)),
    FOREIGN KEY (user_id) REFERENCES users(id)
);
  • credential_id: Identifiziert den Passkey (vom Authenticator vergeben)
  • public_key: Der öffentliche Schlüssel (COSE-Format)
  • sign_count: Zähler gegen Credential-Klone (wird bei jeder Nutzung geprüft)
  • transports: Wie der Authenticator erreichbar ist (usb, ble, internal, hybrid)

Schritt 3: Registration — Passkey anlegen

Backend: Options generieren

 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
@app.route('/api/passkey/register/options', methods=['POST'])
@login_required
def passkey_register_options():
    user = get_current_user()

    # Bereits registrierte Credentials ausschließen
    existing = get_passkeys_by_user(user['id'])
    exclude = [
        PublicKeyCredentialDescriptor(id=pk['credential_id'])
        for pk in existing
    ]

    options = generate_registration_options(
        rp_id=RP_ID,
        rp_name=RP_NAME,
        user_id=str(user['id']).encode('utf-8'),
        user_name=user['username'],
        user_display_name=user['username'],
        exclude_credentials=exclude,
        authenticator_selection=AuthenticatorSelectionCriteria(
            resident_key=ResidentKeyRequirement.PREFERRED,
            user_verification=UserVerificationRequirement.PREFERRED,
        ),
    )

    # Challenge in Session speichern
    session['reg_challenge'] = bytes_to_base64url(options.challenge)
    return jsonify(json.loads(options_to_json(options)))

Frontend: Credential erzeugen

 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
47
48
49
50
51
52
53
54
55
async function registerPasskey() {
    const name = prompt('Name für den Passkey:', 'Mein Laptop');
    if (!name) return;

    // 1. Options vom Server holen
    const optRes = await fetch('/api/passkey/register/options',
                               { method: 'POST' });
    const options = await optRes.json();

    // 2. Challenge und User-ID in ArrayBuffer umwandeln
    options.challenge = base64urlToBuffer(options.challenge);
    options.user.id = base64urlToBuffer(options.user.id);
    if (options.excludeCredentials) {
        options.excludeCredentials.forEach(c => {
            c.id = base64urlToBuffer(c.id);
        });
    }

    // 3. Browser-API aufrufen: hier passiert die Magie
    const credential = await navigator.credentials.create({
        publicKey: options
    });

    // 4. Response encodieren
    const response = {
        id: credential.id,
        rawId: bufferToBase64url(credential.rawId),
        type: credential.type,
        response: {
            attestationObject: bufferToBase64url(
                credential.response.attestationObject),
            clientDataJSON: bufferToBase64url(
                credential.response.clientDataJSON),
        },
        passkey_name: name,
    };

    // Transports mitsenden (für spätere Authentication)
    if (typeof credential.response.getTransports === 'function') {
        response.transports = credential.response.getTransports();
    }

    // 5. An Server senden
    const verifyRes = await fetch('/api/passkey/register/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(response),
    });

    const result = await verifyRes.json();
    if (result.success) {
        alert('Passkey registriert! ✅');
        location.reload();
    }
}

Backend: Credential verifizieren und speichern

 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
@app.route('/api/passkey/register/verify', methods=['POST'])
@login_required
def passkey_register_verify():
    body = request.get_json()
    challenge_b64 = session.pop('reg_challenge', None)
    if not challenge_b64:
        return jsonify({'error': 'No registration in progress'}), 400

    verification = verify_registration_response(
        credential=body,
        expected_challenge=base64url_to_bytes(challenge_b64),
        expected_rp_id=RP_ID,
        expected_origin=RP_ORIGIN,
    )

    # Transports extrahieren
    transports = None
    resp_transports = (body.get('response', {}).get('transports')
                       or body.get('transports'))
    if resp_transports:
        transports = ','.join(resp_transports)

    name = (body.get('passkey_name') or 'Passkey').strip()[:100]

    create_passkey(
        user_id=session['user_id'],
        credential_id=verification.credential_id,
        public_key=verification.credential_public_key,
        sign_count=verification.sign_count,
        name=name,
        transports=transports,
    )

    return jsonify({'success': True, 'message': 'Passkey registriert!'})

Schritt 4: Authentication — Login mit Passkey

Backend: Challenge generieren

1
2
3
4
5
6
7
8
9
@app.route('/api/passkey/login/options', methods=['POST'])
def passkey_login_options():
    options = generate_authentication_options(
        rp_id=RP_ID,
        user_verification=UserVerificationRequirement.PREFERRED,
    )

    session['auth_challenge'] = bytes_to_base64url(options.challenge)
    return jsonify(json.loads(options_to_json(options)))

Beachte: Hier werden keine allowCredentials angegeben. Das bedeutet, der Browser zeigt alle für diese Domain registrierten Passkeys an. Der Nutzer wählt selbst. Das ist der „Usernameless Flow": Kein Benutzername, kein Passwort, nur ein Klick.

Frontend: Signatur erzeugen

 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
async function loginWithPasskey() {
    // 1. Options holen
    const optRes = await fetch('/api/passkey/login/options',
                               { method: 'POST' });
    const options = await optRes.json();
    options.challenge = base64urlToBuffer(options.challenge);

    // 2. Browser-API: Nutzer wählt Passkey
    const credential = await navigator.credentials.get({
        publicKey: options
    });

    // 3. An Server senden
    const verifyRes = await fetch('/api/passkey/login/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            id: credential.id,
            rawId: bufferToBase64url(credential.rawId),
            type: credential.type,
            response: {
                authenticatorData: bufferToBase64url(
                    credential.response.authenticatorData),
                clientDataJSON: bufferToBase64url(
                    credential.response.clientDataJSON),
                signature: bufferToBase64url(
                    credential.response.signature),
                userHandle: credential.response.userHandle
                    ? bufferToBase64url(credential.response.userHandle)
                    : null,
            },
        }),
    });

    const result = await verifyRes.json();
    if (result.success) window.location.href = '/';
}

Backend: Signatur prüfen und einloggen

 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
@app.route('/api/passkey/login/verify', methods=['POST'])
def passkey_login_verify():
    body = request.get_json()
    challenge_b64 = session.pop('auth_challenge', None)
    if not challenge_b64:
        return jsonify({'error': 'No authentication in progress'}), 400

    # Passkey anhand der Credential-ID finden
    raw_id = base64url_to_bytes(body['rawId'])
    passkey = get_passkey_by_credential_id(raw_id)
    if not passkey:
        return jsonify({'error': 'Unbekannter Passkey'}), 400

    verification = verify_authentication_response(
        credential=body,
        expected_challenge=base64url_to_bytes(challenge_b64),
        expected_rp_id=RP_ID,
        expected_origin=RP_ORIGIN,
        credential_public_key=passkey['public_key'],
        credential_current_sign_count=passkey['sign_count'],
    )

    # Sign-Count aktualisieren (Klone erkennen)
    update_passkey_sign_count(passkey['id'],
                              verification.new_sign_count)

    # Einloggen
    session['user_id'] = passkey['user_id']
    return jsonify({'success': True})

Schritt 5: Key-Management

Nutzer sollten ihre Passkeys sehen, benennen und löschen können:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@app.route('/passkeys')
@login_required
def passkeys():
    user_passkeys = get_passkeys_by_user(session['user_id'])
    return render_template('passkeys.html', passkeys=user_passkeys)

@app.route('/passkeys/rename/<int:pid>', methods=['POST'])
@login_required
def rename_passkey_route(pid):
    name = request.form.get('name', 'Passkey').strip()[:100]
    rename_passkey(pid, session['user_id'], name)
    return redirect(url_for('passkeys'))

@app.route('/passkeys/delete/<int:pid>', methods=['POST'])
@login_required
def delete_passkey_route(pid):
    delete_passkey(pid, session['user_id'])
    return redirect(url_for('passkeys'))

Die Base64url-Hilfsfunktionen

WebAuthn arbeitet mit ArrayBuffers, JSON-APIs mit Base64url. Diese zwei Funktionen braucht das Frontend:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function base64urlToBuffer(b64url) {
    const padding = '='.repeat((4 - b64url.length % 4) % 4);
    const base64 = b64url.replace(/-/g, '+').replace(/_/g, '/') + padding;
    const binary = atob(base64);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++)
        bytes[i] = binary.charCodeAt(i);
    return bytes.buffer;
}

function bufferToBase64url(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = '';
    for (const b of bytes) binary += String.fromCharCode(b);
    return btoa(binary)
        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

Der Sign-Count — Warum?

Bei jeder Authentication erhöht der Authenticator einen internen Zähler. Der Server prüft, ob der neue Wert größer ist als der gespeicherte. Wenn nicht, wurde das Credential möglicherweise kopiert (Klone-Detection).

In der Praxis ist das vor allem bei Hardware-Security-Keys relevant. Bei plattformgebundenen Passkeys (Touch ID, Windows Hello) ist Klonen praktisch unmöglich, da der private Schlüssel in der Secure Enclave liegt.

Passkeys + TOTP = Doppelter Boden

In einer produktiven App bieten wir beides an:

  • Passwort + TOTP: für Nutzer, die noch kein Passkey-fähiges Gerät haben
  • Passkey: für alle, die den modernen Weg gehen wollen
  • Passkey + TOTP als Fallback: für Nutzer, die ihren Passkey verlieren

Der Login-Screen zeigt beides: Das klassische Formular und einen „Mit Passkey anmelden"-Button. Der Nutzer wählt selbst.

Browser-Support

Passkeys werden heute von allen großen Browsern unterstützt:

  • Chrome/Edge ab Version 108
  • Safari ab Version 16
  • Firefox ab Version 122

Auf Mobilgeräten funktionieren sie über Face ID (iOS), Fingerabdruck (Android) oder den Geräte-PIN. Cross-Device-Authentication (Handy als Authenticator für den Laptop) läuft über QR-Code + Bluetooth.

Fazit

Die gesamte Passkey-Integration — Registration, Authentication, Key-Management — ist in etwa 150 Zeilen Python und 80 Zeilen JavaScript umgesetzt. py_webauthn nimmt die komplette Krypto-Arbeit ab. Der schwierigste Teil ist ehrlich gesagt die Base64url-Konvertierung im Frontend.

Was es kostet: Ein pip install und eine Datenbank-Tabelle. Was es bringt: Phishing-resistente, passwortlose Authentifizierung.

Die Zukunft der Authentifizierung ist kein Passwort. Sie ist ein Schlüsselpaar, von dem eine Hälfte nie das Gerät verlässt.


Alle Code-Beispiele stammen aus einer produktiven Flask-Anwendung. Das relevante Paket: py_webauthn (BSD). Der vollständige WebAuthn-Standard: W3C Web Authentication.