skip to content

At passkey registration, what does your server generate before the ceremony and store after it?

level: middleimportance: must knowfreq 70%

answer

  1. two documents, one set of comparisons
  2. options out, credential record in
  3. opaque handle, capped at 64 bytes
  4. one row per credential, not per account

basics

~20 s

Passkey registration begins with server-generated options - a fresh random challenge, the relying-party identifier, an opaque user handle and an exclude list - and ends with one credential record per credential: credential id, public key, sign counter, user handle and transports.

solid answer

~40 s

The server drives both ends. Before the ceremony it builds creation options: a fresh random `challenge` held against this pending registration, the `rpId` the credential will be scoped to, a `userHandle` that is opaque, at most 64 bytes and carries no personally identifying information, an `excludeCredentials` list of the ids this account already holds, and the `residentKey` and `userVerification` preferences. When the response arrives it verifies before it stores: `type` is `webauthn.create`, the `challenge` is the one it issued, the `origin` is expected, `rpIdHash` is the SHA-256 hash of the expected `rpId`, the user-present flag is set. Only then does it write a credential record - credential id, public key, `signCount`, `userHandle`, `transports` - one row per credential, so a skipper holding a wheelhouse tablet and a roaming security key has two.

code

json · 19 lines
json
{
  "rp": { "id": "quota-reports.example", "name": "Quota Reports" },
  "user": {
    "id": "b2Y5Wm5RcVh0N0pkS2w0MA",
    "name": "skipper-1147",
    "displayName": "Skipper, Ardglass fleet"
  },
  "challenge": "9Q0m5rT1xK3bV8pN2sA6cQ",
  "pubKeyCredParams": [ { "type": "public-key", "alg": -7 } ],
  "excludeCredentials": [
    { "type": "public-key", "id": "Zm9vLWNyZWQtaWQtMDAx" }
  ],
  "authenticatorSelection": {
    "residentKey": "required",
    "userVerification": "required"
  },
  "attestation": "none",
  "timeout": 120000
}

go deeper

for a junior

Recall the two halves: the server generates options first and stores a credential record afterwards. Name the challenge, the relying-party identifier and the user handle on the way out, and the credential id and public key on the way back.

for a middle

Explain why every field in the response is checked against something the server issued, and why the record is per credential rather than per account. Be able to say what the exclude list prevents and what the user handle must never contain.

for a senior

Show the ordering discipline: verify, then persist, and never the other way round. Talk about credential-id uniqueness across accounts, what a half-written registration costs you later, and why the transports list is a prompt hint and not an input to any decision.

for a principal

Frame the record as the durable asset. Its shape decides whether a skipper can hold two authenticators, whether a lost one can be removed without locking the account, and whether you can ever add a model policy later - which you cannot if you threw the model evidence away at registration.

