WebAuthn (Web Authentication, W3C Level 3) und das darauf aufbauende Passkeys-Konzept sind die strukturelle Antwort auf Phishing: statt eines Passworts oder TOTP-Codes (alles abfangbar) nutzt der Browser einen asymmetrischen Schlüsselpaar-Flow, der Origin-gebunden ist — ein Phishing-Site auf einer fremden Domain kann das Credential nicht missbrauchen. 2025/2026 ist Passkey-Support flächendeckend (Apple, Google, Microsoft, Firefox, alle großen Identity-Provider). Dieser Artikel zeigt den Flow, die Server-Implementierungs-Bausteine und die Enrollment-/Recovery-Patterns.
Was Passkeys von Passwort + 2FA unterscheidet
Das fundamentale Konzept: bei der Registration erzeugt der Authenticator (Phone, Hardware-Key, Plattform-Stack) einen Schlüsselpaar. Public Key geht zum Server, Private Key bleibt im Authenticator. Bei der Authentication signiert der Authenticator eine Challenge mit dem Private Key, der Server prüft mit dem Public Key.
Eigenschaften, die das ergibt:
- Phishing-resistent — der Authenticator bindet das Credential an die Origin (Domain). Eine Phishing-Site auf
bank-security.cckann das Credential fürbank.examplenicht nutzen, weil die Origin nicht passt. - Kein Shared Secret — der Server bekommt nur den Public Key. DB-Leak ist harmlos für Auth (Public Keys sind kein Geheimnis).
- MitM-resistent — der Authenticator signiert die Origin als Teil der Antwort. Adversary-in-the-Middle-Proxies (Evilginx) funktionieren nicht.
- Multi-Faktor in einem Schritt — Authenticator verlangt User Verification (PIN, Biometrie, Touch). Damit ist „etwas, das du hast" und „etwas, das du weißt/bist" in einem Flow erfüllt.
Passkey vs. klassische WebAuthn-Credentials: Passkeys sind WebAuthn-Credentials mit dem discoverable=true-Flag (Stand 2025). Das heißt, der Authenticator hält den Username bereits — User muss beim Login keinen Username eingeben, sondern wählt aus einer Liste.
Registration-Flow (Anmeldung eines neuen Credentials)
1. Client (Browser) fordert vom Server "Registration-Options".
2. Server generiert:
- challenge (zufällige Bytes, 32+)
- user (id, name, displayName)
- rp (relying party — die App: id=Domain, name=Lesbar)
- pubKeyCredParams (akzeptierte Algorithmen, z. B. ES256, RS256)
- excludeCredentials (existierende Credentials des Users, gegen Duplikate)
- authenticatorSelection (UserVerification, ResidentKey-Flag)
3. Server schickt Options zum Client.
4. Client ruft navigator.credentials.create(options) auf.
5. Browser → Authenticator → User-Interaktion (PIN, Touch, Biometrie).
6. Authenticator erzeugt Schlüsselpaar, signiert Attestation.
7. Client schickt Antwort (publicKey, attestationObject, clientDataJSON) zurück.
8. Server validiert:
- clientDataJSON.challenge == Original-Challenge
- clientDataJSON.origin == erwartete Origin
- clientDataJSON.type == "webauthn.create"
- attestationObject (optional: Attestation-Trust-Chain)
9. Server speichert: credentialId, publicKey, signCount, userId.Node.js (mit @simplewebauthn/server):
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
// 1. Options generieren
app.post('/register-start', async (req, res) => {
const user = req.session.user;
const options = await generateRegistrationOptions({
rpName: 'My App',
rpID: 'app.example.com',
userID: Buffer.from(user.id),
userName: user.email,
attestationType: 'none', // 'direct' wenn Attestation gewünscht
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
excludeCredentials: user.credentials.map(c => ({ id: c.credentialId })),
});
req.session.currentChallenge = options.challenge;
res.json(options);
});
// 2. Antwort verifizieren
app.post('/register-finish', async (req, res) => {
const verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: req.session.currentChallenge,
expectedOrigin: 'https://app.example.com',
expectedRPID: 'app.example.com',
});
if (!verification.verified) {
return res.status(400).send('Registration failed');
}
await db.credentials.insert({
userId: req.session.user.id,
credentialId: verification.registrationInfo.credentialID,
publicKey: verification.registrationInfo.credentialPublicKey,
counter: verification.registrationInfo.counter,
transports: req.body.response.transports,
});
res.json({ ok: true });
});Authentication-Flow
1. Client fordert "Authentication-Options".
2. Server generiert:
- challenge
- allowCredentials (Liste der CredentialIDs des Users — bei Passkey-Discovery leer)
- rpId
- userVerification (preferred / required / discouraged)
3. Client ruft navigator.credentials.get(options) auf.
4. Browser → Authenticator → User-Interaktion.
5. Authenticator signiert die Challenge mit dem Private Key.
6. Client schickt Antwort (credentialId, signature, authenticatorData, clientDataJSON).
7. Server validiert:
- clientDataJSON.challenge == Original
- clientDataJSON.origin korrekt
- clientDataJSON.type == "webauthn.get"
- Signatur gegen gespeicherten Public Key
- signCount > gespeicherter Counter (Replay-Schutz)
8. Server setzt Session.Node.js:
import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server';
// 1. Authentication-Options
app.post('/login-start', async (req, res) => {
const options = await generateAuthenticationOptions({
rpID: 'app.example.com',
userVerification: 'preferred',
// Bei Passkey-Login leer lassen — Browser zeigt Auswahl aller Credentials
allowCredentials: [],
});
req.session.currentChallenge = options.challenge;
res.json(options);
});
// 2. Verify
app.post('/login-finish', async (req, res) => {
const credentialId = req.body.id;
const credential = await db.credentials.findOne({ credentialId });
const verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: req.session.currentChallenge,
expectedOrigin: 'https://app.example.com',
expectedRPID: 'app.example.com',
authenticator: {
credentialID: credential.credentialId,
credentialPublicKey: credential.publicKey,
counter: credential.counter,
},
});
if (!verification.verified) {
return res.status(401).send('Auth failed');
}
// Counter aktualisieren (Replay-Schutz)
await db.credentials.update(
{ credentialId },
{ counter: verification.authenticationInfo.newCounter }
);
// Session setzen
req.session.userId = credential.userId;
res.json({ ok: true });
});Synced vs Device-bound Passkeys
Eine zentrale Entscheidung im Passkey-Modell:
| Typ | Wo lebt der Private Key | Beispiele | Use Case |
|---|---|---|---|
| Synced Passkeys | iCloud Keychain / Google Password Manager / 1Password — Cross-Device-Sync | Standard für Consumer-Apps | Komfort: kein „neues Device anmelden" nötig |
| Device-bound | Nur auf einem Gerät (Hardware-Key, Plattform-TPM, secure enclave) | Hardware-Token (YubiKey), Enterprise-Setups | Höchste Sicherheit, kein Sync-Risiko |
Synced Passkeys:
- Plus: User-Komfort. Phone vergessen? Anderes Gerät mit demselben iCloud-Account funktioniert.
- Minus: Schutz hängt vom Sync-Provider ab. iCloud-Account-Übernahme = Passkey-Übernahme.
Device-bound (Attestation):
- Plus: Kein Sync-Risiko, kryptografisch an ein Gerät gebunden.
- Minus: Verlust = Auth-Verlust. Recovery-Strategie zwingend nötig.
Server-Wahl: Im authenticatorSelection-Parameter:
residentKey: 'preferred'— funktioniert mit beiden Typen.residentKey: 'required'— nur discoverable credentials (Standard für Passkey).authenticatorAttachment: 'platform'— nur eingebaute Authenticators (Touch ID, Windows Hello).authenticatorAttachment: 'cross-platform'— nur externe (YubiKey).
Für Consumer-Apps: preferred, platform/cross-platform undefined (User entscheidet). Für Enterprise mit Hardware-Token-Pflicht: cross-platform + Attestation-Validierung.
User Verification (UV) und User Presence (UP)
WebAuthn unterscheidet zwei Bestätigungs-Stufen:
- User Presence (UP) — User-Touch/Tap am Authenticator. Beweis, dass jemand physisch anwesend ist. Standard.
- User Verification (UV) — User identifiziert sich gegenüber dem Authenticator (PIN, Biometrie). Beweis, dass es der rechtmäßige User ist.
Server-Anforderung im Options-Request:
userVerification: 'preferred'— UV wenn verfügbar, sonst UP only.userVerification: 'required'— UV Pflicht (für sensitive Aktionen).userVerification: 'discouraged'— UP only.
Konsens: Login → required; passive Operationen (z. B. „bestätige, dass du da bist") → preferred. Für Step-Up (Re-Auth) → required.
Server-Check nach Authentication: Im authenticatorData-Flag prüfen, ob UV-Bit gesetzt ist — Library-API liefert das.
Attestation — Vertraue ich diesem Authenticator?
Attestation ist eine signierte Aussage des Authenticators: „Ich bin ein echter Hardware-YubiKey von Yubico, Modell 5C." Mit Attestation kann ein Server:
- Validieren, dass nur zertifizierte Hardware zugelassen ist (Enterprise-Pflicht).
- Bei Compliance-Anforderungen (Banking, Government) das Attest-Zertifikat prüfen.
Attestation-Typen:
| Typ | Was es ist |
|---|---|
| None | Keine Attestation — Authenticator verrät nichts über sich |
| Self | Authenticator signiert mit eigenem Schlüssel — kein externer Trust |
| Basic | Signiert mit Hersteller-Zertifikat (Yubico-Wurzel etc.) |
| AttCA | Mit Attestation-CA-Zertifikat |
| AnonCA | Anonymisierte Variante (Apple-Modell) |
Für Consumer-Apps: Attestation in der Regel none. Passkeys sollen ja Cross-Platform funktionieren.
Für Enterprise-/High-Security: Attestation direct, Trust-Chain gegen FIDO-Metadata-Service (MDS) prüfen. Damit lassen sich nicht-zertifizierte Authenticators ausschließen.
Enrollment-Strategien
Wie kommt ein User zu einem Passkey?
1. Passkey beim Sign-up:
Bei der Registrierung Passkey vorschlagen. User muss zustimmen — Browser zeigt System-Dialog für Authenticator-Wahl. Nach erfolgreichem Setup: User hat ein Credential, kein Passwort nötig (passwortlos).
2. Passkey-Migration für bestehende User:
Bestehender User mit Passwort + ggf. TOTP. App schlägt während eines normalen Logins vor: „Möchtest du einen Passkey anlegen?" Bei Zustimmung: Registration-Flow läuft, Passwort wird optional behalten oder gelöscht.
3. Passkey neben Passwort:
User behält Passwort, hat aber zusätzlich einen Passkey. Beim Login wählt der Browser/User die Methode. Pragmatisch, weil schrittweise Migration.
4. Mehrere Passkeys pro User:
Empfehlung: immer mindestens zwei Passkeys pro User registrieren (z. B. Phone + Laptop-Authenticator). Damit ist Verlust eines Geräts kein Lockout.
// Settings-Seite: Liste der registrierten Passkeys
app.get('/account/passkeys', async (req, res) => {
const credentials = await db.credentials.find({ userId: req.session.userId });
res.json(credentials.map(c => ({
id: c.credentialId,
createdAt: c.createdAt,
lastUsedAt: c.lastUsedAt,
label: c.label || 'Unnamed Passkey',
transports: c.transports,
})));
});
// Add new Passkey (Re-Auth required)
app.post('/account/passkeys', requireFreshAuth(), async (req, res) => {
// ... Registration-Flow ...
});
// Remove Passkey (Re-Auth required, mindestens ein Passkey muss bleiben)
app.delete('/account/passkeys/:id', requireFreshAuth(), async (req, res) => {
const count = await db.credentials.count({ userId: req.session.userId });
if (count <= 1) {
return res.status(400).send('Cannot delete last passkey');
}
await db.credentials.delete({ credentialId: req.params.id });
res.json({ ok: true });
});Recovery — der schwierigste Teil
Was, wenn der User alle Passkeys verliert?
Strategien (kombinierbar):
-
Synced Passkeys — Cloud-Sync (iCloud/Google) bedeutet: neues Gerät, gleicher Cloud-Account → Passkeys da. Verlagert das Recovery-Problem in den Cloud-Provider.
-
Recovery-Codes — beim ersten Setup einmalige Recovery-Codes generieren, User speichert sie offline. Bei Verlust: ein Code einlösbar, neue Passkeys anmelden.
-
E-Mail-basiertes Recovery — Magic Link zum Reset. Sicherheit hängt vom Mail-Account ab.
-
Account-Wiederherstellung über Support — manuelle Identitäts-Prüfung (ID-Dokument hochladen, Video-Call). Für sensitive Apps (Banking) Standard, aber teuer.
-
Second Factor als Backup — TOTP-App oder SMS als Fallback. Schwächt die Phishing-Resistenz, aber praktikabel.
Faustregel: Mindestens zwei Recovery-Pfade — z. B. Recovery-Codes plus zweite Passkey-Device. Single Point of Failure vermeiden.
Besonderheiten
Conditional UI / Autofill macht Passkey-Login intuitiv
Mit Conditional UI taucht der Passkey-Vorschlag direkt im Username-Feld auf — User klickt einmal, Auth ist durch. Browser-Support seit Chrome 108, Safari 16. Implementierungs-Trick: mediation: 'conditional' bei navigator.credentials.get(), plus Input-Tag autocomplete="username webauthn".
rpID muss Origin-konsistent sein
rpID ist die Domain (ohne Schema/Port), an die das Credential gebunden wird. Default ist der aktuelle Host. Wenn die App auf app.example.com läuft, ist rpID="app.example.com" die strikte Wahl, rpID="example.com" erlaubt das Credential auf allen Subdomains. Strikte Variante ist sicherer; übergreifende Variante wenn explizit gewünscht.
signCount-Replay-Detection
Authenticator führt einen Counter pro Credential, der bei jeder Authentication hochgezählt wird. Server speichert den letzten Wert; wenn der Counter beim nächsten Login niedriger ist → potenzielles Replay oder Clone. Bei Synced Passkeys ist der Counter oft 0 (alle Geräte) — dann ist der Check deaktiviert. Bei device-bound: aktiv prüfen.
Passkey + Passwort gleichzeitig — Vorsicht beim Recovery-Pfad
Wer Passkey UND Passwort behält, hat den schwächeren als Recovery-Pfad. Wenn der Passkey phishing-resistent ist, aber das Passwort phishbar — Angreifer geht über den schwächeren Weg. Konsequente Pattern: Passwort entweder ganz löschen (passwortlos), oder zumindest als „nicht zum Login nutzbar, nur zum Account-Wiederfinden" markieren.
Cross-Device-Authentication (CDA) via QR-Code
Wer auf einem fremden Gerät einloggen will (ohne registriertem Passkey dort), kann CDA nutzen: Web zeigt QR-Code, User scannt mit Phone, Phone-Authenticator signiert, Bluetooth-Proximität-Check. Komplettiert das Cross-Device-Modell. Setup ist Browser/OS-seitig, App-Code braucht nichts Spezielles.
WebAuthn Level 3 ist 2024 finalisiert
Die W3C-Spec WebAuthn Level 3 ist 2024 als Recommendation veröffentlicht. Neuerungen u. a.: credProtect-Extension, largeBlob für Side-Data, verbesserte Conditional-UI. Library-Support folgt — @simplewebauthn/server v10+ deckt Level 3 ab. Stand 2026: Production-ready.
FIDO Metadata Service (MDS) für Attestation-Validation
Bei Attestation-Validierung im Enterprise-Setup: gegen die FIDO Metadata Service-Daten prüfen. Liefert Liste aller zertifizierten Authenticator-Modelle mit Attestation-Wurzeln. Tagliche Updates, JSON-Format. Libraries wie fido2-lib oder webauthn-server integrieren MDS direkt.
Weiterführende Ressourcen
Externe Quellen
- W3C — Web Authentication Level 3
- passkeys.dev — Implementation Guide
- FIDO Alliance
- web.dev — Passkey Registration
- web.dev — Conditional UI
- SimpleWebAuthn — Node.js Libraries
- py_webauthn (Python)
- go-webauthn (Go)
- FIDO Metadata Service