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.
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.
| Column | Why you need it |
|---|---|
| credential_id (unique) | Lookup key for every assertion |
| user_id | Account owner; the user handle maps here |
| public_key (COSE) | Signature verification |
| sign_count | Clone detection where the authenticator supports it |
| transports | Hints so the browser offers the right path next time |
| backup_eligible, backed_up | Synced or device-bound; drives recovery policy |
| aaguid | Authenticator model, when the authenticator reports one |
| created_at, last_used_at, name | Account 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:
- Ship conditional UI on the sign-in page first. It changes nothing for users without passkeys.
- After a successful password sign-in, offer passkey creation once, and again after 30 days if declined.
- Show every passkey in account settings with its name, created and last-used dates, and a delete button.
- 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.
- 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
| Failure | Effect | Prevention |
|---|---|---|
| Challenge reused or not bound to session | Replay of a captured response | Single-use, short-lived, session-bound challenges |
| UV flag not checked | A touch, without PIN or biometric, passes as two factors | requireUserVerification where policy needs it |
| Counter zero treated as clone | Synced passkey users locked out | Treat zero as no counter |
| RP ID changed | Every existing passkey stops working | Choose the registrable domain once |
| Weak recovery | Phishing moves to the reset flow | Multiple passkeys, notified and delayed recovery |
| Email as user handle | Personal data stored on authenticators | Random opaque user handle |
| Deleted credential still offered | Confusing failures at sign-in | Signal 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
- Choose your RP ID and write it down as a decision that will not change.
- Adopt a maintained server library and verify origin, RP ID, challenge, UV and signature on every assertion.
- Model credentials as one-to-many per user, recording backup flags, transports and last use.
- Add conditional UI to the sign-in form and an enrolment prompt after password sign-in.
- Build a settings page that lists, names and deletes passkeys, and call the Signal API where available.
- Define tiers: synced passkeys for consumers, two device-bound keys for administrators.
- Redesign recovery with notifications and delays, then measure recovery requests per thousand users.
- Plan when passkey users lose the password and SMS paths.