Implementing Passkeys and WebAuthn

Passwords are the single largest credential-theft surface in production authentication, and passkeys retire that surface by replacing shared secrets with origin-bound public-key cryptography. This walkthrough is part of the Modern Authentication Fundamentals guide, and it covers the full WebAuthn model end to end: how the relying party, the authenticator, and the browser cooperate across a registration ceremony and an authentication ceremony, and how to ship both with @simplewebauthn/server and @simplewebauthn/browser without leaving exploitable gaps.

WebAuthn is a W3C Recommendation (Web Authentication: An API for accessing Public Key Credentials, Level 2/3), and it sits on top of the FIDO Alliance’s CTAP2 protocol that lets browsers talk to authenticators over USB, NFC, BLE, or the platform’s internal secure element. Together they are marketed as FIDO2. A “passkey” is simply a discoverable WebAuthn credential — a public-key credential the authenticator can find and present without the server first naming it — often synced across a user’s devices through iCloud Keychain, Google Password Manager, or a third-party manager.

The WebAuthn model

Three roles participate in every ceremony:

  • Relying party (RP): your server and its origin. The RP is identified by an RP ID — a registrable domain suffix such as example.com — and credentials are cryptographically scoped to it. This scoping is what makes WebAuthn phishing-resistant: a credential minted for example.com will not be offered on examp1e.com, because the browser refuses to match the origin.
  • Authenticator: the hardware or software that generates and stores the key pair. Platform authenticators are bound to one device (Touch ID, Windows Hello, an Android device’s secure element). Roaming authenticators are portable (a YubiKey or any CTAP2 security key) and can move between machines.
  • Public-key credential: an asymmetric key pair. The private key never leaves the authenticator; only the public key is sent to and stored by the RP. Each credential carries a unique credential ID the RP uses to address it later.

Two ceremonies use these roles. Registration (also called attestation, or “make credential”) creates a new key pair and hands the public key to the server. Authentication (also called assertion, or “get assertion”) proves possession of an existing private key by signing a server challenge. Both are anchored by a server-generated, single-use challenge and by origin binding performed in the browser.

The WebAuthn registration and authentication ceremonies Registration: the server issues a challenge, the authenticator creates a key pair and returns an attestation, and the server stores the credential id, public key and counter. Authentication: the server issues a fresh challenge, the authenticator signs it with the private key, and the server verifies the signature, origin and counter before starting a session. Authenticator Browser Relying party Registration request options challenge · rpID · user handle credentials.create() new key pair · attestation store id · public key · counter Authentication fresh challenge credentials.get() signature over challenge + origin verify · then set session cookie
The private key never crosses a lane boundary. Everything the server sees is a public key and signatures over challenges it issued itself.

Prerequisites

Before writing any code, confirm the following — most production WebAuthn bugs trace back to one of these being wrong:

  • HTTPS everywhere except localhost. WebAuthn requires a secure context. localhost is exempt for development.
  • A stable RP ID. Decide whether your RP ID is example.com (covers all subdomains, where the browser allows it) or app.example.com (narrower). The RP ID must be the registrable suffix of the page origin. Changing it later invalidates every existing credential.
  • An exact expected origin list, e.g. https://app.example.com. The browser reports the full origin during the ceremony and the server must compare it byte-for-byte.
  • Per-user, server-side challenge storage (a session or short-TTL store). The challenge is one-time and must be read back during verification.
  • A credentials table keyed by credential ID, storing the public key, the signature counter, the transports, and the owning user. Plan this schema before you start.

Install the libraries:

npm install @simplewebauthn/server @simplewebauthn/browser

Server + client implementation

The four endpoints below form a complete passkey system. The deep mechanics of the first pair live in the passkey registration ceremony walkthrough; the second pair are dissected in verifying passkey authentication assertions.

1. Shared configuration

Centralize the RP identity so every endpoint agrees on it. Drift between these constants is the most common cause of RP ID mismatch and origin mismatch failures.

// webauthn-config.ts
export const rpName = "Example App";
export const rpID = process.env.WEBAUTHN_RP_ID ?? "app.example.com";
export const origin = process.env.WEBAUTHN_ORIGIN ?? `https://${rpID}`;

