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.cc kann das Credential für bank.example nicht 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)

Plain webauthn-registration-flow.txt
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):

JavaScript webauthn-registration-node.js
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

Plain webauthn-authentication-flow.txt
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:

JavaScript webauthn-authentication-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:

TypWo lebt der Private KeyBeispieleUse Case
Synced PasskeysiCloud Keychain / Google Password Manager / 1Password — Cross-Device-SyncStandard für Consumer-AppsKomfort: kein „neues Device anmelden" nötig
Device-boundNur auf einem Gerät (Hardware-Key, Plattform-TPM, secure enclave)Hardware-Token (YubiKey), Enterprise-SetupsHö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:

TypWas es ist
NoneKeine Attestation — Authenticator verrät nichts über sich
SelfAuthenticator signiert mit eigenem Schlüssel — kein externer Trust
BasicSigniert mit Hersteller-Zertifikat (Yubico-Wurzel etc.)
AttCAMit Attestation-CA-Zertifikat
AnonCAAnonymisierte 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.

JavaScript multiple-passkeys-pattern.js
// 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):

  1. Synced Passkeys — Cloud-Sync (iCloud/Google) bedeutet: neues Gerät, gleicher Cloud-Account → Passkeys da. Verlagert das Recovery-Problem in den Cloud-Provider.

  2. Recovery-Codes — beim ersten Setup einmalige Recovery-Codes generieren, User speichert sie offline. Bei Verlust: ein Code einlösbar, neue Passkeys anmelden.

  3. E-Mail-basiertes Recovery — Magic Link zum Reset. Sicherheit hängt vom Mail-Account ab.

  4. Account-Wiederherstellung über Support — manuelle Identitäts-Prüfung (ID-Dokument hochladen, Video-Call). Für sensitive Apps (Banking) Standard, aber teuer.

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

/ Weiter

Zurück zu Authentifizierung (Entwickler)

Zur Übersicht