A passkey is a WebAuthn credential: a key pair whose private half stays with the user's authenticator and whose public half is stored by your server. Sign-in is a signature over a fresh challenge, made only for your domain, so there is no shared secret to phish, replay or leak from a database. That much is widely understood. What is less obvious is what your server, the relying party, must actually do with the bytes the browser hands back, and which of those checks are the ones that make the scheme secure.

This page is the relying-party implementer's view. It walks through the registration and sign-in ceremonies as data, the exact byte layout of authenticator data and its flags, the verification steps in the order they matter, what to store, how sign-in with autofill works, how synced passkeys change the meaning of the signature counter, and the failure modes teams hit in production. The architecture around it, including authenticator types, the sync fabric and account recovery, is covered in WebAuthn and passkeys architecture. Use a maintained server library in production; this page explains what that library does so you can configure it correctly and debug it when it says no.

Two ceremonies, one data model

Both ceremonies have the same shape. The server creates options containing a random challenge, the browser passes them to an authenticator, the authenticator answers, and the server verifies the answer. The browser contributes one critical thing: it records the real origin of the page in clientDataJSON and refuses to use a relying party ID (rpId) that the origin is not entitled to. The authenticator contributes the other: it binds every credential to the hash of the rpId and signs only for that rpId.

Relying party serverchallenge, rpId, user.idoptionsBrowserchecks origin vs rpIdrpIdHash, challengeAuthenticatorUP / UV, key pairsignsResponseclientDataJSON + authData + sigBrowser returnscredential id, userHandlePOSTVerifyin orderCredential storeid, public key, flags, counterRegistration stores a public key.Sign-in proves possession of the private keyfor this rpId, for this challenge, once.
A WebAuthn ceremony as data: the server issues a challenge, the browser binds the origin, the authenticator signs over the rpId hash and the client data, and the server verifies before touching its credential store.

The server stores, per credential: the credential ID (bytes, unique), the user it belongs to, the public key in COSE format, the signature counter, the backup flags, the transports reported at registration, and timestamps and a user-visible name for account settings. The user also needs a stable, random user handle, up to 64 bytes, which is sent as user.id at registration and returned by discoverable credentials at sign-in. Never put an email address or other personal data in it; it is stored on the authenticator and may be shown to other parties.

Registration options and response

Registration starts with options built on the server. A sensible set for passkeys:

{
  "challenge": "<32 random bytes, base64url>",
  "rp": { "id": "example.com", "name": "Example" },
  "user": { "id": "<random user handle, base64url>", "name": "ana@example.com", "displayName": "Ana" },
  "pubKeyCredParams": [ { "type": "public-key", "alg": -7 }, { "type": "public-key", "alg": -257 } ],
  "authenticatorSelection": { "residentKey": "required", "userVerification": "preferred" },
  "attestation": "none",
  "excludeCredentials": [ { "type": "public-key", "id": "<existing credential id>" } ],
  "timeout": 300000
}

-7 is ES256 and -257 is RS256; listing both covers practically every authenticator. residentKey: required asks for a discoverable credential, which is what makes username-less sign-in possible. attestation: none is right for consumer sign-in: attestation proves which authenticator model created the key, which matters only if policy restricts models, and it adds privacy and maintenance cost. excludeCredentials lists the user's existing credentials so the same authenticator is not registered twice.

The browser side is short. The binary fields arrive as base64url strings and must be turned into buffers; newer browsers offer PublicKeyCredential.parseCreationOptionsFromJSON and a toJSON() method on the result for exactly this, but check support and keep a fallback.

const opts = await (await fetch('/webauthn/register/options', { method: 'POST' })).json();
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON
  ? PublicKeyCredential.parseCreationOptionsFromJSON(opts)
  : decodeOptions(opts);                 // your base64url -> ArrayBuffer helper
const cred = await navigator.credentials.create({ publicKey });
await fetch('/webauthn/register/verify', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(cred.toJSON ? cred.toJSON() : encodeCredential(cred)),
});

The response carries clientDataJSON and an attestationObject, a CBOR map with fmt, attStmt and authData. With attestation none, fmt is "none" and the statement is empty; everything you need is in the authenticator data.

Inside the authenticator data

Authenticator data is a compact binary structure, and most of the security checks are reads of fixed offsets in it.

