WebAuthn is the W3C browser API, and CTAP2 the FIDO Alliance protocol beneath it, that let a website authenticate users with public-key cryptography instead of shared secrets. A passkey is a WebAuthn credential the user can sign in with directly, usually without typing a username, and often synced across their devices by a passkey provider such as their phone's platform or a password manager.

This article is about the architecture of a passkey deployment rather than the byte layout of one response: which components hold which secrets, how the two ceremonies flow, what your credential store must record, how to choose policies per account tier, and where real deployments fail, which is mostly around recovery and lifecycle. You will finish with server and browser code and a rollout plan.

Who holds what: private keys stay with the authenticator, the server holds public keysPlatform authenticatorOS keychain, TPM or enclaveSecurity keyUSB / NFC, device-boundPhone via hybridQR + BLE proximity + relayBrowser / OS clientchecks origin vs RP ID, builds clientDataJSONlocal APICTAP2CTAP2 hybridRelying party serverchallenges, verificationCredential storepublic key, flags, counterRecovery + sessionthe next weakest linkoptions / responsePasskey provider syncend-to-end encrypted copiesSynced passkeys trade device binding for recoverability;the sync provider joins your threat model.
Passkey architecture: authenticators hold private keys, the browser binds every signature to the origin, and the relying party stores only public keys.

Why passkeys change the architecture

Three properties make WebAuthn an architectural change rather than a stronger password.

The server holds no secret. The credential table stores public keys. A stolen copy lets an attacker verify signatures, not create them, so there is nothing to crack offline and nothing to stuff into other sites.

Phishing resistance is enforced by the browser. One-time codes and push approvals fail against a real-time phishing proxy, because the human relays a secret that works from anywhere. With WebAuthn the browser writes the page's actual origin into clientDataJSON, which the authenticator's signature covers, and only offers credentials scoped to an RP ID that matches the origin. A lookalike domain cannot obtain a usable assertion, however convincing the page.

Recovery becomes the attack surface. Once sign-in cannot be phished, attackers target whatever path lets a user back in without the passkey: email reset links, SMS codes, support desks. Your architecture is only as strong as that path.

Why passkeys change the architecture

Three properties make WebAuthn an architectural change rather than a stronger password.

The server holds no secret. The credential table stores public keys. A stolen copy lets an attacker verify signatures, not create them, so there is nothing to crack offline and nothing to stuff into other sites.

Phishing resistance is enforced by the browser. One-time codes and push approvals fail against a real-time phishing proxy, because the human relays a secret that works from anywhere. With WebAuthn the browser writes the page's actual origin into clientDataJSON, which the authenticator's signature covers, and only offers credentials scoped to an RP ID that matches the origin. A lookalike domain cannot obtain a usable assertion, however convincing the page.

Recovery becomes the attack surface. Once sign-in cannot be phished, attackers target whatever path lets a user back in without the passkey: email reset links, SMS codes, support desks. Your architecture is only as strong as that path.

The components

Authenticators come in two families. Platform authenticators are built into the device and unlocked with its biometric or PIN. Roaming authenticators, such as USB or NFC security keys, are separate hardware. A third arrangement, the hybrid transport, lets a phone act as the authenticator for a nearby computer: the computer shows a QR code, the phone scans it, a Bluetooth advertisement proves the two are physically close, and the protocol messages travel through a relay service.

Device-bound versus synced is the distinction that matters most. A security key generates its private key in hardware and never exports it. A synced passkey is copied, end-to-end encrypted, to the user's other devices by the passkey provider. Synced passkeys are therefore not 'non-exportable'; they trade hardware binding for the ability to survive a lost phone. The authenticator reports which kind it is through two flags: backup eligible (BE) and backup state (BS).

The client (browser or OS) exposes navigator.credentials.create() and .get(), checks that the calling origin may use the requested RP ID, assembles clientDataJSON with the ceremony type, challenge and origin, and talks to the authenticator. The relying party (your server) issues challenges, verifies responses and stores credentials.

The two ceremonies

Registration. The server creates a random, single-use challenge bound to the session, and sends creation options: RP ID and name, a stable opaque user handle (never an email address), accepted algorithms with ES256 (-7) first and RS256 (-257) for compatibility, a request for a discoverable credential, a user verification preference, and excludeCredentials listing the user's existing credential IDs so the same authenticator is not enrolled twice. The authenticator creates a key pair, and the server verifies the response and stores the public key.

Authentication. The server sends a fresh challenge. With discoverable credentials it can leave allowCredentials empty: the authenticator offers whichever passkeys it holds for this RP ID, and the response carries the user handle, which identifies the account. The authenticator signs the authenticator data concatenated with the SHA-256 hash of clientDataJSON, and the server verifies that signature with the stored public key.

