Zum Inhalt springen

Passkeys implementieren: Ein praktischer WebAuthn-Leitfaden

Praktischer Leitfaden zu Passkeys mit WebAuthn: von der Registrierungs-Ceremony bis zur serverseitigen Verifizierung.

5 Min. Lesezeit
Diagramm des WebAuthn-Passkey-Registrierungs- und Authentifizierungsablaufs zwischen Browser und Server

Passwörter sind das schwächste Glied in den meisten Authentifizierungssystemen, und das weiß mittlerweile jeder. Nutzer verwenden sie mehrfach, Phishing-Kits sammeln sie ein, und Breach-Datenbanken leaken sie milliardenfach. Passkeys, basierend auf der WebAuthn-Spezifikation, lösen das Problem, indem sie geteilte Geheimnisse komplett aus der Gleichung entfernen. Der Browser und ein Authenticator (ein Smartphone, ein Security-Key oder der OS-Schlüsselbund) halten einen privaten Schlüssel, der das Gerät nie verlässt — der Server bekommt nur je einen öffentlichen Schlüssel und eine Signatur zu sehen.

Die Theorie ist mittlerweile gut verstanden. Woran Teams scheitern, ist die Implementierung: Challenge-Management, Origin-Validierung, Credential-Speicherung und der UX-Fallback, wenn Browser oder Gerät des Nutzers den Flow nicht sauber unterstützen. Dieser Beitrag geht eine produktionsreife Implementierung durch, keine Spielzeug-Demo.

Wie die Ceremony tatsächlich funktioniert

WebAuthn definiert zwei "Ceremonies": Registrierung und Authentifizierung. Beide sind Challenge-Response-Protokolle, vermittelt durch die Web Authentication API des Browsers.

Bei der Registrierung generiert der Server eine zufällige Challenge, der Browser bittet den Authenticator, ein neues Schlüsselpaar zu erzeugen, das an deine Relying-Party-ID (rpId, in der Regel deine Domain) gebunden ist, und der Authenticator liefert einen öffentlichen Schlüssel plus ein Attestation-Objekt zurück. Bei der Authentifizierung stellt der Server eine neue Challenge aus, der Authenticator signiert sie mit dem gespeicherten privaten Schlüssel, und der Server verifiziert die Signatur gegen den zuvor gespeicherten öffentlichen Schlüssel.

texttext
Registration:  server --challenge--> browser --> authenticator
               authenticator --public key + attestation--> server (stored)
 
Authentication: server --challenge--> browser --> authenticator
                authenticator --signature--> server (verified against stored key)

Niemals überquert etwas, das einem Passwort ähnelt, das Netzwerk. Die Challenge verhindert Replay-Angriffe, und die Signatur beweist den Besitz des privaten Schlüssels, ohne ihn preiszugeben.

Ein Credential client-seitig registrieren

Die Browser-API ist bewusst low-level — sie arbeitet mit ArrayBuffers, nicht mit JSON — deshalb kapseln die meisten Teams sie. Hier der Kernaufruf, ohne Helper-Bibliothek:

jsjavascript
// client/register.js
async function registerPasskey(username) {
  const optionsResponse = await fetch("/api/webauthn/register/options", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ username }),
  });
  const options = await optionsResponse.json();
 
  // Challenge and user.id arrive as base64url strings over JSON;
  // the browser API needs raw ArrayBuffers.
  options.challenge = base64urlToBuffer(options.challenge);
  options.user.id = base64urlToBuffer(options.user.id);
 
  const credential = await navigator.credentials.create({
    publicKey: options,
  });
 
  await fetch("/api/webauthn/register/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(serializeCredential(credential)),
  });
}

Der interessante Teil spielt sich fast vollständig serverseitig ab: sinnvolle Registrierungsoptionen generieren und die Response korrekt verifizieren.

Registrierungsoptionen generieren

Ein häufiger Fehler ist, die Challenge oder rpId als statische Konfiguration statt als Request-spezifischen State zu behandeln, der bei der Rückkehr validiert werden muss.

tstypescript
// server/webauthn/options.ts
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { randomBytes } from "node:crypto";
 
export async function buildRegistrationOptions(userId: string, username: string) {
  const existingCredentials = await credentialRepo.findByUserId(userId);
 
  const options = await generateRegistrationOptions({
    rpName: "Acme Inc",
    rpID: "acme.com", // must exactly match the origin's registrable domain
    userID: Buffer.from(userId),
    userName: username,
    attestationType: "none", // "none" is enough for most apps; skip vendor lock-in
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    })),
    authenticatorSelection: {
      residentKey: "required",      // discoverable credential, enables usernameless login
      userVerification: "preferred",
    },
  });
 
  // Store the challenge server-side (Redis, session store), never trust
  // a challenge the client claims to have received.
  await challengeStore.save(userId, options.challenge, { ttlSeconds: 300 });
 
  return options;
}

residentKey: "required" ist es, was aus dem Credential einen echten Passkey macht statt nur einen gewöhnlichen Security-Key — es ist discoverable, das heißt der Browser kann es finden, ohne dass der Server überhaupt zuerst einen Benutzernamen senden muss.

Verifizierung beim Rückweg — hier liegt das eigentliche Risiko