2. Registration options (server)

// routes/register-options.ts
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { rpName, rpID } from "../webauthn-config";

export async function registerOptions(req, res) {
  const user = req.user; // authenticated or pending account
  const existing = await db.getCredentialsForUser(user.id);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: user.email,
    userID: new TextEncoder().encode(user.id),
    attestationType: "none",
    // Block re-registering an authenticator the user already enrolled.
    excludeCredentials: existing.map((c) => ({
      id: c.credentialID,
      transports: c.transports,
    })),
    authenticatorSelection: {
      residentKey: "required",      // make it a discoverable passkey
      userVerification: "preferred",
    },
    supportedAlgorithmIDs: [-7, -257], // ES256, RS256 — explicit allowlist
  });

  // Single-use challenge, bound to this user, server-side.
  await sessionStore.set(req.sessionId, { currentChallenge: options.challenge });
  res.json(options);
}

The explicit supportedAlgorithmIDs allowlist (COSE -7 = ES256, -257 = RS256) is deliberate: it refuses any algorithm you have not vetted. There is no WebAuthn equivalent of a JWT alg: none because the structure is fixed, but pinning algorithms keeps weak or experimental curves out of your credential store.

3. Registration verify (server)

// routes/register-verify.ts
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { rpID, origin } from "../webauthn-config";

export async function registerVerify(req, res) {
  const { currentChallenge } = await sessionStore.get(req.sessionId);
  if (!currentChallenge) return res.status(400).json({ error: "no challenge" });

  const verification = await verifyRegistrationResponse({
    response: req.body,
    expectedChallenge: currentChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    requireUserVerification: false,
  });

  if (!verification.verified || !verification.registrationInfo) {
    return res.status(400).json({ error: "verification failed" });
  }

  const { credential } = verification.registrationInfo;
  await db.saveCredential({
    userId: req.user.id,
    credentialID: credential.id,
    publicKey: credential.publicKey,    // store as bytea/blob
    counter: credential.counter,        // signature counter starts here
    transports: req.body.response.transports ?? [],
  });

  await sessionStore.delete(req.sessionId); // burn the challenge
  res.json({ verified: true });
}

4. Authentication ceremony (server)

// routes/login-options.ts
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { rpID } from "../webauthn-config";

export async function loginOptions(req, res) {
  const options = await generateAuthenticationOptions({
    rpID,
    userVerification: "preferred",
    // allowCredentials omitted -> usernameless / discoverable login.
  });
  await sessionStore.set(req.sessionId, { currentChallenge: options.challenge });
  res.json(options);
}
// routes/login-verify.ts
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { rpID, origin } from "../webauthn-config";

export async function loginVerify(req, res) {
  const { currentChallenge } = await sessionStore.get(req.sessionId);
  const credential = await db.getCredentialById(req.body.id);
  if (!credential || !currentChallenge) return res.status(400).end();

  const verification = await verifyAuthenticationResponse({
    response: req.body,
    expectedChallenge: currentChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credentialID,
      publicKey: credential.publicKey,
      counter: credential.counter,
      transports: credential.transports,
    },
    requireUserVerification: false,
  });

  if (!verification.verified) return res.status(401).end();

  // Clone-detection: persist the advanced counter.
  await db.updateCounter(credential.credentialID, verification.authenticationInfo.newCounter);
  await sessionStore.delete(req.sessionId);

  // Bind the verified passkey to a fresh server-side session.
  await issueSession(res, credential.userId);
  res.json({ verified: true });
}

5. Browser glue

@simplewebauthn/browser handles base64url encoding and the navigator.credentials calls so you never touch raw ArrayBuffers:

// client.ts
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";

export async function enroll() {
  const optionsJSON = await fetch("/register/options", { method: "POST" }).then((r) => r.json());
  const attResp = await startRegistration({ optionsJSON });
  await fetch("/register/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(attResp),
  });
}

export async function signIn() {
  const optionsJSON = await fetch("/login/options", { method: "POST" }).then((r) => r.json());
  const asseResp = await startAuthentication({ optionsJSON });
  await fetch("/login/verify", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(asseResp),
  });
}

Validation & testing

  • Round-trip in Chrome DevTools. Open the WebAuthn tab (More tools → WebAuthn), enable a virtual authenticator, and run both ceremonies. The virtual authenticator lets you simulate platform and roaming devices and increment counters without hardware.
  • Confirm the challenge is consumed. Replay the same /register/verify body twice; the second call must fail because the challenge was deleted.
  • Assert origin rejection. Point a second origin at the API and confirm verifyAuthenticationResponse throws on the mismatch.
  • Inspect stored material. The publicKey should be raw COSE bytes, the counter an integer, and credentialID base64url — log nothing else.

Common misconfigurations

Symptom Root cause Fix
The RP ID is not a registrable domain suffix RP ID doesn’t match the page origin’s domain Set rpID to the registrable suffix of the serving origin; never an arbitrary string
Verification always fails with origin error expectedOrigin includes a port/scheme that differs from the browser’s reported origin Compare the exact https://host[:port] the browser sends; keep one source of truth
Challenge mismatch on every attempt Challenge not stored, or stored globally instead of per-session Persist the challenge server-side keyed to the user’s session and read it back on verify
Stolen credential keeps working forever Signature counter never persisted or never compared Store newCounter after each assertion and reject non-increasing counters
Users can’t log in across devices Credential created non-discoverable (residentKey: "discouraged") Set residentKey: "required" to mint a true passkey

Security implications

WebAuthn’s core property is phishing resistance through origin binding. Because the browser injects the real origin into the signed client data and the authenticator scopes keys to the RP ID, a credential cannot be exercised on a look-alike domain — the attack that defeats passwords, TOTP codes, and SMS OTPs simply does not apply. The private key is non-exportable from the authenticator, so server breaches leak only public keys, which are useless to an attacker.

The signature counter provides clone detection: a hardware authenticator increments a monotonic counter on each assertion, so a non-increasing value signals a duplicated credential and should fail closed. (Synced passkeys frequently report a counter of zero across devices, so treat zero as “counter not supported” rather than an error — covered in the authentication assertions page.) Because a successful assertion only proves possession, you must still bind it to a regenerated, HttpOnly, Secure cookie session and rotate the session ID on login to close session fixation windows.

Passkeys do not eliminate the need for a recovery path or for layered factors. Pair them with the multi-factor authentication TOTP and FIDO2 guide for accounts that still keep a password, and plan passkey account recovery and fallback strategies before launch so a lost device does not become a lost account.

Why Passkeys Resist Phishing When Nothing Else Does

Every shared-secret factor — passwords, one-time codes, push approvals — can be relayed. The user is on a convincing replica of your login page, they type the code, and the attacker’s proxy forwards it to the real site within the validity window. WebAuthn breaks that relay at the protocol level, and it is worth being precise about how, because the mechanism explains most of the implementation rules.

Why a relayed one-time code works but a relayed passkey assertion does not A phishing proxy can forward a typed one-time code to the real site because the code is not bound to an origin, but the browser signs the passkey assertion over the attacker's origin, so the real site's origin check fails. One-time code User types 123456 into the fake page Proxy replays it to the real site The code carries no origin Real site accepts it — relay succeeds The secret is portable, so the channel does not matter Passkey assertion Browser signs over evil.example Proxy forwards the signature Origin is inside the signed data Real site rejects it — origin mismatch The browser, not the user, decides which site is which
The browser writes the origin into the signed payload and the user cannot override it. That is the whole phishing-resistance story, and it is why the server-side origin check is not optional.

Three implementation rules follow directly. Verify origin against an exact allowlist rather than a suffix match, because yourapp.com.evil.example ends with your domain as a string. Verify that the relying-party ID hash in the authenticator data matches the RP ID you configured, since that is the value the credential is scoped to. And keep the challenge single-use and server-side: a challenge stored in the client, or accepted twice, turns a signature into a replayable token.