## What registration actually is A passkey registration is a short exchange your server drives from both ends. Your server builds a **creation-options document**, the client passes it to an authenticator - the built-in authenticator in the wheelhouse tablet, or a roaming security key the skipper keeps in a dry bag - and the authenticator generates a fresh key pair, keeps the private half and returns the public half together with signed evidence about the conditions under which it was made. Nothing in the response is trusted because the client sent it. It is trusted because every field in it can be compared against something your server issued moments earlier. That is the whole design, and it is why the options document matters as much as the response. ## What the server generates - **A challenge.** Fresh bytes from a cryptographic random generator, held server-side against this one pending registration. The specification requires enough entropy that guessing is infeasible, and says a challenge should be at least 16 bytes. - **The relying-party identifier, `rpId`.** The registrable domain the credential is scoped to. Everything the credential can ever be used for is fixed here, and it cannot be widened later. - **A user handle, `userHandle`.** An opaque byte string you choose for this account, at most 64 bytes, which must not carry personally identifying information - no email address, no skipper's name, no vessel registration. It is stored on the authenticator and handed back to you at sign-in, so treat it as a public identifier for a private account. A random opaque value is the safe choice. - **`excludeCredentials`.** The credential ids this account already holds. An authenticator that is already registered then refuses rather than quietly creating a second credential nobody asked for. - **Authenticator selection.** `residentKey` decides whether the credential is discoverable; `userVerification` decides whether the authenticator must demand a PIN or a biometric. - **`pubKeyCredParams`.** The COSE algorithm identifiers you are willing to verify signatures under, most preferred first. - **Attestation conveyance.** Whether you want evidence about the authenticator's model at all. ## What comes back Two documents carry everything you check. `clientDataJSON` is the client's account of the ceremony: a `type` of `webauthn.create`, the `challenge` echoed back, the `origin` the client was actually running on, and `crossOrigin`. The `attestationObject` wraps the authenticator's own account: the `rpIdHash`, a flags byte, `signCount`, and attested credential data holding the `aaguid`, the credential id and the credential public key. ## What you verify before you store anything 1. `type` reads `webauthn.create`, so a response produced by the other ceremony cannot be posted here. 2. The `challenge` equals the one you issued for this pending registration, and you spend it now. 3. The `origin` is one your server expects. The specification is explicit that tolerating a mismatch here compromises the protocol. 4. `rpIdHash` equals the SHA-256 hash of the `rpId` you expected. 5. The user-present flag is set, and the user-verified flag too if you asked for verification. 6. The signature algorithm is one you actually offered. 7. The credential id is not already registered - to this account or to any other. ## The credential record | stored field | where it comes from | what it is for | |---|---|---| | credential id | the returned credential | the key you look up on every later assertion | | public key | attested credential data | verifying the signature on every later assertion | | `signCount` | authenticator data | the value you compare next time, as a clone signal | | `userHandle` | you generated it | joining the credential back to an account when sign-in starts with no username | | `transports` | the client's hint | putting a usable prompt in front of the skipper next time | | `aaguid` | attested credential data | the authenticator model, when you asked for and verified attestation | The record is **per credential, never per account**. A skipper who registers the wheelhouse tablet and a roaming key has two rows, and removing one must leave the other working. ## Where this goes wrong - **Writing the record before verifying.** A half-verified registration that is already persisted is a credential you will trust forever. - **Reusing something meaningful as the user handle.** An email address breaks the privacy rule outright; a sequential account number leaks how many skippers you have to anyone who reads it off an authenticator. - **Skipping the exclude list.** The same authenticator then registers twice, the skipper sees two indistinguishable entries, and revoking the wrong one is now possible. - **Treating `transports` as a security input.** It is a hint from the client about how to prompt next time, nothing more. - **Assuming the client checked the origin for you.** The client enforces relying-party-identifier scoping; it does not know which origins your server is prepared to accept.

  • The skipper taps the same roaming key twice at registration. What stops a duplicate credential?
    `excludeCredentials` carries the ids that account already holds. An authenticator that recognises one of its own ids refuses the ceremony - the client surfaces an `InvalidStateError` rather than creating a second key pair. It is per account, so you still need a uniqueness check on credential id across all accounts to stop the same credential landing under two.
  • Why must the user handle not be the skipper's email address?
    The handle is written into the authenticator and returned to your server on every username-less sign-in, so it behaves as a durable public identifier for the account. The specification says it must not contain personally identifying information and caps it at 64 bytes. Use a random opaque value and keep the human-readable labels in the fields meant for display.
  • Does the server need to store the transports list at all?
    Only for the prompt. Replaying `transports` in later request options lets the client put the right hint in front of the skipper - tap the key, use the tablet - instead of offering every option. It is a usability input from the client and must never gate a verification decision.

saying these in an interview costs you the question

  • Says the user handle can be the skipper's email address
  • Stores one credential per account, so a second authenticator overwrites the first
  • Writes the credential record before checking the challenge and origin
  • Assumes the client checked the expected origin so the server need not
  • Treats the transports list as a security control rather than a prompt hint
  • Cannot say what the exclude list is for at registration