Das ist der Schritt, bei dem Leute Fehler machen. Wenn du Origin- oder RP-ID-Validierung überspringst, hast du etwas gebaut, das aussieht wie WebAuthn, dir aber nicht dessen Garantien gibt.

tstypescript
// server/webauthn/verify.ts
import { verifyRegistrationResponse } from "@simplewebauthn/server";
 
export async function verifyRegistration(userId: string, body: unknown) {
  const expectedChallenge = await challengeStore.consume(userId);
  if (!expectedChallenge) {
    throw new Error("Challenge expired or already used");
  }
 
  // ❌ Bad: trusting whatever origin the client claims
  // const verification = await verifyRegistrationResponse({
  //   response: body,
  //   expectedOrigin: body.origin,
  // });
 
  // ✅ Good: origin and RP ID are fixed server-side constants
  const verification = await verifyRegistrationResponse({
    response: body as RegistrationResponseJSON,
    expectedChallenge,
    expectedOrigin: "https://acme.com",
    expectedRPID: "acme.com",
    requireUserVerification: true,
  });
 
  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("Registration verification failed");
  }
 
  const { credentialID, credentialPublicKey, counter } =
    verification.registrationInfo;
 
  await credentialRepo.save({
    userId,
    credentialId: credentialID,
    publicKey: credentialPublicKey,
    signCount: counter,
    transports: body.response.transports ?? [],
  });
}

Das Feld counter ist wichtiger, als es aussieht. Authenticators erhöhen bei jeder Nutzung einen Signaturzähler; wenn ein künftiger Login einen Zähler präsentiert, der niedriger oder gleich dem gespeicherten Wert ist, ist das ein starkes Signal für einen geklonten Authenticator, und der Login sollte abgelehnt werden.

Login ohne Benutzernamen

Das discoverable Credential, das du oben erstellt hast, erlaubt es, das Benutzernamen-Feld komplett wegzulassen — der Browser fordert den Nutzer auf, ein Konto aus den für deine Domain vorhandenen Passkeys auszuwählen.

jsjavascript
// client/login.js
async function loginWithPasskey() {
  const optionsResponse = await fetch("/api/webauthn/login/options");
  const options = await optionsResponse.json();
  options.challenge = base64urlToBuffer(options.challenge);
 
  const assertion = await navigator.credentials.get({
    publicKey: options,
    mediation: "optional", // or "conditional" for autofill-style UI
  });
 
  const result = await fetch("/api/webauthn/login/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(serializeAssertion(assertion)),
  });
 
  if (!result.ok) throw new Error("Login failed");
  return result.json();
}

mediation: "conditional" verdient besondere Erwähnung — in Kombination mit einem autocomplete="webauthn"-Attribut auf einem Benutzernamen-Feld treibt es die "Autofill-Passkey"-UI an, die Chrome und Safari ohne zusätzlichen Button anzeigen.

Plattform-Support braucht trotzdem einen Fallback-Pfad

!

Nicht jeder Nutzer hat einen kompatiblen Authenticator oder Browser, und unternehmensverwaltete Geräte deaktivieren Plattform-Authenticators manchmal komplett. Passkeys sollten eine Ergänzung sein, kein harter Ersatz, bis die Adoption näher an universell liegt.

SzenarioEmpfohlener Fallback
Kein Plattform-AuthenticatorCross-Device-Flow (QR + Bluetooth)
Browser unterstützt WebAuthn nichtMagic Link oder TOTP
Nutzer verliert alle registrierten GeräteIdentitätsgeprüfter Account-Recovery-Flow

Baue Magic-Link- oder Passwort-Fallbacks nicht als nachträglichen Anbau nach dem Launch — entwirf das Account-Modell von Anfang an so, dass es mehrere Credential-Typen pro Nutzer unterstützt, mit Passkeys als bevorzugter Option mit dem höchsten Vertrauen.

Die wichtigsten Punkte

  1. Behandle die Challenge als serverseitigen Session-State mit kurzer TTL — vertraue niemals einem Challenge-Wert, den der Client zurücksendet.
  2. Pinne expectedOrigin und expectedRPID auf hartkodierte Werte; lies sie niemals aus dem Request-Body.
  3. Nutze residentKey: "required", um echte discoverable Passkeys zu bekommen und benutzernamenloses, Autofill-gesteuertes Login zu ermöglichen.
  4. Tracke und validiere den Signaturzähler bei jeder Authentifizierung, um geklonte Authenticators zu erkennen.
  5. Liefere immer einen Fallback ohne Passkey aus — Account-Recovery und Cross-Browser-Support sind 2026 keine Option mehr, sondern Pflicht.
  6. Speichere von Anfang an mehrere Credentials pro Nutzer; "ein Passkey pro Account" ist eine Design-Falle, die du später mühsam auflösen musst.

Passkeys eliminieren eine ganze Klasse von Credential-Diebstahl-Angriffen — aber nur, wenn die serverseitige Verifizierung so rigoros ist, wie die Spezifikation es vorsieht. Die Passkey-Dokumentation der FIDO Alliance lohnt sich, von Anfang bis Ende zu lesen, bevor du das in Produktion bringst — die Fehlermodi sind subtil, und genau diese Art von Dingen sieht in der Demo gut aus und bricht unter einem echten Angreifer zusammen.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX