Passkeys für SaaS: Migration von Passwörtern zu WebAuthn ohne Nutzer zu verlieren

#Passkey Implementierung SaaS
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Das erste Mal, dass ein B2B-Prospect-Sicherheitsfragebogen fragt, ob euer SaaS Passkeys unterstützt, und es als Procurement-Blocker einstuft, hört das Gespräch über WebAuthn auf, theoretisch zu sein. Bis Ende 2025 behandelten die meisten unserer Kunden Passkeys als Nice-to-have, geparkt hinter einem "vielleicht Q3"-Tag im Roadmap. 2026 hat sich die Rechnung geändert: Enterprise-Käufer fragen nach FIDO2 im gleichen Atemzug wie nach SAML SSO, Regulatoren im Finanzbereich verweisen darauf als Baseline-Kontrolle, und Apple, Google und Microsoft haben genug von der Cross-Device-Sync-Lücke geschlossen, dass normale Nutzer nicht mehr auf halbem Weg der Registrierung steckenbleiben.

Der schwierige Teil einer Passkey-Implementierung in einer SaaS-Anwendung ist nicht die Kryptografie. Die WebAuthn-Bibliotheken erledigen die schwere Arbeit und die Spezifikation ist endlich stabil. Der schwierige Teil ist die Migration einer installierten Basis von Passwort-Nutzern zu einem grundlegend anderen mentalen Modell, ohne jemanden auszusperren, die Unterstützung beider Zeremonien auf jeder Gerätekombination, die Kunden tatsächlich besitzen, und Account-Recovery richtig hinzubekommen. Dieser Beitrag ist das Playbook, das wir verwenden, wenn ein Symfony + Next.js SaaS-Team zu uns kommt und nach einer Passkey-Implementierung fragt, die Produktionsnutzer tatsächlich annehmen.

Was "Passkey" in deinem Code wirklich bedeutet

Ein Passkey ist ein auffindba­rer WebAuthn-Credential, der häufig über die Cloud eines Anbieters synchronisiert wird - iCloud Keychain, Google Password Manager, Windows Hello mit Microsoft-Konto, 1Password, Bitwarden. Aus der Perspektive deiner Anwendung ist der Credential selbst ein öffentlicher Schlüssel, der an deine Relying Party ID (deine Domain) gebunden ist, eine Credential-ID, ein optionales User-Handle und ein Signature-Counter. Du siehst den privaten Schlüssel nie. Du siehst die Biometrie des Nutzers nie. Die WebAuthn-API des Browsers vermittelt die gesamte Zeremonie.

Zwei Zeremonien sind relevant. Registrierung (navigator.credentials.create) generiert einen neuen Credential am Authenticator, gibt dir den öffentlichen Schlüssel plus eine Attestation zurück, und du speicherst ihn dem Nutzer gegenüber. Authentifizierung (navigator.credentials.get) fordert einen bestehenden Authenticator auf, eine vom Server ausgestellte Challenge zu signieren, und du verifizierst die Signatur gegen den gespeicherten öffentlichen Schlüssel. Beide Zeremonien werden vom Server geleitet - dein Backend stellt die Challenge aus, der Authenticator signiert sie, dein Backend verifiziert. Alles, was die Challenge im Client-Code ohne einen Server-Round-Trip berührt, ist designbedingt kaputt.

Zwei Begriffe stolpern Teams, die neu in der Spezifikation sind. Conditional UI ist die Autofill-ähnliche Passkey-Aufforderung, die erscheint, wenn ein Nutzer ein leeres Benutzernamefeld auf einer Anmeldeseite fokussiert. Hybrid Transport ist der QR-Code-Tanz, der einem Desktop-Browser ermöglicht, den Authenticator eines Telefons über Bluetooth und einen CTAP2-Tunnel zu nutzen. Du wirst beides brauchen.

Die Symfony-Seite: Storage, Zeremonien und die Bibliothekswahl

Für PHP-Backends ist das web-auth/webauthn-lib-Paket (Spomky-Labs, jetzt FIDO Alliance-ausgerichtet) die einzige Bibliothek, die reif genug ist, in Produktion zu laufen. Sie spricht die vollständige FIDO2-Spezifikation, funktioniert gut mit Symfonys Security-Komponente, und bekommt zeitnahe Fixes, wenn ein Browser ein Verhaltensänderung einführt.

Dein Domain-Modell braucht drei kleine Ergänzungen neben deiner bestehenden User-Entität:

#[ORM\Entity]
class PasskeyCredential
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private int $id;

    #[ORM\ManyToOne(targetEntity: User::class)]
    private User $user;

    #[ORM\Column(length: 512, unique: true)]
    private string $credentialId; // base64url

    #[ORM\Column(type: 'json')]
    private array $publicKey;

    #[ORM\Column]
    private int $signatureCounter = 0;

    #[ORM\Column(length: 64, nullable: true)]
    private ?string $aaguid = null; // hilft, Authenticator-Familien zu erkennen

    #[ORM\Column]
    private \DateTimeImmutable $createdAt;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $lastUsedAt = null;
}

