How passkeys work

Relying Party ID, challenges, user handles, discoverable credentials.

Enough of the model to debug your own integration. Ten minutes here saves an hour of guessing at rpID.

The one-paragraph version

A passkey is a key pair. The private half never leaves the user's device, or their iCloud or Google keychain. Your server stores the public half. Signing in means your server sends a random challenge, the device signs it, and you verify the signature. There is no shared secret, so there is nothing in your database worth stealing and nothing for a phishing page to capture.

The two ceremonies

Both are two round trips, and both are the same shape: you issue a challenge, the device signs something, you verify.

REGISTRATION                                  AUTHENTICATION
 
browser          server                       browser          server
   |  register/start  |                          |  login/start   |
   |----------------->|  challenge + rp info     |--------------->|  challenge
   |<-----------------|                          |<---------------|
   |                                             |
   [ device creates a key pair,                  [ device signs the challenge
     signs, shows Touch ID ]                       with the stored private key ]
   |                                             |
   |  register/finish |                          |  login/finish  |
   |----------------->|  verify + store pubkey   |--------------->|  verify signature
   |<-----------------|                          |<---------------|

The three terms that matter

Relying Party IDconcept

The domain a passkey is bound to. A credential created for example.com cannot be used on evil.com.

Why this is the whole security story

The rule is that rpID must be the page's host or a parent domain of it:

Page served fromValid rpIDInvalid
https://example.comexample.comapp.example.com, www.example.com
https://app.example.comapp.example.com, example.comother.example.com
http://localhost:3000localhost127.0.0.1

Challengeconcept

Random bytes you generate, store, and send to the browser. The device signs them, and you check the signature is over the bytes you issued.

Why it works this way

Challenges expire after five minutes by default. That window covers a slow user finding their phone without leaving a useful replay surface.

User handleconcept

An opaque identifier for the account, stored on the user's device alongside the credential. On a usernameless login the device sends it back, which is how your server knows which account just signed in.

Why it must not be an email address

Hard limit: 64 bytes once UTF-8 encoded. passkify checks this before the browser prompt appears rather than letting it fail opaquely.

Discoverable credentials

This is what makes login() work with no username.

Discoverable (resident key)

The authenticator stores the credential and the user handle. The browser can list every passkey for your domain, so it shows a picker and you learn who signed in from the response.

await login();   // no argument

Non-discoverable

The authenticator stores only a key derived from an ID you supply. You must already know which credentials to offer, which means asking for a username first.

await login({ username });

passkify defaults to residentKey: 'preferred' and supports both flows.

Why 'preferred' and not 'required'

User presence and user verification

Two different flags in every response, often confused.

User Present (UP)always required
User Verified (UV)configurable

passkify always requires UP. UV is governed by userVerification, which defaults to 'preferred'.

Synced versus device-bound

Every registration reports whether the credential can leave the device it was made on.

multiDevicebackup eligible
singleDevicenot backup eligible

passkify records this on every credential as deviceType, so an account settings page can warn a user whose only passkey is device-bound. See credential management.

Attestation, and why you should ignore it

Attestation answers "what kind of authenticator made this key?". For consumer passkeys the answer is deliberately nothing: Apple, Google and 1Password return fmt: "none" with an all-zero AAGUID, because a per-model identifier is a tracking vector.

passkify verifies any statement that arrives, never requires one, and defaults to attestation: 'none'. The full reasoning is in the security guide.

Next