Zum Inhalt springen
ecdsa.com
← Alle Artikel

Passkeys für Entwickler: WebAuthn ohne Rätselraten

Ein Passkey ist ein WebAuthn-Credential, mit dem sich der Nutzer anmelden kann, ohne Benutzernamen oder Passwort einzutippen. Die Browser-API ist klein, die Entscheidungen drumherum sind es nicht. Hier findest du beide Zeremonien von Anfang bis Ende, was der Server prüfen muss, und die genauen Fehler, die Chromium wirft – alles aus einem Lauf gegen einen virtuellen Authenticator.

Kurz gesagt. Ein Passkey ist ein Discoverable Credential von WebAuthn: ein Schlüsselpaar, das ein Authenticator (ein Smartphone, die Sicherheitshardware eines Laptops, ein Passwortmanager oder ein Sicherheitsschlüssel) für genau eine Website erzeugt, die Relying Party. Bei der Registrierung erhält der Server einen öffentlichen Schlüssel, bei der Anmeldung eine Signatur über eine Challenge des Servers. Der Server muss bei jeder Antwort die Challenge, den Origin, die RP-ID und das Flag der Benutzerverifizierung prüfen. Fordere residentKey: "required" und userVerification: "required" an, biete -8, -7 und -257 als Algorithmen an, lass die Attestierung auf "none", es sei denn, du bist ein Unternehmen, das Authenticator-Modelle einschränkt, und rechne damit, dass die meisten Passkeys zwischen Geräten synchronisiert werden.

Das Modell in einer Tabelle

BegriffWas er für deinen Code bedeutet
Relying Party (RP) und RP-IDDeine Website. Die RP-ID ist eine Domain wie example.com; Credentials funktionieren auf dieser Domain und ihren Subdomains, nirgends sonst
Discoverable CredentialWird zusammen mit dem User Handle auf dem Authenticator gespeichert, daher braucht die Anmeldung keinen Benutzernamen. WebAuthn Level 3 nennt „Passkey“ als weiteren Namen dafür
User Handle (user.id)Deine opake Konto-ID, höchstens 64 Byte, die bei der Anmeldung zurückkommt. Nie eine E-Mail-Adresse
Benutzeranwesenheit (UP) und Benutzerverifizierung (UV)UP: Jemand hat den Authenticator berührt oder die Anfrage bestätigt. UV: Der Authenticator hat eine PIN oder ein biometrisches Merkmal geprüft. Flags in den Authenticator-Daten
Backup-fähig (BE) und gesichert (BS)Ein Credential, das auf andere Geräte synchronisiert werden kann oder bereits synchronisiert ist: ein synchronisierter Passkey, in der Spezifikation „Multi-Device Credential“
Attestierung und AAGUIDEine optionale Zertifikatskette, die angibt, welches Authenticator-Modell den Schlüssel erzeugt hat; die AAGUID identifiziert das Modell

Die Flags der Authenticator-Daten sind Bit 0 UP, Bit 2 UV, Bit 3 BE, Bit 4 BS, Bit 6 AT (attestierte Credential-Daten enthalten) und Bit 7 ED (Erweiterungen) (WebAuthn Level 3 §6.1). WebAuthn Level 3 wurde am 25. August 2026 zur W3C Recommendation.

Server: beide Zeremonien

Das hier ist eine vollständige Relying Party mit @simplewebauthn/server 14.0.3. Damit es kurz bleibt, liegen die Daten im Arbeitsspeicher; in Produktion sind die drei Maps Datenbanktabellen, und die Challenge liegt in der serverseitigen Sitzung des Nutzers.