Zwei nicht offensichtliche Felder sind ihr Geld wert. Der signatureCounter ist dein einziger Schutz gegen Credential-Kloning für nicht-synchronisierte Authenticatoren - inkrementiere ihn bei jeder erfolgreichen Anmeldung und lehne Signaturen ab, die unterhalb des gespeicherten Counters kommen. Die aaguid ermöglicht es dir, Credentials in der UI als "iCloud Keychain", "Google Password Manager" oder "YubiKey 5C" zu kennzeichnen, was Support-Tickets dramatisch reduziert, wenn Nutzer vergessen, auf welchem Gerät sie sich registriert haben.

Ein Registrierungs-Controller sieht ungefähr so aus:

#[Route('/api/passkeys/register/options', methods: ['POST'])]
public function options(#[CurrentUser] User $user): JsonResponse
{
    $existing = array_map(
        fn(PasskeyCredential $c) => PublicKeyCredentialDescriptor::create(
            'public-key', base64_decode($c->getCredentialId())
        ),
        $this->repo->findBy(['user' => $user])
    );

    $options = $this->ceremonyFactory->createCreationOptions(
        rp: $this->relyingParty,
        user: PublicKeyCredentialUserEntity::create(
            $user->getEmail(), (string) $user->getId(), $user->getDisplayName()
        ),
        challenge: random_bytes(32),
        excludeCredentials: $existing,
        authenticatorSelection: AuthenticatorSelectionCriteria::create(
            residentKey: 'required',
            userVerification: 'preferred',
        ),
    );

    $this->session->set('webauthn_challenge', $options->challenge);
    return new JsonResponse($options);
}

Der excludeCredentials-Parameter verhindert, dass ein Nutzer versehentlich denselben Authenticator zweimal registriert. residentKey: 'required' ist das, was diesen Credential zu einem echten Passkey macht - er liegt auf dem Authenticator und ist auffindbar, ohne dass du eine Liste von Credential-IDs schicken musst. Das ist relevant für Conditional UI.

Verifikation ist ein Bibliotheksaufruf:

$publicKeyCredential = $this->loader->loadArray($request->toArray());
$this->ceremonyVerifier->check(
    $publicKeyCredential->response,
    $options,
    $request->headers->get('Origin'),
);

Credential persistieren, Erfolg zurückgeben, dem Nutzer eine Bestätigungs-E-Mail schicken, damit ein Angreifer mit gestohlener Session nicht heimlich einen Credential hinzufügen kann.

Die Next.js-Seite: SimpleWebAuthn und Conditional UI

Auf dem Client setzen wir auf @simplewebauthn/browser. Sie ist klein, framework-agnostisch und trackt Browser-Quirks schnell. In einer Next.js 15 App Router-App ist der Registrierungsflow eine Server-Action gefolgt von einer browserseitigen Zeremonie:

'use client';
import { startRegistration } from '@simplewebauthn/browser';

export function AddPasskeyButton() {
  async function handleAdd() {
    const opts = await fetch('/api/passkeys/register/options', { method: 'POST' })
      .then(r => r.json());
    const attestation = await startRegistration({ optionsJSON: opts });
    const result = await fetch('/api/passkeys/register/verify', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(attestation),
    });
    if (!result.ok) throw new Error('Passkey-Registrierung fehlgeschlagen');
  }
  return <button onClick={handleAdd}>Passkey hinzufügen</button>;
}

Für die Anmeldung ist Conditional UI der Gewinn. Statt Nutzer aufzufordern, auf einen "Mit Passkey anmelden"-Button zu klicken, kabelst du autocomplete="username webauthn" auf das E-Mail-Feld und rufst startAuthentication({ useBrowserAutofill: true }) beim Mounten auf. Der Passkey erscheint in den Autofill-Vorschlägen; ein Tippen und der Nutzer ist drin. Wir haben gemessen, dass 30 bis 50 Prozent der wiederkehrenden Nutzer die Anmeldung auf diese Weise abschließen, sobald es freigegeben ist.

Wichtiges Detail: Vergiss nicht die Feature-Erkennung. Etwa einer von fünfzehn Sessions trifft noch auf eine Browser-Kombination, bei der Conditional UI nicht unterstützt wird, und der Autofill-Aufruf wirft. Kapsele es, falle auf das Passwort-Formular zurück, protokolliere den Fehler mit einem User-Agent-Tag.

Migration einer bestehenden Nutzerbasis ohne Unterbrechungen

Hier gehen die meisten Passkey-Rollouts schief, und hier sind die Engineering-Entscheidungen wichtiger als die Bibliothekswahl.

