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:
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.
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
fromwebauthnimport(generate_registration_options,verify_registration_response,generate_authentication_options,verify_authentication_response,options_to_json,)fromwebauthn.helpers.structsimport(AuthenticatorSelectionCriteria,ResidentKeyRequirement,UserVerificationRequirement,PublicKeyCredentialDescriptor,)fromwebauthn.helpersimportbytes_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:
@app.route('/api/passkey/register/options',methods=['POST'])@login_requireddefpasskey_register_options():user=get_current_user()# Bereits registrierte Credentials ausschließenexisting=get_passkeys_by_user(user['id'])exclude=[PublicKeyCredentialDescriptor(id=pk['credential_id'])forpkinexisting]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 speichernsession['reg_challenge']=bytes_to_base64url(options.challenge)returnjsonify(json.loads(options_to_json(options)))
asyncfunctionregisterPasskey(){constname=prompt('Name für den Passkey:','Mein Laptop');if(!name)return;// 1. Options vom Server holen
constoptRes=awaitfetch('/api/passkey/register/options',{method:'POST'});constoptions=awaitoptRes.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
constcredential=awaitnavigator.credentials.create({publicKey:options});// 4. Response encodieren
constresponse={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(typeofcredential.response.getTransports==='function'){response.transports=credential.response.getTransports();}// 5. An Server senden
constverifyRes=awaitfetch('/api/passkey/register/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(response),});constresult=awaitverifyRes.json();if(result.success){alert('Passkey registriert! ✅');location.reload();}}
Beachte: Hier werden keineallowCredentials 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.
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.