Ethan T. McGee's Blog

Passkey Authentication Ceremony

This is the third post in a multi-part series covering passkeys (or WebAuthn). Each post builds on previous posts in the series, so catch up if needed before continuing.

Table of Contents

Note: will be updated as more posts are completed

The authentication ceremony is the process by which an authenticator is used to verify a user's identity. This is done by utilizing the key pair stored on the authenticator for the specific website.

Note: All specifications linked throughout this document are up to date at time of writing, remember to check for updated specifications before implementing.

Before we begin, a few terms must be defined:

The below diagram provides a general flow of the authentication ceremony. The grey box along the Relying Party line indicates the need for a singular value to be persisted into a session and for how long it must be persisted.

Authentication Ceremony

The ceremony has a few distinct phases: the trigger, generation of the authentication options, retrieval of the credential, and validation of the credential. The options must be safely stored by the Relying Party from the start of the process to the end. However stored, the options must be free from tampering to provide the security guarantees that WebAuthn promises. Storage is usually done via a backend system like Redis where the options expire after a short timeout, usually a few minutes at most.

For our purposes, we will use the standard use case for WebAuthn. The user will be visiting a website, the Relying Party will be the website itself, and the authenticator will be a hardware authenticator. The trigger is easy enough, the user visits a sign-in page and clicks a login with passkey button. The site may request a username or email address before the button is activated, but this is not necessary UNLESS the site is not using resident (or discoverable) credentials, in which case it is necessary.

The generation of the authentication options comes next. Here the Relying Party has numerous options that can be supplied to the authenticator in the form of a JSON payload. We discuss what each field does after the example payload.

{
  "allowCredentials": [{
    "id": "string",
    "type": "public-key",
    "transports": ["ble | hybrid | internal | nfc | usb"]
  }],
  "challenge": "string",
  "extensions": {},
  "hints": ["security-key | hybrid | client-device"],
  "rpId": "string",
  "timeout": "number",
  "userVerification": "discouraged | preferred (default) | required"
}

Once the options are generated, they are provided to the authenticator via the standard browser API navigator.credentials.get({options}). The browser must validate that the site in question is authorized to use the rpId before it can ask the authenticator to return a credential.

That validation process includes two checks. For websites, the rpId must be a valid domain. The current website must either be a subdomain of the rpId (for example, rpId is example.com and current website is sub.example.com) or rpId must expose a /.well-known/webauthn endpoint listing the current website as an officially supported origin. This is the step that gives WebAuthn its famed phishing resistance.

Should validation succeed, the credential is returned to the relying party. The relying party must now confirm the credential conforms to the options provided, and if it does not, validation errors must be returned to the user alerting them that their authenticator is not registered for the site in question or that the ceremony failed for some other reason. An example credential is provided, then a discussion of the fields and validations needed on each follows.

{
  "authenticatorAttachment": "cross-platform | platform",
  "id": "string",
  "rawId": "string",
  "response": {
    "clientDataJSON": "string",
    "authenticatorData": "string",
    "signature": "string",
    "userHandle": "string"
  },
  "type": "public-key",
  "clientExtensionResults": {}
}
{
  "type": "webauthn.get",
  "challenge": "9f3k-xYY1c5RZSi5q5GKhA",
  "origin": "https://example.com",
  "crossOrigin": false
}

After the credential has been validated, the Relying Party should also check the signature counter. The authenticatorData contains a signCount value that should be greater than the value stored from the previous authentication (or registration). If the counter has not incremented (or has decreased), it may indicate a cloned authenticator. Not all authenticators support signature counters (some always return 0), so the Relying Party must account for this case as well.

Note: The signature counter is a best-effort defense against cloning. If the stored counter and the returned counter are both 0, the authenticator does not support the counter and the check should be skipped.

Once the authentication succeeds, fails, or times out, the Relying Party should destroy the authentication options, and that's it! The ceremony is complete. Discussing FIDO metadata is next.

Want to try out the authentication ceremony yourself? Give the developer tools from Yubico a try.