Counter handling deserves a note because it produces support tickets. The signature counter is intended to detect a cloned authenticator, but many platform authenticators — including the ones backing synced passkeys — always report zero. The correct rule is: if the stored counter and the new counter are both zero, accept; if the new counter is greater than the stored one, accept and update; if it went backwards on an authenticator that reports non-zero counters, treat it as a possible clone and require another factor. Rejecting all zero counters locks out most consumer devices.

Rolling Passkeys Out Without Locking Anyone Out

The technical ceremony is the easy part; the deployment sequence is where products get into trouble. A workable order looks like this.

Start by offering passkeys as an additional factor alongside the existing password, and make enrolment opt-in from account settings. This produces real telemetry — how many users have compatible devices, how many complete enrolment, how many come back and use it — without risking anyone’s access. Prompt for enrolment after a successful login rather than before, when the user has already proved who they are and has no reason to abandon.

Encourage a second credential immediately after the first. A single passkey on a single device is a lockout waiting for a lost phone, and the moment right after a successful enrolment is when the user understands what they are being asked to add. Show which devices hold credentials, with a name and a last-used date, so removing an old laptop is obvious.

Only after adoption is meaningful should you offer passwordless as the default, and only for accounts with at least two credentials or a verified recovery path. Keep the password as a fallback longer than feels necessary; removing it is a one-way door for users whose devices break, and the recovery and fallback strategies you provide determine how many support tickets that door generates.

Throughout, instrument the ceremony failures. Browsers report errors — NotAllowedError for a cancelled or timed-out prompt, InvalidStateError when the credential already exists on the device — and the distribution of those errors tells you whether your UX or your configuration is wrong. A spike in NotAllowedError usually means the prompt is appearing without explanation; a spike in origin or RP ID errors means a configuration mismatch between environments.

Frequently Asked Questions

What is the difference between a passkey and a WebAuthn credential?

Technically none — a passkey is a WebAuthn credential. The word marks a set of properties the platforms standardised on: the credential is discoverable (the authenticator can find it without the server naming it first, which is what makes usernameless login possible), it is backed by user verification such as a biometric or PIN, and on most platforms it syncs through the vendor’s keychain so it survives losing a device. Older security-key credentials are the same protocol without those properties.

Do synced passkeys weaken security?

They move part of the trust to the platform vendor’s keychain, which is a real change but usually a favourable one. The alternative — a credential that exists on exactly one device — fails closed in a way users experience as being locked out, and the recovery flow you build to compensate is almost always weaker than the vendor’s account security. If your threat model genuinely requires it, request device-bound credentials with attestation and accept the support burden deliberately.

Can I use the same relying-party ID across subdomains?

Yes, if you set the RP ID to the registrable parent domain — a credential scoped to example.com works on app.example.com and admin.example.com. That is convenient and it widens the scope: any subdomain that can serve a login page can request an assertion for that credential. Scope the RP ID as narrowly as your deployment allows, and never set it to a domain you do not fully control.

What happens if a user loses every device with a passkey?

That is exactly why the recovery path has to be designed before you go passwordless. The usual layers are a second passkey on another device, a verified email or phone challenge, recovery codes generated at enrolment, and — for high-value accounts — an identity-proofing step with a human. Whatever you choose becomes the real strength of the account, because an attacker will attack the weakest path, not the strongest one.

Should I require attestation?

For consumer products, no: requesting attestation adds a privacy-sensitive prompt on some platforms, complicates the flow, and tells you little you will act on. Enterprise deployments that must restrict authentication to approved hardware are the genuine use case — there, you verify the attestation statement against the FIDO metadata service and enforce an allowlist of authenticator models, and you accept that users’ personal devices will be refused.

Credential Inventory Users Can Act On

What a passkey management screen should show per credential Each enrolled credential should display a user-chosen name, the device or platform it lives on, whether it is backed up or device-bound, the date it was added and last used, and a remove action guarded by a step-up challenge. Name Platform Backed up Last used Remove Work laptop macOS keychain yes 2 hours ago step-up Backup key hardware token device-bound 3 months ago step-up Never allow the last credential to be removed without an alternative factor already in place.
The backup column is the one that prevents lockouts: a user with only device-bound credentials on a single device needs to be told so.