BytesFieldMeaning
0-31rpIdHashSHA-256 of the rpId the authenticator signed for
32flagsBit 0 UP (user present), bit 2 UV (user verified), bit 3 BE (backup eligible), bit 4 BS (backed up), bit 6 AT (attested credential data follows), bit 7 ED (extensions follow)
33-36signCountUnsigned 32-bit big-endian signature counter
37-52aaguidAuthenticator model identifier (present when AT is set)
53-54credentialIdLengthBig-endian length L of the credential ID
55 onwardscredentialId, then credentialPublicKeyL bytes of ID, then the COSE public key as CBOR

At sign-in the authenticator data is just the first 37 bytes plus any extensions: there is no new key to report. UP means someone touched or acknowledged the authenticator. UV means the authenticator verified the user with a PIN or biometric; it is what lets a passkey count as two factors in one gesture. BE says whether this credential can ever be synced, and is fixed for its lifetime. BS says whether it is backed up right now, and can change.

Verification, step by step

Verification is a sequence of checks, and the order is meaningful: cheap structural checks first, the signature last, and nothing written to the database until every check passes. For a sign-in assertion:

  1. Look up the credential by its ID. If it is unknown, fail; for a discoverable flow, also check that the returned userHandle matches the credential's owner.
  2. Parse clientDataJSON: type must be webauthn.get (webauthn.create at registration), challenge must equal the one you issued for this session, and origin must be in your exact allow-list of origins.
  3. Consume the challenge: delete it from session storage so it cannot be replayed, and reject it if it is older than your timeout.
  4. Check that rpIdHash equals SHA-256 of your rpId.
  5. Check that UP is set, and that UV is set if your policy requires user verification.
  6. Verify the signature over authenticatorData || SHA-256(clientDataJSON) with the stored public key.
  7. Update the counter, backup state and last-used time, then create the session.

The core of steps 4 to 6 in Python, using the cryptography package for an ES256 credential, shows how little magic there is:

import hashlib, json, struct
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import ec

UP, UV, BE, BS = 0x01, 0x04, 0x08, 0x10

def verify_assertion(auth_data, client_data_json, signature, cred, rp_id,
                     expected_challenge, allowed_origins, require_uv=True):
    cd = json.loads(client_data_json)
    if cd['type'] != 'webauthn.get':
        raise ValueError('wrong ceremony type')
    if cd['challenge'] != expected_challenge:         # both base64url strings
        raise ValueError('challenge mismatch')
    if cd['origin'] not in allowed_origins:
        raise ValueError('origin not allowed')
    if auth_data[:32] != hashlib.sha256(rp_id.encode()).digest():
        raise ValueError('rpIdHash mismatch')
    flags = auth_data[32]
    if not flags & UP or (require_uv and not flags & UV):
        raise ValueError('user presence or verification missing')
    sign_count = struct.unpack('>I', auth_data[33:37])[0]
    signed = auth_data + hashlib.sha256(client_data_json).digest()
    key = ec.EllipticCurvePublicNumbers(cred.x, cred.y, ec.SECP256R1()).public_key()
    key.verify(signature, signed, ec.ECDSA(hashes.SHA256()))  # raises if invalid; sig is DER
    return sign_count, bool(flags & BS)

A real library also decodes the COSE key, supports RS256 and EdDSA, and handles extensions. Mature options include SimpleWebAuthn for Node, py_webauthn for Python, java-webauthn-server for the JVM and go-webauthn for Go. Read their configuration for rpId, origins and user verification rather than accepting defaults blindly.

Sign-in from the autofill menu

The best passkey sign-in is the one that appears in the browser's autofill menu. This is conditional mediation: the page starts a WebAuthn request in the background, and the browser offers the user's passkeys for this site when they focus the username field. Two pieces make it work. The input carries the autofill token webauthn, and the request passes mediation: 'conditional' with an empty allowCredentials list, so any discoverable credential for the rpId may answer.

<input type="text" name="username" autocomplete="username webauthn">

<script type="module">
if (PublicKeyCredential.parseRequestOptionsFromJSON &&
    await PublicKeyCredential.isConditionalMediationAvailable?.()) {
  const opts = await (await fetch('/webauthn/login/options', { method: 'POST' })).json();
  const cred = await navigator.credentials.get({
    mediation: 'conditional',
    publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(opts),  // allowCredentials: []
  });
  await fetch('/webauthn/login/verify', { method: 'POST', body: JSON.stringify(cred.toJSON()) });
}
</script>