server.mjs (@simplewebauthn/server 14.0.3)
// Relying party: registration and sign-in ceremonies with @simplewebauthn/server 14.0.3.
import {
  generateAuthenticationOptions,
  generateRegistrationOptions,
  verifyAuthenticationResponse,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";

const rpID = "localhost"; // the site's registrable domain in production, e.g. "example.com"
const origin = "http://localhost:3000"; // every origin allowed to use this RP ID
const users = new Map(); // userName -> { id, credentials: [] }
const credentials = new Map(); // credential ID -> { userName, publicKey, counter, transports }
const pending = new Map(); // ceremony ID -> expected challenge, single use

export async function registrationOptions(userName, { attestation = "none" } = {}) {
  const user = users.get(userName) ?? { id: crypto.getRandomValues(new Uint8Array(16)), credentials: [] };
  users.set(userName, user);
  const options = await generateRegistrationOptions({
    rpName: "Example",
    rpID,
    userName,
    userID: user.id,
    attestationType: attestation,
    authenticatorSelection: { residentKey: "required", userVerification: "required" },
    supportedAlgorithmIDs: [-8, -7, -257], // EdDSA, ES256, RS256
    excludeCredentials: user.credentials.map((id) => ({ id, transports: credentials.get(id).transports })),
  });
  pending.set(`reg:${userName}`, options.challenge);
  return options;
}

export async function finishRegistration(userName, response) {
  const expectedChallenge = pending.get(`reg:${userName}`);
  pending.delete(`reg:${userName}`);
  const { verified, registrationInfo: info } = await verifyRegistrationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    requireUserVerification: true,
  });
  if (!verified) throw new Error("registration not verified");
  const { credential } = info;
  credentials.set(credential.id, { userName, publicKey: credential.publicKey, counter: credential.counter, transports: credential.transports });
  users.get(userName).credentials.push(credential.id);
  return info;
}

export async function authenticationOptions() {
  // Empty allowCredentials: the authenticator offers its discoverable credentials (passkeys).
  const options = await generateAuthenticationOptions({ rpID, userVerification: "required" });
  pending.set(`auth:${options.challenge}`, options.challenge);
  return options;
}

export async function finishAuthentication(response, challenge) {
  if (!pending.delete(`auth:${challenge}`)) throw new Error("unknown or used challenge");
  const stored = credentials.get(response.id);
  if (!stored) throw new Error("unknown credential");
  const { verified, authenticationInfo } = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: { id: response.id, publicKey: stored.publicKey, counter: stored.counter, transports: stored.transports },
    requireUserVerification: true,
  });
  if (!verified) throw new Error("authentication not verified");
  stored.counter = authenticationInfo.newCounter;
  return { userName: stored.userName, ...authenticationInfo };
}

Was die Verify-Funktionen prüfen und warum das jeweils wichtig ist:

  • Challenge. Zufällig, nur einmal verwendbar, an diese Zeremonie gebunden. Eine Antwort mit einer alten Challenge ist ein Replay.
  • Origin. Der Browser schreibt den Origin der Seite in clientDataJSON, und der Authenticator signiert einen Hash davon. Erst der Vergleich mit deiner Liste von Origins macht Passkeys Phishing-resistent: Eine täuschend ähnliche Website erzeugt eine Antwort mit ihrem eigenen Origin.
  • Hash der RP-ID. Die Authenticator-Daten beginnen mit dem SHA-256-Hash der RP-ID; ein Credential für eine andere Website lässt sich für deine nicht verwenden.
  • Flags. UP muss gesetzt sein; UV muss gesetzt sein, wenn du dich allein auf den Passkey verlässt – und genau darum geht es bei einem Passkey.
  • Signatur über die Authenticator-Daten und den Hash der Client-Daten, geprüft mit dem bei der Registrierung gespeicherten öffentlichen Schlüssel.
  • Zähler. Viele synchronisierte Passkeys melden immer 0. WebAuthn wertet einen sinkenden Zähler nur dann als Hinweis auf einen möglichen Klon, wenn ein Wert ungleich null beteiligt ist (§6.1.1); lehne den Wert null nie ab.

Browser: zwei Aufrufe

browser.js
// Browser side of both ceremonies. JSON options from the server need their base64url
// fields turned into ArrayBuffers; the static helpers on PublicKeyCredential do that.
async function register(optionsJSON) {
  const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON);
  const credential = await navigator.credentials.create({ publicKey });
  return credential.toJSON(); // send to the server as-is
}

async function signIn(optionsJSON) {
  const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON);
  const credential = await navigator.credentials.get({ publicKey });
  return credential.toJSON();
}

parseCreationOptionsFromJSON, parseRequestOptionsFromJSON und toJSON gehören zu WebAuthn Level 3 und ersetzen den Umwandlungscode für base64url, den bisher jedes Projekt mit sich herumtrug. Für ein Benutzernamensfeld, das Passkeys im Autofill des Browsers anbietet, starte navigator.credentials.get() beim Laden der Seite mit mediation: "conditional", nachdem du PublicKeyCredential.isConditionalMediationAvailable() geprüft hast, und markiere das Eingabefeld mit autocomplete="username webauthn".

Der Lauf von Anfang bis Ende