Conditional UI puts passkeys into the browser's autofill menu on an ordinary username field, so users who have a passkey just pick it and everyone else types as before:

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

<script>
async function startConditionalSignIn() {
  if (!window.PublicKeyCredential?.isConditionalMediationAvailable ||
      !(await PublicKeyCredential.isConditionalMediationAvailable())) return;
  const opts = await (await fetch("/webauthn/auth/options")).json();
  const cred = await navigator.credentials.get({
    mediation: "conditional",
    publicKey: {
      challenge: b64urlToBytes(opts.challenge),
      rpId: opts.rpId,
      allowCredentials: [],          // discoverable: let the user pick
      userVerification: "preferred",
    },
  });
  await fetch("/webauthn/auth/verify", {method: "POST",
    headers: {"Content-Type": "application/json"},
    body: JSON.stringify(serialise(cred))});
}
startConditionalSignIn();
</script>

Server-side verification

Use a maintained library for verification rather than parsing CBOR and COSE keys yourself. With the SimpleWebAuthn server library for Node.js the authentication check looks like this:

import { verifyAuthenticationResponse } from "@simplewebauthn/server";

const cred = await db.credentials.findById(body.id);       // look up by credential ID
if (!cred) return unknownCredential(res, body.id);          // see the Signal API below

const { verified, authenticationInfo } = await verifyAuthenticationResponse({
  response: body,
  expectedChallenge: session.challenge,      // single use: delete it now
  expectedOrigin: "https://example.com",
  expectedRPID: "example.com",
  credential: { id: cred.id, publicKey: cred.publicKey,
                counter: cred.signCount, transports: cred.transports },
  requireUserVerification: policy.requireUV,  // true for admin tiers; platform passkeys always do UV
});
delete session.challenge;
if (!verified) return deny(res);

await db.credentials.update(cred.id, {
  signCount: authenticationInfo.newCounter,
  backedUp: authenticationInfo.credentialBackedUp,
  lastUsedAt: new Date(),
});

Treat the signature counter with care. Device-bound authenticators usually increment it, and a counter that goes backwards suggests a cloned key. Synced passkeys commonly report zero on every use, because several devices share the key, so zero must mean 'no counter', not 'possible clone'.

Registration and what to store

Registration is verified the same way, and the result is what you store. Check user verification here too, and record whether the new credential is synced so recovery policy can depend on it:

import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";

const options = await generateRegistrationOptions({
  rpName: "Example", rpID: "example.com",
  userName: user.email,                       // display only
  userID: user.handleBytes,                   // random opaque bytes, not the email
  excludeCredentials: user.credentials.map(c => ({ id: c.id, transports: c.transports })),
  authenticatorSelection: { residentKey: "required", userVerification: "preferred" },
});
session.challenge = options.challenge;

// ...later, when the browser posts the result...
const { verified, registrationInfo } = await verifyRegistrationResponse({
  response: body, expectedChallenge: session.challenge,
  expectedOrigin: "https://example.com", expectedRPID: "example.com",
  requireUserVerification: policy.requireUV,  // match the tier policy, not a blanket true
});
delete session.challenge;
if (verified) {
  const { credential, credentialDeviceType, credentialBackedUp, aaguid } = registrationInfo;
  await db.credentials.insert({ id: credential.id, userId: user.id,
    publicKey: credential.publicKey, signCount: credential.counter,
    transports: credential.transports, multiDevice: credentialDeviceType === "multiDevice",
    backedUp: credentialBackedUp, aaguid, createdAt: new Date() });
}

The credential store

The credential store is a one-to-many table from user to credentials. Plan for several per user from day one; a user with a phone, a laptop and a security key should be normal, not an edge case.

ColumnWhy you need it
credential_id (unique)Lookup key for every assertion
user_idAccount owner; the user handle maps here
public_key (COSE)Signature verification
sign_countClone detection where the authenticator supports it
transportsHints so the browser offers the right path next time
backup_eligible, backed_upSynced or device-bound; drives recovery policy
aaguidAuthenticator model, when the authenticator reports one
created_at, last_used_at, nameAccount settings page and stale-credential cleanup

Policy: RP ID, tiers and lifecycle

RP ID. Pick it once. Credentials are scoped to the RP ID, and changing it later orphans every passkey. Using the registrable domain, such as example.com, lets credentials work on all its subdomains. Brands that span several domains, such as country-code variants, can use Related Origin Requests: the RP publishes a list of allowed origins at https://{RP ID}/.well-known/webauthn, and supporting browsers let those origins use the RP ID. Chrome caps the list at five distinct registrable-domain labels.

