Phishing-Resistant Auth · Chapter 3 of 5
WebAuthn ceremony: challenge, authenticator, attestation
Registration and authentication in full: what the server sends, what the authenticator signs, which flags matter and when attestation is worth it.
WebAuthn is specified by the W3C and works with the CTAP protocol that browsers use to talk to external authenticators. Together they define two ceremonies — the specification’s word for protocol runs that involve a human. Registration creates a credential; authentication uses it. Both follow the same shape: the server issues a challenge and options, the browser mediates, the authenticator produces signed data, and the server verifies a precise list of fields.
Registration
Registration begins on the server, which generates a random challenge of at least sixteen bytes, stores it in the user’s session, and returns creation options: the RP ID and display name, a user handle, accepted public-key algorithms, a list of credentials the user already has (so the authenticator can refuse duplicates), and preferences for resident keys and user verification. The page passes these to navigator.credentials.create().
The user handle deserves care. It is an opaque identifier of up to 64 bytes that the authenticator stores with the credential and returns on authentication. It must not contain personal data such as an email address, because authenticators may expose it. Generate a random value per account and keep it stable for the account’s life.
The authenticator asks the user for presence (a touch) and, if requested, verification (a biometric or PIN checked locally). It generates a new key pair scoped to the RP ID and returns an attestation object containing authenticator data and an optional attestation statement. The browser adds client data: a JSON object with the type webauthn.create, the challenge and the origin.
Authenticator data, field by field
rpIdHash(32 bytes): SHA-256 of the RP ID; the server compares it with its own.flags(1 byte): UP (user present), UV (user verified), BE (backup eligible), BS (backed up), AT (attested credential data included), ED (extensions).signCount(4 bytes): a counter that some authenticators increment; synced passkeys often report zero.attestedCredentialData(registration only): authenticator model identifier (AAGUID), credential ID and the public key in COSE format.
On registration the server verifies that the client data type is webauthn.create, the challenge matches the stored one, the origin is in its allow-list, the RP ID hash matches, the UP flag is set, the UV flag is set if verification was required, and the algorithm is one it accepted. Then it stores the credential ID, public key, sign count and the BE and BS flags against the account.
Authentication
Authentication mirrors registration with fewer moving parts. The server issues a fresh challenge and, optionally, a list of allowed credential IDs. With discoverable credentials — the norm for passkeys — the list can be empty, and the browser offers whichever passkeys the user has for this RP ID, which enables username-less sign-in. The authenticator signs the concatenation of authenticator data and the SHA-256 of client data with the credential’s private key.
// Browser side, authentication
const assertion = await navigator.credentials.get({
publicKey: {
challenge: fromB64url(opts.challenge), // single-use, server-issued
rpId: "larkspur.example",
userVerification: "required",
allowCredentials: [], // discoverable: let the user pick
},
mediation: "conditional", // autofill-style passkey UI
});The server checks the type webauthn.get, the challenge, the origin, the RP ID hash, the UP and UV flags, and the signature against the stored public key. It then looks up the account by the returned user handle or credential ID. If the stored sign count is non-zero and the new value is not greater, it may indicate a cloned authenticator; for synced passkeys, which commonly report zero, the counter carries no signal and should not trigger lockouts.
Attestation: when it is worth it
Attestation lets an authenticator prove what it is: a statement signed by a key that chains to the manufacturer, identifying the model by its AAGUID. Formats include packed, tpm, android-key, apple and fido-u2f, plus none. For a consumer service, request attestation: "none". You gain nothing from knowing which phone a user owns, attestation adds verification complexity and metadata maintenance, and synced passkeys generally do not provide meaningful attestation anyway.
Attestation matters in regulated or high-assurance environments where policy requires a specific class of hardware — for example, administrators at Larkspur Ledger must use FIPS-validated security keys issued by IT. There the server validates the attestation chain against a metadata service and rejects unlisted AAGUIDs. Even then, enforce it only for the population that needs it, not for every customer.
User verification deserves a policy decision of its own. If you set userVerification to required, the authenticator must check a biometric or PIN, and the UV flag proves it did. With preferred, some authenticators skip verification and set only UP. For passkeys used as the only factor, require UV, because without it a passkey proves possession but not that the device's owner is the one using it. For a security key used as a second factor after a password, presence alone may be acceptable.
Finally, plan for errors. The browser rejects the promise with named errors: NotAllowedError when the user cancels or times out, InvalidStateError when the authenticator already holds a credential from excludeCredentials. Map them to clear messages, and never fall back silently to a weaker factor when a ceremony fails.
Keep ceremony timeouts generous, around five minutes, because users may need to find a security key or unlock a phone.
// bench check
0/3 correct · 0 answered
1. Which flag tells you the credential may be synced to other devices?
2. For a consumer sign-in flow, the sensible attestation preference is…
3. A synced passkey always reports signCount 0. What should the server do?