Because no username was typed, the server identifies the account from the returned userHandle and the credential ID. Keep a password or email-link path beside it during migration; conditional mediation must never block the form for users without a passkey.

Synced passkeys, counters and backup flags

Synced passkeys change two assumptions from the hardware-key era. The first is the signature counter. A single-device authenticator increments it on every use, so a counter that goes backwards suggests a cloned key. A synced passkey lives on several devices, and most providers report zero permanently. Treat a counter of zero as "not supported". Only if both the stored and the received values are non-zero and the new one is not greater should you raise a signal, and even then prefer flagging the account for review over hard-failing a user mid sign-in.

The second is the backup flags. BE and BS tell you whether a credential survives device loss. A useful policy: if a user's only credential has BS clear, prompt them to add a second passkey or another recovery path, because losing that device means account recovery. The flags are reported by the authenticator and are not proof, so use them for user experience, not as a security control.

Keeping provider state honest is the job of the signal methods in WebAuthn Level 3: PublicKeyCredential.signalUnknownCredential tells the provider a credential ID no longer exists on the server, signalAllAcceptedCredentials sends the full current list for a user, and signalCurrentUserDetails updates the stored name and display name. Without them a deleted passkey keeps appearing in the autofill menu and fails every time. Support varies by browser, so feature-detect each method; PublicKeyCredential.getClientCapabilities() reports what the client supports.

Failure modes and trade-offs

Failures teams hit in production, roughly in order of frequency:

  • rpId and origin mismatch. The rpId must be the registrable domain or a parent of the page's host. Credentials created with rpId login.example.com cannot be used from www.example.com; choose example.com up front, because changing it later orphans every credential. Separate domains need related origin requests, where /.well-known/webauthn on the rpId lists the other origins, and support is still uneven.
  • Base64 versus base64url. The challenge in clientDataJSON is base64url without padding. Comparing it with a standard base64 string fails intermittently, only for challenges that contain the differing characters.
  • Challenge stored badly. Challenges must be single-use, bound to the session that requested them, and expire. Storing one global challenge, or not deleting it, reopens replay.
  • Personal data in user.id. Using the email as the user handle leaks it to the authenticator and breaks when the email changes.
  • Recovery as the weak link. If an account with a passkey can be recovered by an SMS code, the attacker uses the SMS path. Recovery deserves the same scrutiny as sign-in; session management also matters, because a stolen session cookie bypasses any sign-in method.
  • Counter enforcement on synced keys. Rejecting a non-increasing counter of zero locks out every synced-passkey user.

The trade-off at the centre of passkeys is between recoverability and device binding: synced passkeys are easy to keep and recover, while device-bound keys give stronger assurance about where the key is. Most consumer sites should accept synced passkeys and use device-bound keys only where policy requires them. Passkeys also complement, not replace, delegated authorization; see OAuth 2.0 with PKCE for that layer and CSRF defence for protecting the endpoints that start each ceremony.

What to do next

A checklist to take a site from reading about passkeys to shipping them:

  • Choose the rpId as your registrable domain and list every exact origin that will call WebAuthn.
  • Generate a random user handle for every account and store it; never derive it from personal data.
  • Pick a maintained server library and configure rpId, origins, user verification and accepted algorithms explicitly.
  • Store credential ID, COSE public key, counter, BE and BS flags, transports, name and timestamps per credential.
  • Make challenges single-use, session-bound and short-lived, and test replay and expiry.
  • Add conditional mediation to the sign-in form, keeping existing methods as fallback.
  • Treat a zero counter as unsupported and log, rather than block, counter regressions.
  • Show users their passkeys in account settings, call the signal methods when one is removed, and review recovery paths for anything weaker than the passkey itself.
Key takeaway: A passkey sign-in is a signature over your challenge, your client data and your rpId hash, and the server is secure only if it checks all of them: ceremony type, challenge, exact origin, rpId hash, user presence and verification, then the signature, before storing anything. Choose the rpId once, keep user handles free of personal data, make challenges single-use, treat zero counters as unsupported for synced passkeys, offer autofill sign-in, and give recovery the same scrutiny as sign-in.