Mache Passkeys für mindestens ein volles Quartal additiv, nicht als Ersatz. Jeder Nutzer behält sein Passwort. Nach einer erfolgreichen Passwort-Anmeldung zeige ein einseitiges Interstitial: "Überspringe das Passwort beim nächsten Mal - füge einen Passkey von diesem Gerät hinzu." Tracke, wer annimmt und wer ablehnt. Blockiere die Anmeldung nicht damit. Das Interstitial ist das, was den größten Teil deiner Adoptionskurve antreibt.

Halte das Passwort-Formular auf der Anmeldeseite auffindbar, bis die Passkey-Adoption deutlich über 50 Prozent liegt. Ein "Stattdessen Passwort verwenden"-Link unter der Conditional UI-Aufforderung reicht. Nutzer auf einem brandneuen Gerät ohne synchronisierten Passkey werden ihn brauchen.

Biete Passkeys als Zweifaktor an, bevor du sie als passwortlos anbietest. Ein Nutzer, der einen Passkey "für schnellere Anmeldung" registriert und dabei sein Passwort behält, wird zu einem 2FA-Nutzer, ohne es zu merken. Das ist ein einstiegsschwacher Eintrittspunkt. Sobald der Nutzer sowohl einen Passkey als auch ein Passwort hat und den Passkey drei- oder viermal verwendet hat, fordere ihn auf, das Passwort zu entfernen - aber mache das Entfernen zu einer bewussten Handlung mit Bestätigung, niemals automatisch.

Account-Recovery ist das schwierigste Problem der Migration, nicht ihr letztes. Ein Nutzer mit einem Passkey auf einem einzigen nicht-synchronisierten Gerät, der dieses Gerät verliert, ist ausgesperrt. Du brauchst einen Recovery-Pfad, der vor dem Launch geplant ist. Das Muster, das standhält: Bei der Passkey-Registrierung einen Einmal-Recovery-Code generieren, den Nutzer zwingen, ihn aufzuzeichnen, und seinen Hash speichern. Bei der Recovery den Code verifizieren, dann eine hochreibungsarme Identitätsverifikation durchführen. Verwende kein SMS. Ein naiver Recovery-Flow wird zur neuen Angriffsfläche.

Die Grenzfälle, die Produktionssysteme still brechen

Eine kurze Liste von Dingen, die uns - bei Kunden und in unseren eigenen Systemen - auf die Füße gefallen sind:

Ein Safari-Nutzer auf iOS 17 registriert einen Passkey, dann aktualisiert auf iOS 18 - der Credential überlebt. Derselbe Nutzer löscht das Gerät und stellt von einem anderen iCloud-Konto wieder her - der Credential ist weg. Plane dafür. Zeige die Passkey-Liste mit einem "Zuletzt verwendet"-Zeitstempel und einer "Entfernen"-Aktion an.

Ein Chrome-Nutzer auf Windows registriert einen Windows Hello-Credential, meldet sich dann vom gleichen Browser auf einem Unternehmens-VDI ohne Windows Hello an. Der Credential ist unsichtbar. Der Fix ist userVerification: 'preferred' statt 'required', und die Unterstützung von Hybrid Transport.

Ein Nutzer mit Bitwarden-Browser-Extension und einem synchronisierten iCloud-Passkey für dasselbe Konto bekommt zwei Aufforderungen, wählt die falsche, und registriert ein Duplikat. Ohne excludeCredentials gelingt die zweite Registrierung. Gib immer die bestehende Liste mit.

Der Signature-Counter-Check ist eine Fußfalle für synchronisierte Passkeys, bei denen der Counter typischerweise über den Cluster hinweg bei null bleibt. Deine Verifikationslogik muss counter=0 von synchronisierten Authenticatoren akzeptieren, während sie die Monotonizität für Hardware-Authenticatoren noch erzwingt.

Wie das in einem echten Engagement aussieht

Wenn wir einen Passkey-Rollout für ein Symfony- und Next.js-SaaS durchführen, teilt sich die Arbeit in ungefähr drei Wochen auf: eine für die Zeremonien-Infrastruktur und das Entitätsmodell, eine für die UI-Flows einschließlich Conditional UI und Recovery-Pfad, und eine für das Migrations-Scaffolding - die additiven Interstitials, das Support-Tooling für das Credential-Management, das Metrics-Dashboard. Die Bibliotheksarbeit ist klein. Die Produktarbeit drum herum entscheidet darüber, ob die Adoptionskurve bei fünf Prozent abflacht oder durch vierzig klettert.

Wenn dein Team einen Enterprise-Sicherheitsfragebogen vor sich hat, der gerade Passkeys in die Must-have-Spalte gesetzt hat, oder wenn du eine audit-getriebene Deadline hast, Passwörter als primären Faktor zu entfernen, ist das eine Art Engagement, bei dem ein fokussiertes externes Team sich schnell bezahlt macht. Wolf-Tech führt Passkey-Migrationen als Teil unserer Code-Quality-Consulting- und Custom-Software-Development-Arbeit durch.

Kontaktiere uns unter hello@wolf-tech.io oder besuche wolf-tech.io.