Passkey Registration
When to Use
Use when adding a passkey creation flow — either as an explicit "Create passkey" button in account settings (management flow, allows security keys) or as a promotion shown after a successful password sign-in (promotion flow, platform authenticators only). A single server endpoint can handle both via a
promotionflag.
Decision
| Context | authenticatorAttachment |
Security keys allowed? |
|---|---|---|
| Promotion after password sign-in | "platform" |
No — platform authenticator only |
| Account settings / security panel | Omit entirely | Yes |
Pattern
interface StoredPasskeyCredential {
id: string; // Base64URL credential ID (primary key)
passkeyUserId: string; // App user ID
credentialPublicKey: string; // Base64URL public key for verification
credentialDeviceType: 'singleDevice' | 'multiDevice';
credentialBackedUp: boolean;
aaguid: string; // Provider identifier — UX display only
transports: string[]; // Required for excludeCredentials
counter: number; // Replay-attack prevention
registeredAt: number; // Unix epoch
}
import 'webauthn-polyfills';
async function registerPasskey(isPromotion = false) {
const caps = await PublicKeyCredential.getClientCapabilities();
if (!caps.passkeyPlatformAuthenticator || !caps.conditionalGet) return;
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(
await optionsFetch({ promotion: isPromotion })
);
let credential;
try {
// Inner block — ONLY wraps credentials.create()
credential = await navigator.credentials.create({ publicKey });
} catch (err) {
if (err.name === 'InvalidStateError') alert('Passkey already exists.');
// NotAllowedError / AbortError = user cancelled — no UI needed
return; // Do NOT call signalUnknownCredential here — no credential was persisted
}
// Outer block — wraps server verification
const encoded = credential.toJSON();
try {
const res = await registerVerifyFetch(encoded);
if (!res.ok && PublicKeyCredential.signalUnknownCredential) {
await PublicKeyCredential.signalUnknownCredential({ rpId: 'example.com', credentialId: encoded.id });
}
} catch {
if (PublicKeyCredential.signalUnknownCredential) {
await PublicKeyCredential.signalUnknownCredential({ rpId: 'example.com', credentialId: encoded.id });
}
}
}
Server options must set residentKey: "required", requireResidentKey: true, and populate excludeCredentials from existing credentials. Use authenticatorAttachment: "platform" only for promotion flows.
Common Mistakes
- Not segregating try/catch → Catching server errors in the WebAuthn catch block fires
signalUnknownCredentialon user-cancel events, signalling credentials that were never created signalUnknownCredentialfrom the WebAuthn catch → No credential exists server-side; only call signal after a server-side rejection of a successfully-created credentialexcludeCredentialsomitted → Allows registering duplicate passkeys;InvalidStateErrornever fires without itresidentKey: "required"omitted → Credential is not discoverable; will not appear in autofilluserVerification: "preferred"withoutrequireUserVerification: falseserver-side → Authenticators without screen locks trigger spurious 400 errorsauthenticatorAttachment: "platform"in a security-panel flow → Blocks external FIDO2 security keys
See Also
- passkeys-overview — prerequisites
- passkey-conditional-create — silent post-login registration
- passkey-management — AAGUID registry lookup for provider name/icon
- Reference: MDN parseCreationOptionsFromJSON()