Der Testaufbau steuerte Chromium mit Playwright 1.64.1 (mcr.microsoft.com/playwright:v1.64.1-noble) und einem virtuellen Authenticator, der über das Chrome DevTools Protocol hinzugefügt wurde. Die Seite auf http://localhost:3000 führte browser.js aus; der Treiber reichte die Optionen aus server.mjs hinein und die Antworten wieder heraus. Jede Zeile stammt aus einer echten Registrierung oder Anmeldung:

Ausgabe (Chromium 156.0.8078.4, Node.js im Playwright-Image)
chromium 156.0.8078.4, virtual platform authenticator with user verification
registration options: challenge 43 chars, pubKeyCredParams -8, -7, -257, attestation none
registered: fmt none, COSE alg -8, transports internal, credentialDeviceType singleDevice, backedUp false, userVerified true, aaguid 01020304-0506-0708-0102-030405060708
second registration on the same authenticator: InvalidStateError: The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.
signed in as alice: userVerified true, newCounter 2, credentialBackedUp false
replayed assertion: unknown or used challenge
response created on http://localhost:4000: Unexpected registration response origin "http://localhost:4000", expected "http://localhost:3000"
synced passkey, attestation "direct": fmt packed, credentialDeviceType multiDevice, backedUp true
sign-in when the authenticator cannot verify the user: NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.

Was jede Zeile zeigt:

  • Algorithmen. Die Optionen boten -8, -7 und -257 an; der Authenticator wählte -8 (EdDSA), den ersten, den er unterstützt. Die Reihenfolge drückt die Präferenz aus.
  • excludeCredentials funktioniert, und zwar mit dem InvalidStateError, der deiner Oberfläche mitteilt: „Dieses Gerät hat schon einen Passkey für dieses Konto.“
  • Anmeldung ohne Benutzernamen. allowCredentials war leer; der Authenticator bot sein Discoverable Credential an, und der Server fand das Konto über die Credential-ID und den dazu gespeicherten öffentlichen Schlüssel.
  • Replays scheitern an der Challenge. Dass die Challenge bei ihrer Verwendung gelöscht wird, lässt den zweiten Versuch scheitern.
  • Der Browser blockiert nicht jeden falschen Origin. Eine Seite auf http://localhost:4000 konnte ein Credential für die RP-ID localhost erzeugen, weil die RP-ID jeden Port und jede Subdomain dieser Domain abdeckt. Erst die Origin-Prüfung des Servers wies es zurück: Unexpected registration response origin. Gib expectedOrigin als exakte Liste an.
  • Synchronisierte Passkeys geben sich zu erkennen. Bei eingeschalteter Backup-Fähigkeit meldete SimpleWebAuthn multiDevice und backedUp true.
  • Die geforderte UV setzt der Browser durch. Mit einem Authenticator, der den Nutzer nicht verifizieren konnte, lehnte der Browser mit dem allgemeinen NotAllowedError ab; das serverseitige requireUserVerification ist die zweite Verteidigungslinie.

Passkeys neben Passwörtern einführen

Nur wenige Websites können alle Konten auf einmal umstellen. WebAuthn Level 3 ergänzt die Bausteine für eine schrittweise Einführung, die sich alle über PublicKeyCredential.getClientCapabilities() abfragen lassen:

  • Biete direkt nach einer Anmeldung mit Passwort einen Passkey an. Mit der Fähigkeit conditionalCreate lässt navigator.credentials.create() mit mediation: "conditional" den Passwortmanager, der gerade das Passwort ausgefüllt hat, ohne zusätzlichen Dialog einen Passkey erzeugen.
  • Anmeldung über das Benutzernamensfeld. Die Fähigkeit conditionalGet ist der oben beschriebene Autofill-Ablauf: Wer einen Passkey hat, wählt ihn aus, alle anderen tippen wie bisher ein Passwort ein.
  • Halte die Liste des Authenticators mit deiner synchron. Wenn ein Nutzer in deinen Kontoeinstellungen einen Passkey löscht, zeigt der Authenticator ihn weiterhin an. signalUnknownCredential() teilt ihm mit, dass eine Credential-ID nicht mehr akzeptiert wird, signalAllAcceptedCredentials() schickt die vollständige Liste für einen angemeldeten Nutzer, und signalCurrentUserDetails() aktualisiert den angezeigten Namen, nachdem ein Nutzer sein Konto umbenannt hat (§5.1.10).
  • Mehrere Domains, eine RP-ID. Eine Marke mit Anmeldung auf example.com und example.co.uk kann bei einer RP-ID bleiben und die anderen Origins in https://example.com/.well-known/webauthn als {"origins": [...]} auflisten. Der Browser ruft diese Datei ab, wenn die RP-ID nicht die eigene Domain der Seite ist (Related Origin Requests, §5.11); schlägt der Abruf fehl, ist das Ergebnis der unten gezeigte SecurityError.
  • Erfasse BE und BS pro Credential. Ein Nutzer, dessen einziger Passkey gerätegebunden und nicht gesichert ist, verliert mit diesem Gerät den Zugang; fordere ihn auf, einen zweiten hinzuzufügen, bevor du ihm das Passwort wegnimmst.

