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
| Begriff | Was er für deinen Code bedeutet |
|---|---|
| Relying Party (RP) und RP-ID | Deine Website. Die RP-ID ist eine Domain wie example.com; Credentials funktionieren auf dieser Domain und ihren Subdomains, nirgends sonst |
| Discoverable Credential | Wird 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 AAGUID | Eine 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.
// 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 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:
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,-7und-257an; der Authenticator wählte-8(EdDSA), den ersten, den er unterstützt. Die Reihenfolge drückt die Präferenz aus. excludeCredentialsfunktioniert, und zwar mit dem InvalidStateError, der deiner Oberfläche mitteilt: „Dieses Gerät hat schon einen Passkey für dieses Konto.“- Anmeldung ohne Benutzernamen.
allowCredentialswar 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:4000konnte ein Credential für die RP-IDlocalhosterzeugen, 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. GibexpectedOriginals exakte Liste an. - Synchronisierte Passkeys geben sich zu erkennen. Bei eingeschalteter Backup-Fähigkeit meldete SimpleWebAuthn
multiDeviceundbackedUp 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
requireUserVerificationist 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
conditionalCreatelässtnavigator.credentials.create()mitmediation: "conditional"den Passwortmanager, der gerade das Passwort ausgefüllt hat, ohne zusätzlichen Dialog einen Passkey erzeugen. - Anmeldung über das Benutzernamensfeld. Die Fähigkeit
conditionalGetist 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, undsignalCurrentUserDetails()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.comundexample.co.ukkann bei einer RP-ID bleiben und die anderen Origins inhttps://example.com/.well-known/webauthnals{"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 gezeigteSecurityError. - 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:
| Wert | Name | Anmerkungen |
|---|---|---|
| -8 | EdDSA | In 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 |
| -7 | ES256 | ECDSA P-256; der am breitesten unterstützte. In COSE zugunsten von -9 ESP256 als veraltet markiert, gleiche Lage |
| -257 | RS256 | RSA 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, -50 | ML-DSA-44, -65, -87 | Registriert 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:
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
| Exception | Bedeutung laut Spezifikation | Was zu tun ist |
|---|---|---|
NotAllowedError | Sammelfehler: Abbruch durch den Nutzer, Zeitüberschreitung, kein passendes Credential, Anforderungen, die kein Authenticator erfüllte | Behandle ihn als „nicht abgeschlossen“; biete einen erneuten Versuch und eine andere Methode an. Zeig nie die rohe Meldung an |
InvalidStateError | Ein Eintrag aus excludeCredentials wurde erkannt | Sag dem Nutzer, dass dieses Gerät hier schon einen Passkey hat |
SecurityError | Die RP-ID ist weder die Domain der Seite noch ein registrierbares Suffix davon, oder die Validierung der Related Origins ist fehlgeschlagen | Korrigiere die Konfiguration; siehe die Seite zum SecurityError |
NotSupportedError | Kein verwendbarer Algorithmus in pubKeyCredParams | Biete -7 und -257 an |
ConstraintError | residentKey oder userVerification gefordert, aber kein Authenticator ist dazu in der Lage | In der Praxis dieselbe Behandlung wie bei NotAllowedError |
AbortError | Dein AbortController hat den Aufruf abgebrochen | Erwartet, wenn du vom Autofill zu einem modalen Dialog wechselst |
TypeError | Fehlerhafte Optionen, zum Beispiel ein User Handle über 64 Byte | Ein 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
- RP-ID auf die registrierbare Domain gesetzt, die deine Nutzer sehen;
expectedOrigineine explizite Liste von HTTPS-Origins, einschließlich jeder Subdomain mit Anmeldung. residentKey: "required",userVerification: "required", der Server prüft UV bei jeder Antwort.pubKeyCredParamsmit-8,-7,-257;excludeCredentialsmit den vorhandenen Credentials des Nutzers gefüllt.- Challenges zufällig, einmalig, kurzlebig und serverseitig gespeichert.
- 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.
- Attestierung
"none", sofern du die Authenticator-Modelle nicht einschränkst; sonst Prüfung gegen den FIDO MDS. - 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
- Web Authentication Level 3, W3C Recommendation, 25. August 2026: §5.1.3.1, §5.1.4.3, §5.8.5, §6.1, §6.1.1
- COSE-Algorithmen-Registry der IANA; RFC 9864; RFC 9964
- Dokumentation von SimpleWebAuthn
- FIDO Metadata Service
- AAGUID-Liste für Passkey-Authenticators
- Chrome DevTools Protocol: WebAuthn-Domain