Passkeys can remove password-reset friction and make phishing-resistant sign-in available inside a normal web application. The browser API is only the visible part of the feature, though. A reliable implementation needs a server-generated challenge, a credential model, origin and relying-party checks, a recovery path, and tests for the cases where a user changes devices or loses a credential.

Understand the boundary before installing a library

WebAuthn uses public-key cryptography. The browser or authenticator keeps the private key; your database stores a credential ID and public key associated with an account. During sign-in, your server creates a fresh challenge, the authenticator signs it with contextual origin information, and your server verifies the result before creating the session. Your application never receives the private key.

Use a maintained WebAuthn server library for CBOR parsing, authenticator data, signatures, and verification rules rather than implementing the protocol with Node's general-purpose crypto primitives. The important engineering work around that library is your application contract: who may register a credential, which relying-party ID is valid, how challenges expire, and how sessions are created after verification.

  • An HTTPS origin in production; localhost is useful for local development
  • A stable relying-party ID that matches the domain scope you intend
  • A database table for users, credentials, and short-lived challenges
  • A session or token system that already has clear authorization rules
  • A maintained server library and browser support policy

Design the credential and challenge records

A user can have more than one passkey: a phone, a laptop, and a hardware security key may all be legitimate credentials. Store one row per credential with the user ID, credential ID, public key, sign counter when supplied by the library, creation time, last-used time, and a user-facing label. Make the credential ID unique so the same credential cannot be attached to two accounts.

Store registration and authentication challenges separately from long-lived credentials. A challenge should be generated by the server with a cryptographically secure random source, bound to the intended user and flow, consumed once, and rejected after a short expiry. Do not accept a challenge echoed from the browser as proof that your server issued it; the server must compare against its own pending record.

  • Unique constraint on credential ID
  • Explicit tenant or account ownership on every credential lookup
  • Single-use challenge state with an expiry and flow type
  • A record of created, renamed, revoked, and last-used timestamps
  • No private keys, raw assertion secrets, or unnecessary personal data in your database

Implement registration as a two-step server flow

The registration endpoint should authenticate the existing account or use a separately protected account-creation flow, then generate creation options on the server. Include the relying-party name and ID, a stable user ID that is not a mutable email address, a fresh challenge, and an exclude list containing that user's existing credential IDs. Return the options to the browser as JSON.

The browser calls navigator.credentials.create() with those options and posts the returned credential to your verification endpoint. The server-side verifier must check the stored challenge, expected origin, relying-party ID, user-ownership rules, and the authenticator result before inserting the new credential. Only after that transaction succeeds should the UI tell the user the passkey is ready. Give the credential a label the user can edit later.

  • Do not let the client choose the relying-party ID or expected origin
  • Reject a reused, missing, expired, or flow-mismatched challenge
  • Use an immutable account identifier for the WebAuthn user handle
  • Require recent authentication before adding a credential to an existing account
  • Handle duplicate-registration errors without exposing another account

Implement sign-in and session creation separately

For sign-in, create assertion options with a fresh challenge and either allow discoverable credentials or provide an allow list after the user identifies an account. The browser calls navigator.credentials.get(), then sends the assertion to the server. The server looks up the credential, verifies the challenge, origin, relying-party ID, signature, and user-verification policy, and updates the credential metadata only after successful verification.

Create your ordinary application session only after WebAuthn verification returns the account identity. Continue to enforce authorization, tenant membership, CSRF protections for state-changing requests, session rotation, and logout exactly as you would for another login method. A valid passkey proves possession of a credential; it does not authorize every operation in your product.

  • Use a one-time authentication challenge and consume it on success or expiry
  • Compare the expected origin and relying-party ID on the server
  • Decide whether user verification is required for your risk level
  • Rotate or establish the session only after verification completes
  • Keep password or another recovery method during a measured migration

Plan recovery, migration, and device changes

A passkey rollout fails if the first lost phone becomes an account lockout. Let users register multiple credentials, show a manageable list of devices or authenticators, and require recent authentication before renaming or revoking one. Provide a recovery method with a security level appropriate to the account; a support override should be auditable, rate-limited, and resistant to social engineering rather than an undocumented database edit.

Migrate existing users gradually. Offer passkey enrollment after a successful password login, keep the password path until adoption and recovery evidence are strong, and explain what happens on a device that cannot use a passkey. Synced passkeys and device-bound credentials have different recovery and assurance properties; communicate the trade-off instead of promising that every passkey behaves the same way.

  • Enroll at least one backup credential where the account risk warrants it
  • Show the last-used time and a user-editable label
  • Require reauthentication before credential management changes
  • Rate-limit recovery and record support actions with an audit trail
  • Test new-device sign-in, credential revocation, and account recovery

Verify the rollout with adversarial tests

Test the complete flow in a real browser over HTTPS: registration, sign-in, logout, a second credential, and a revoked credential. Then test the boundaries deliberately. Change the origin, replay a consumed challenge, substitute a different user's credential, submit malformed client data, use an expired challenge, and race two verification requests. Every rejection should leave no new credential or authenticated session behind.

Monitor successful and failed ceremonies by reason without logging credential payloads or sensitive authentication data. Alert on unusual verification failures, spikes in recovery, and repeated attempts against one account. Before production, verify the relying-party ID for every environment, document the browser support policy, and confirm that proxies preserve the HTTPS origin users actually see.

  • A challenge cannot be replayed or used in the wrong flow
  • An assertion from another origin or account is rejected
  • Duplicate credential registration has no partial write
  • Revoked credentials cannot create a new session
  • Multiple concurrent verification attempts have one well-defined result
  • Recovery works without silently weakening high-risk accounts
  • Logs contain correlation IDs and reason codes, not raw credential material
Building a workflow like this?Start with a clear scope →