Attestierung: wann du sie brauchst

Attestierung beantwortet die Frage: „Welches Authenticator-Modell hat diesen Schlüssel erzeugt?“ Über den Nutzer, den Patch-Stand des Geräts oder darüber, ob es gerootet ist, sagt sie nichts aus. Mit attestation: "none", dem Standardwert, ersetzt der Browser jedes Attestierungs-Statement durch ein leeres, bevor dein Server es sieht. Der Testaufbau erhielt trotzdem die AAGUID des Authenticators: WebAuthn Level 2 wies Browser an, sie in diesem Fall durch 16 Null-Bytes zu ersetzen, und Level 3 hat diesen Schritt gestrichen. Ohne Attestierung ist die AAGUID eine bloße Angabe, kein Beweis.

Verlange auf einer Website für Endkunden keine Attestierung. Synchronisierte Passkeys sollen dem Nutzer auf jedes Gerät folgen, und ihre Anbieter haben sie in der Regel nicht attestiert: Google erklärte zum Start von Passkeys unter Android, die Passkeys würden „zunächst keine Attestierung haben“ (fido-dev-Liste, 2022). Der virtuelle Authenticator des Testaufbaus lieferte auf die Anfrage "direct" ein Statement im Format packed. Das zeigt, was ein attestierender Authenticator schickt, aber nicht, was ein bestimmter Sync-Anbieter tut; teste also die Anbieter, die deine Nutzer tatsächlich haben. Fordere Attestierung an, wenn du die Registrierung auf bestimmte Modelle beschränken musst, etwa auf Sicherheitsschlüssel, die dein Unternehmen ausgibt, und prüfe die Kette gegen den FIDO Metadata Service. Um Nutzern einen verständlichen Namen für ihren Passkey-Anbieter anzuzeigen („Google Password Manager“, „1Password“), ordnet die von der Community gepflegte AAGUID-Liste für Passkey-Authenticators den AAGUIDs Namen zu; laut ihrer eigenen README-Datei dient sie nur der Anzeige und ist keine Grundlage für Sicherheitsentscheidungen.

COSE-Algorithmen

pubKeyCredParams listet COSE-Algorithmus-IDs auf (COSE-Registry der IANA). Das Beispiel der Spezifikation selbst akzeptiert EdDSA, ES256 und RS256 und bevorzugt EdDSA; diese Liste deckt praktisch jeden Authenticator ab, der im Einsatz ist:

WertNameAnmerkungen
-8EdDSAIn WebAuthn nur Ed25519 (§5.8.5); in der COSE-Registry durch RFC 9864 zugunsten von -19 Ed25519 als veraltet markiert, Authenticators geben aber weiterhin -8 aus
-7ES256ECDSA P-256; der am breitesten unterstützte. In COSE zugunsten von -9 ESP256 als veraltet markiert, gleiche Lage
-257RS256RSA PKCS#1 v1.5; Windows Hello unter Windows 10 und auf manchen Windows-11-Installationen erzeugt nur RS256-Credentials (Yubico auf der W3C-Liste)
-48, -49, -50ML-DSA-44, -65, -87Registriert durch RFC 9964 (Mai 2026). Der virtuelle Authenticator von Chromium 156 akzeptierte -49 (siehe unten); prüfe echte Authenticators und deine Server-Bibliothek, bevor du dich darauf verlässt

Behalte -7 und -257 in der Liste, auch wenn du -8 bevorzugst: Ist pubKeyCredParams leer, nimmt die Spezifikation selbst ES256 und RS256 als Standard. Speichere den Algorithmus mit dem Credential; SimpleWebAuthn bewahrt ihn im gespeicherten öffentlichen Schlüssel im COSE-Format auf.

Die DOMException-Fehler

