Contents
Passwords are still the default way to “log into a site” — and the default way to steal an account. Phishing, reuse, breached databases, fatigue from one-time codes. Passkeys promise a different contract: the user confirms with a fingerprint, face, or device PIN, and the server verifies a cryptographic signature instead of a string that can be typed into a fake form.
By 2026 this is no longer a lab toy. Industry reports show most large organizations deploying or piloting passkeys, and public-web support keeps growing through native implementations and identity providers. This site had a gap: we covered JWT mistakes in Node.js and npm supply-chain hardening, but not a map of “how to ship WebAuthn so it survives production.” Below is that pillar: browser ceremonies, server duties, synced vs device-bound keys, recovery, password migration, and the failure modes demos hide.
Key takeaways
A passkey is a credential on an authenticator, not “another password in a manager.” You store a public key and metadata. You never store the secret that signs the login.
Without server checks of challenge, origin, and RP ID, a passkey is theatre. The browser shows a nice dialog; security starts in a FIDO library on the backend.
Synced keys and device-bound keys solve different jobs. Synced keys are kinder to people and survive a phone swap. Device-bound keys fit policies that demand “this laptop, this token.”
Account recovery is part of the product, not a phase-two ticket. A single key with no backup path loses the customer. A password as the only fallback reopens phishing.
After a successful WebAuthn ceremony you usually still issue a session. The passkey replaces proof of identity at the door. After that you still run cookies, refresh tokens, and short-lived access tokens — with the discipline from the JWT note.
How a passkey differs from a password and a one-time code
A password is a shared secret. The user and the server both know it (ideally the server only knows a hash). It can be harvested by a lookalike domain, watched, pulled from a breach, or guessed. A one-time code from SMS or an app strengthens a second factor, but the code can still be intercepted or socially engineered. Both models rely on a human transmitting a secret over a channel attackers know how to imitate.
A passkey is a key pair. The private key never leaves the authenticator: Secure Enclave, TPM, phone module, hardware key. The public key lives in your database. At sign-in the server issues a one-time challenge. The authenticator signs it only for your site (more precisely, for the registered relying-party id) and only after local user verification. That signature cannot be “typed” into a phishing page: the browser will not release the credential to a foreign origin.
Practical product contrast:
| Mechanism | What gets stolen | Phishing resistance | User fatigue |
|---|---|---|---|
| Password | A string | Low | High |
| Password + SMS | String and code | Medium | High |
| TOTP | String and code | Medium | Medium |
| Passkey | Needs the device plus biometrics/PIN | High | Low on a familiar device |
“High phishing resistance” does not mean “the account cannot be taken.” A stolen unlocked laptop, social engineering of support, or weak recovery still work. What breaks is the classic fake-login form that harvests passwords.
A glossary so the spec stops sounding like noise
WebAuthn — the web API (navigator.credentials.create / get) and the server verification model. This is the browser language.
FIDO2 / CTAP — how an authenticator talks to the platform. You rarely touch CTAP directly for an ordinary site.
Relying Party (RP) — your service. It has an rpId: usually the domain without scheme or port, for example example.com. A credential created for example.com will not work on evil.com.
Authenticator — what holds the private key: a platform authenticator (phone, laptop) or a roaming one (USB/NFC/BLE).
Credential — a concrete pair: credentialId + public key + counter/flags + user binding.
Discoverable credential (resident key) — a credential the authenticator can offer without a username first. That powers one-button passkey sign-in and Conditional UI.
User verification (UV) — local proof of a person: biometrics or PIN. A flag in the response shows whether it happened.
Attestation — proof of authenticator model. Consumer products often skip requiring it. Enterprises sometimes need it for an allowlist of devices.
Synced passkey — the private key is copied by an ecosystem (iCloud Keychain, Google Password Manager, a password manager). Convenient when the phone changes.
Device-bound passkey — the private key does not leave the hardware. Stricter for regulated flows, harsher when the device dies.
Two ceremonies: registration and sign-in
Registration
- The user has already proved the account somehow (password session, email link, invite).
- The server generates a random challenge (≥16 bytes), stores it with a short TTL, and returns
PublicKeyCredentialCreationOptions:rp,user,challenge,pubKeyCredParams,authenticatorSelection,attestation. - The browser calls
navigator.credentials.create({ publicKey: options }). - The user confirms on the device.
- The client returns the credential. The server checks challenge, origin,
rpId, flags, then storescredentialId, public key, transports, counter. - Delete the challenge on success and on failure. Never reuse it.
Sign-in
- The server issues a fresh challenge and
PublicKeyCredentialRequestOptions. For discoverable credentialsallowCredentialsmay be[]— the authenticator offers matching keys. - The browser calls
navigator.credentials.get. - The user confirms.
- The server finds the credential by id, verifies the signature over the challenge, origin,
rpId, UP/UV flags, optionally the counter, then creates an application session. - The challenge is single-use again.
On Node.js and other stacks, do not hand-roll verification from a blog post. Use a maintained FIDO library (@simplewebauthn/server and peers). The specification is long, the edges are sharp, and updates arrive in batches. A library does not remove your duty to set rpId and origin correctly — it removes the risk of mis-parsing CBOR.
What the server must do (and what the client must not decide)
The client can send anything. Trust only what you re-check.
Minimum sign-in checks:
- The challenge exists, has not expired, has not been used, and belongs to this attempt.
- The response
originmatches the expected value (https://app.example.com). rpIdmatches the one used at registration.- The credential is known and belongs to the user (or you explicitly support usernameless lookup by credentialId — then rate limits matter even more).
- The signature verifies with the stored public key.
- User presence is set. If you requested
userVerification: "required", UV must be true. Persist the requested UV level in the server-side attempt record; do not take it from the client. - Sign count, when the authenticator provides it: the new value must not be lower than the previous one. A drop is a cloning signal; policy is usually to alert or revoke.
Separately: do not import JWT shortcuts here. After WebAuthn you create a session. How you sign the access token is the story in JWT mistakes. Confusing “passkey succeeded” with “eternal JWT in localStorage” is a short path to an incident.
Synced and device-bound keys: a product choice
A synced key survives a phone swap inside an ecosystem. For consumer SaaS that is usually the right default: fewer tickets about a new iPhone. The cost is trust in the vendor cloud and in the Apple/Google account that protects the vault.
A device-bound key does not travel. Regulated admin panels and “this YubiKey” policies like it. The cost is a mandatory recovery path and training people not to own a single token.
Mature rollouts often mix both: synced for ordinary users, device-bound for privileged roles. That matches 2026 industry surveys: many deploying organizations do not bet on a single type.
authenticatorSelection and resident-key requirements set the behaviour. One-button sign-in needs discoverable credentials. “Username first, then known keys” can pass allowCredentials with ids. Conditional UI (autofilling the username field with passkeys) depends on discoverable credentials and browser support — probe with PublicKeyCredential.getClientCapabilities where available.
Recovery: where “perfect” launches die
The most common product failure: enable passkeys, disable passwords, forget phone loss. The user is on holiday without a backup path — and support invents a temporary password by hand, opening a hole wider than before.
Working strategies (combine them):
- Several keys per account. Second phone, hardware key, work laptop. The UI should ask for a backup, not hide it in settings.
- Recovery through an already proven channel. Email to a verified address with a one-time link that only allows registering a new key, not a full unrestricted session.
- Hardware recovery codes shown once when passwordless mode is enabled. Store hashes like passwords. Rate-limit hard.
- Password as a temporary fallback during migration — with a plan to turn it off and without asking for the password on every page.
- Support with a ritual. Video call, document check, delay — expensive, sometimes inevitable for banks and admin tools. Support must not have a “reset to qwerty” button.
New browser Signal APIs let the server tell the authenticator that a credential is unknown or that the accepted list changed. That is UX sync (“stop offering a revoked key”), not a replacement for your database. If the browser lacks the API, degrade quietly.
Migrating from passwords without a second incident
Almost nobody can kill passwords overnight. A typical ladder:
- Add a passkey beside the password. After a normal login, gently ask to create a key on this device.
- Prefer the key. Put passkey / Conditional UI on top; password behind “another way.”
- Narrow the password. Ban it for admins. Require a key for sensitive actions (email change, payouts) even inside a session — step-up.
- Disable the password for segments that look ready: ≥2 keys, recovery present, support trained.
Do not merge “mandatory passkey” and “we deleted every other factor” into one release. And do not store passwords “just in case” in clear text in support tickets.
For corporate SSO the picture differs: the passkey often lives at the identity provider, and your app keeps accepting OIDC. You may not need WebAuthn code at all — but you still need step-up and clarity about whether the IdP key is device-bound. Outsourcing to an IdP does not erase a weak recovery story at the provider.
Client sketch in TypeScript
Not a paste-into-prod snippet — a responsibility sketch. Options always come from the server.
// registration
const options = await fetch('/auth/webauthn/register/options', {
method: 'POST',
credentials: 'include',
}).then((r) => r.json())
const credential = await navigator.credentials.create({
publicKey: PublicKeyCredential.parseCreationOptionsFromJSON
? PublicKeyCredential.parseCreationOptionsFromJSON(options)
: options,
})
await fetch('/auth/webauthn/register/verify', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(credential),
})
// authentication (discoverable)
const options = await fetch('/auth/webauthn/login/options', {
method: 'POST',
credentials: 'include',
}).then((r) => r.json())
const assertion = await navigator.credentials.get({
publicKey: {
...options,
allowCredentials: [],
},
mediation: 'optional',
})
await fetch('/auth/webauthn/login/verify', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(assertion),
})
Feature-detect the API. On older browsers show a fallback instead of a blank screen. Do not log full attestation blobs to open logs.
Typical production mistakes
- Wrong
rpId. Registered onwww.example.com, verified onexample.com— or the reverse. Keys “mysteriously” vanish. - Challenge in one node’s memory without sticky sessions. Options from instance A, verify on B. Use a shared store with TTL.
- Challenge without TTL or single-use. Replay becomes a gift.
- Trusting
userVerificationfrom the client. Persist the requested level server-side. - One key per account and password disabled. Device loss equals customer loss.
- Passkey “success,” eternal session in localStorage. Return to short access tokens and refresh rotation.
- Ignoring sign count when the authenticator provides it. A cloned key stays quiet.
- Mixing staging and production RP IDs.
localhostkeys do not transfer — and must not. Build a separate local contour; do not “temporarily disable origin checks.” - Weak rate limits on usernameless login. Noise against challenges and credential ids needs limits and monitoring.
attestation: "required"without a policy. You break consumer key managers for a checkbox you never enforce.
How to test
Minimum manual path:
- Register a key in a clean browser profile.
- Sign out, sign in with only the key.
- Revoke the key in the UI; confirm login fails and Signal (if present) stops offering it.
- Add a second key, remove the first — login still works.
- Expire a challenge and reuse a challenge — both must fail.
- Repeat on a second browser/phone for the synced case.
- For device-bound — confirm the key does not appear on a second device.
Automation can use virtual authenticators via Chrome DevTools Protocol and Playwright. That does not replace a real phone, but it catches options/verify regressions. Also run multi-instance verify: options on one node, verify on another.
Where passkeys fit especially well
- Consumer accounts with frequent phone sign-in.
- Admin and internal panels (often device-bound + hardware key).
- Step-up before money movement, email changes, data export.
- Field apps where typing a password on a shared keyboard is its own risk; pair with offline-first writes when the session and the local queue share one UX.
Be careful with audiences without smartphones, kiosks, library PCs, and regions with constrained ecosystems. There passkeys are an addition, not the only door.
FAQ
Do passkeys fully replace passwords already?
Technically for users who have a key and recovery — yes. Organizationally you almost always need a transition. Turn passwords off when metrics and support are ready, not when the blog post ships.
Do I need WebAuthn if I already have Google / Apple Login?
Not necessarily. If the IdP already gives a strong factor, focus on a correct OIDC session and step-up. Own WebAuthn when you hold identity yourself or want a key bound to your RP.
How is a passkey different from “sign in with Windows Hello” we saw years ago?
Often it is the same platform authenticator. The difference is whether the credential is bound to your rpId through WebAuthn and whether you verify the signature on your server.
What do I store in the database?
credentialId, public key (or COSE), algorithm, transports, signCount, createdAt, a friendly name (“Maria’s iPhone”), type if known, userId. No user secret.
Can I use passkeys as API tokens for services?
Not as long-lived CI secrets. A passkey is an interactive human factor. Machines need client credentials, CI signing keys, or workload identity. Do not mix them.
How does this relate to JWT?
WebAuthn answers “is this the person at the door?” JWT/session cookies answer “is this browser already signed in for the next N minutes?” After verify, issue a short-lived access token and a managed refresh — see JWT mistakes.
Further reading
Nearby on this site: JWT in Node.js, npm supply-chain hardening, offline-first React writes, React stack 2026, the install element for PWAs. External anchors: Google’s server-side passkey guides and FIDO Alliance / passkeys.dev library lists.
Conclusion
Passkeys move the secret from “a string a human types into forms” to “a private key that never leaves the authenticator.” WebAuthn is a browser ceremony plus boring, mandatory server checks. Product maturity starts where the demo ends: a second key, recovery, correct rpId, a one-time challenge, an honest password migration, and a session after login that is not an eternal token in localStorage.
In one line: prove identity with a signature, then issue a short session, and always leave a way back that does not humiliate support. That is duller than ads about a passwordless world — and it survives the first lost phone.
Four questions for your contour. Where does the private key live? On the authenticator, not with you. What do you check on verify? Challenge, origin, rpId, signature, flags. What if there is one key and the phone sinks? A recovery path written in advance. What does the browser get after success? A finite session, not a new eternal password in another costume.



Comments