Tiers. Consumers get synced passkeys with user verification preferred; recoverability matters more than hardware binding. Administrators and high-value operations get device-bound security keys, user verification required, and possibly attestation, which is a certificate chain at registration that identifies the authenticator model so you can enforce an allowlist. Most synced passkeys provide no attestation, so do not require it from consumers.

Keeping providers in sync. When a user deletes a passkey in your settings page, their passkey provider still offers it. The WebAuthn Signal API addresses this. PublicKeyCredential.signalUnknownCredential() tells the provider that a credential ID the user just tried is unknown to you, and it is safe to call without signing the user in. signalAllAcceptedCredentials() sends the full list of valid credential IDs for a user, so call it only after authentication, since it reveals how many passkeys the account has. Chrome and Edge support it from version 132; feature-detect everywhere.

Operating passkey sign-in

Log every ceremony with its outcome and a reason code: challenge expired, origin mismatch, unknown credential, user verification missing, signature invalid, user cancelled. Most failures in production are cancellations and timeouts from users who did not understand the prompt, and they look identical to attacks unless the reason is recorded. Alert on spikes in origin mismatches, which can indicate a misconfigured deploy or a phishing campaign probing your users, and on bursts of recovery requests.

Keep challenges in a store shared by all application servers, with a lifetime of a few minutes, so that a load balancer sending the verify request to a different instance does not fail the ceremony. Notify the user by email whenever a passkey is added or removed; it is the cheapest detection you have for account takeover through the recovery path.

Worked example: rolling out passkeys to a million users

Consider a site with one million monthly active users on passwords plus SMS codes. A workable rollout:

  1. Ship conditional UI on the sign-in page first. It changes nothing for users without passkeys.
  2. After a successful password sign-in, offer passkey creation once, and again after 30 days if declined.
  3. Show every passkey in account settings with its name, created and last-used dates, and a delete button.
  4. Once a user has a passkey, stop offering SMS as a sign-in factor for them; keep it only inside a recovery flow with extra friction and a notification to the user.
  5. For staff accounts, require two registered security keys and remove all other factors.

Plan with your own numbers. If 20 percent of active users enrol in the first quarter, that is 200,000 accounts and perhaps 300,000 credentials at 1.5 per user. The store is small; the load to plan for is support. Measure enrolment prompt acceptance, passkey sign-in success rate, fallback rate to password, and recovery requests per thousand passkey users, which is the metric that tells you whether attackers have moved to your recovery path.

Failure modes

FailureEffectPrevention
Challenge reused or not bound to sessionReplay of a captured responseSingle-use, short-lived, session-bound challenges
UV flag not checkedA touch, without PIN or biometric, passes as two factorsrequireUserVerification where policy needs it
Counter zero treated as cloneSynced passkey users locked outTreat zero as no counter
RP ID changedEvery existing passkey stops workingChoose the registrable domain once
Weak recoveryPhishing moves to the reset flowMultiple passkeys, notified and delayed recovery
Email as user handlePersonal data stored on authenticatorsRandom opaque user handle
Deleted credential still offeredConfusing failures at sign-inSignal API where supported

Trade-offs

Synced passkeys give most users phishing resistance with almost no recovery burden, at the cost of trusting the provider's account security and end-to-end encryption. Device-bound keys remove that dependency and give you attestation, at the cost of hardware logistics and lockouts when keys are lost. Passwords will remain as a fallback for some time, and while they remain, an attacker can still phish them, so the full benefit arrives only when passkey users lose the password path.

Read on: the relying-party view of WebAuthn responses, session management after sign-in, OAuth 2 with PKCE and JWTs versus session tokens.

What to do next

  1. Choose your RP ID and write it down as a decision that will not change.
  2. Adopt a maintained server library and verify origin, RP ID, challenge, UV and signature on every assertion.
  3. Model credentials as one-to-many per user, recording backup flags, transports and last use.
  4. Add conditional UI to the sign-in form and an enrolment prompt after password sign-in.
  5. Build a settings page that lists, names and deletes passkeys, and call the Signal API where available.
  6. Define tiers: synced passkeys for consumers, two device-bound keys for administrators.
  7. Redesign recovery with notifications and delays, then measure recovery requests per thousand users.
  8. Plan when passkey users lose the password and SMS paths.
Key takeaway: WebAuthn moves authentication to public keys bound to an origin, so the server stores nothing worth stealing and phishing pages cannot obtain a usable signature. The real design work is around it: a fixed RP ID, a one-to-many credential store, verification of every flag, policy per account tier, and a recovery path that does not quietly reopen the door.