navigator.credentials.create() und get() melden Fehler über eine kleine Gruppe von Exceptions, die WebAuthn Level 3 in §5.1.3.1 und §5.1.4.3 aufführt. Was Chromium 156 bei typischen Fehlern warf, getestet gegen einen virtuellen USB-Sicherheitsschlüssel ohne Speicher für Discoverable Credentials:

Ausgabe: errors.mjs (Chromium 156.0.8078.4)
only an unknown algorithm (-65000)           NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
only ML-DSA-65 (-49)                         created, COSE alg -49
residentKey required, key cannot store it    NotAllowedError: The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.
rp.id "example.com" on localhost             SecurityError: The relying party ID is not a registrable domain suffix of, nor equal to the current domain. Subsequently, an attempt to fetch the .well-known/webauthn resource of the claimed RP ID failed.
user.id of 65 bytes                          TypeError: User handle exceeds 64 bytes.
aborted with AbortController                 AbortError: signal is aborted without reason
ExceptionBedeutung laut SpezifikationWas zu tun ist
NotAllowedErrorSammelfehler: Abbruch durch den Nutzer, Zeitüberschreitung, kein passendes Credential, Anforderungen, die kein Authenticator erfüllteBehandle ihn als „nicht abgeschlossen“; biete einen erneuten Versuch und eine andere Methode an. Zeig nie die rohe Meldung an
InvalidStateErrorEin Eintrag aus excludeCredentials wurde erkanntSag dem Nutzer, dass dieses Gerät hier schon einen Passkey hat
SecurityErrorDie RP-ID ist weder die Domain der Seite noch ein registrierbares Suffix davon, oder die Validierung der Related Origins ist fehlgeschlagenKorrigiere die Konfiguration; siehe die Seite zum SecurityError
NotSupportedErrorKein verwendbarer Algorithmus in pubKeyCredParamsBiete -7 und -257 an
ConstraintErrorresidentKey oder userVerification gefordert, aber kein Authenticator ist dazu in der LageIn der Praxis dieselbe Behandlung wie bei NotAllowedError
AbortErrorDein AbortController hat den Aufruf abgebrochenErwartet, wenn du vom Autofill zu einem modalen Dialog wechselst
TypeErrorFehlerhafte Optionen, zum Beispiel ein User Handle über 64 ByteEin Bug in deinen Optionen

Zwei Ergebnisse des Testaufbaus weichen von der Liste der Spezifikation ab: Chromium antwortete mit NotAllowedError statt NotSupportedError, als der einzige Algorithmus unbekannt war, und mit NotAllowedError statt ConstraintError, als ein Discoverable Credential von einem Sicherheitsschlüssel verlangt wurde, der keines speichern kann. Code, der anhand von NotSupportedError oder ConstraintError verzweigt, um Nutzern das Problem zu erklären, bekommt diese Exceptions in diesen Fällen von Chromium nie zu sehen. Beide APIs gibt es nur in sicheren Kontexten. Auf einer Seite über einfaches HTTP, die nicht localhost ist, meldete Chromium 156 isSecureContext: false, und navigator.credentials, PublicKeyCredential und crypto.subtle waren alle undefined; der Code scheitert also mit einem TypeError, bevor überhaupt eine Zeremonie beginnt (die Web-Crypto-Variante ist crypto.subtle is undefined). Liefere jede Seite, die Passkeys nutzt, über HTTPS aus; http://localhost ist die einzige Ausnahme, für die Entwicklung.

Checkliste

  1. RP-ID auf die registrierbare Domain gesetzt, die deine Nutzer sehen; expectedOrigin eine explizite Liste von HTTPS-Origins, einschließlich jeder Subdomain mit Anmeldung.
  2. residentKey: "required", userVerification: "required", der Server prüft UV bei jeder Antwort.
  3. pubKeyCredParams mit -8, -7, -257; excludeCredentials mit den vorhandenen Credentials des Nutzers gefüllt.
  4. Challenges zufällig, einmalig, kurzlebig und serverseitig gespeichert.
  5. Credentials gespeichert mit ID, öffentlichem Schlüssel, Zähler, Transporten, BE- und BS-Flags, AAGUID und einem Namen, den der Nutzer ändern kann; Nutzer können mehrere registrieren und wieder löschen.
  6. Attestierung "none", sofern du die Authenticator-Modelle nicht einschränkst; sonst Prüfung gegen den FIDO MDS.
  7. Eine Kontowiederherstellung, die nicht schwächer ist als der Passkey selbst: ein zweiter Passkey oder ein geprüfter Wiederherstellungsweg, kein SMS-Code, der alles umgeht.

Quellen