How To Implement Passkeys with WebAuthn: Complete Developer Guide

A practical developer guide to passkey authentication and WebAuthn. Covers how passkeys work, registration and login flows, JavaScript code examples, best practices, and testing tips.

 min. read
March 14, 2026
Star us on GitHub and stay updated

Passwords have been the default way to authenticate users for decades, but they continue to create security and usability challenges. Users forget them, reuse them across services, and often store them insecurely. For developers, password-based systems mean account recovery queues, phishing risk, and credential breach exposure.

Passkey authentication is the modern alternative. This guide walks through how passkeys work, what the WebAuthn API looks like in practice, and what you need to build a complete passkey implementation — from registration to passkey login.

Understanding Passkeys

A passkey is a passwordless credential that lets users sign in with a trusted device — a smartphone, laptop, or hardware security key — instead of a password. Under the hood, passkeys use public-key cryptography:

  • A public key is stored on the application server.
  • A private key is stored securely on the user's device and never leaves it.

During authentication, the server sends a random challenge to the device. The device signs it with the private key and returns the signature. The server verifies the signature using the stored public key. Because the private key never leaves the device, attackers can't steal it through phishing or server breaches.

Passkeys are built on the FIDO2 standard, which combines two components:

  • WebAuthn (Web Authentication API) — the browser-side API developers interact with directly
  • CTAP (Client to Authenticator Protocol) — handles communication between the browser and external authenticators like hardware security keys

Why Developers Are Adopting Passkeys

Apple, Google, and Microsoft have all built passkey support into their platforms. Adoption is accelerating, and the reasons are practical:

  • Phishing resistance. Passkeys are bound to the domain where they were created. A credential registered on yourapp.com won't work on a fake yourapp-login.com.
  • No stored secrets to breach. Servers only store public keys. Even if your database is compromised, attackers get nothing they can use to impersonate users.
  • Better UX. Users authenticate with Face ID, Touch ID, or a device PIN — no passwords to forget or reset.
  • Lower support costs. Password resets are one of the top drivers of support tickets. Passkeys eliminate the problem at the source.

Prerequisites

HTTPS and Browser Support

WebAuthn only works over HTTPS. Make sure your app is served with a valid TLS certificate — there are no exceptions, even in staging environments (use localhost for local development, which is exempted).

Modern browsers — Chrome, Safari, Firefox, and Edge — all support WebAuthn. You can check current coverage on caniuse.com.

Backend Infrastructure

Your server needs to handle three things:

  1. Challenge generation — create a random, single-use challenge for each registration or login attempt
  2. Response verification — validate the signed assertion returned by the authenticator
  3. Credential storage — store public keys, credential IDs, and a signature counter per user

Don't implement the cryptographic verification yourself. Use a well-maintained WebAuthn server library for your stack — for example, go-webauthn for Go, py_webauthn for Python, or @passwordless-id/webauthn for Node.js.

Account Recovery

Plan recovery before you launch. If a user loses their only registered device, they need a way back in. Common approaches: allow registering multiple devices, fall back to email verification, or support backup codes. Without a recovery path, locked-out users become support escalations — or lost customers.

How Passkey Authentication Works

Passkey authentication has two phases: registration (creating the credential) and passkey login (using it to sign in).

Registration Flow

Registration creates the key pair and links the public key to the user's account.

  1. The user triggers passkey setup in your app.
  2. Your server generates a random challenge and sends it to the browser.
  3. The browser calls navigator.credentials.create() with the challenge and your relying party info.
  4. The OS prompts the user to verify — Touch ID, Face ID, Windows Hello, or PIN.
  5. The device generates a key pair. The private key is stored in the secure enclave; the public key is returned to your server along with a credential ID.
  6. Your server stores the public key and credential ID linked to the user's account.

Passkey Login Flow

  1. The user selects "Sign in with passkey."
  2. Your server generates a new random challenge.
  3. The browser calls navigator.credentials.get() with the challenge.
  4. The OS finds matching credentials and prompts for biometric/PIN confirmation.
  5. The device signs the challenge with the stored private key.
  6. Your server verifies the signature using the stored public key. If it matches, the user is in.

WebAuthn Example: Registration and Login

Here's what the WebAuthn API looks like in practice. Two browser methods handle everything.

navigator.credentials.create() — Passkey Registration

// Call your server to get a registration challenge first
const response = await fetch('/auth/passkey/register/begin', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ userId: currentUser.id }),
});
const options = await response.json();

// The server returns PublicKeyCredentialCreationOptions.
// The browser needs the challenge decoded from base64.
options.challenge = base64urlToBuffer(options.challenge);
options.user.id = base64urlToBuffer(options.user.id);

// Trigger the authenticator
const credential = await navigator.credentials.create({ publicKey: options });

// Send the new credential to your server to store
await fetch('/auth/passkey/register/complete', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    id: credential.id,
    rawId: bufferToBase64url(credential.rawId),
    response: {
      attestationObject: bufferToBase64url(credential.response.attestationObject),
      clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
    },
    type: credential.type,
  }),
});

navigator.credentials.get() — Passkey Login

// Get an authentication challenge from your server
const response = await fetch('/auth/passkey/login/begin', { method: 'POST' });
const options = await response.json();

options.challenge = base64urlToBuffer(options.challenge);
// If you pass allowCredentials, decode each credential ID too
if (options.allowCredentials) {
  options.allowCredentials = options.allowCredentials.map(c => ({
    ...c,
    id: base64urlToBuffer(c.id),
  }));
}

// Prompt the user — browser handles biometric/PIN UI
const assertion = await navigator.credentials.get({ publicKey: options });

// Send the signed assertion to your server for verification
const verifyResponse = await fetch('/auth/passkey/login/complete', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    id: assertion.id,
    rawId: bufferToBase64url(assertion.rawId),
    response: {
      authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
      clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
      signature: bufferToBase64url(assertion.response.signature),
      userHandle: assertion.response.userHandle
        ? bufferToBase64url(assertion.response.userHandle)
        : null,
    },
    type: assertion.type,
  }),
});

if (verifyResponse.ok) {
  // User is authenticated — redirect or update UI
}

The base64urlToBuffer and bufferToBase64url helpers convert between base64url strings (what your server sends) and ArrayBuffer (what the WebAuthn API expects). You'll need to implement these or use a library that handles the encoding for you.

⚠️ Common mistake: Sending the raw challenge as a string instead of an ArrayBuffer will cause navigator.credentials.create() to throw. Always decode base64url values from your server before passing them to the WebAuthn API.

Integrating with an Authentication Platform

Building WebAuthn from scratch is doable, but it's a significant surface area — challenge management, attestation validation, signature counter verification, multi-device sync, and more. Many teams use an authentication platform to handle this instead.

Authgear has built-in support for passkey registration and login. You connect your app to Authgear and the passkey flows — including the WebAuthn ceremony, credential storage, and device management — are handled for you. This is worth considering if your team's core product isn't authentication infrastructure. See how Authgear handles passkeys.

Testing Your Passkey Implementation

Test across environments before shipping:

  • Registration — create a passkey on at least two different devices/browsers
  • Login — verify the authentication flow end to end
  • Multi-device — register a passkey on one device, sign in on another (synced passkeys via iCloud Keychain or Google Password Manager)
  • Hardware security keys — test with a FIDO2 key if your app needs to support them
  • Recovery — simulate a lost device and walk through your recovery flow

Chrome DevTools has a WebAuthn emulator (DevTools → More tools → WebAuthn) that lets you test registration and authentication flows without a physical authenticator.

Best Practices

Register Multiple Devices

Encourage users to register at least two devices during onboarding. A user who only has one registered device and loses it will be locked out. Multiple devices — or a mix of a synced passkey plus a hardware key — provide a natural fallback.

Design Onboarding for Unfamiliar Users

Many users have never seen a passkey prompt. Show a brief explanation before triggering navigator.credentials.create(): what passkeys are, what will happen next, and that they're more secure than passwords. A confused user will hit cancel and never try again.

Generate Fresh Challenges Every Time

Every registration and login attempt must use a unique, server-generated random challenge (at least 16 bytes, ideally 32). Challenges must be single-use and short-lived (expire within 5 minutes). Reusing or accepting stale challenges opens the door to replay attacks.

Verify the Signature Counter

Authenticators maintain a signature counter that increments with each use. Your server should store and check this counter. If you receive a counter value lower than the stored one, it may indicate a cloned authenticator — flag it and require re-authentication.

Support Cross-Platform Passkeys

Passkeys can be device-bound (tied to one authenticator, like a hardware key) or synced (backed up via iCloud Keychain, Google Password Manager, or 1Password). Synced passkeys work across a user's devices automatically. Don't set authenticatorAttachment: "platform" if you want to allow hardware keys too.

Log Authentication Events

Keep server-side logs of registration attempts, successful logins, failed verifications, and credential deletions. These logs are essential for detecting anomalies — like repeated failed assertions against the same credential — and for debugging when users report problems.

Bottom Line

Passkeys replace shared secrets with cryptographic key pairs that stay on the user's device. The result is passkey authentication that's resistant to phishing, immune to credential breaches, and faster for users.

The WebAuthn API is well-supported in all modern browsers. The client-side code is straightforward; the complexity lives on the server — challenge management, assertion verification, and credential storage. Use a server library to handle the cryptographic heavy lifting rather than implementing it yourself.

If you'd rather not manage the infrastructure at all, Authgear provides passkey signup and login out of the box, so your team can focus on building the product instead of the authentication layer.

FAQs

What is passkey authentication?

Passkey authentication is a passwordless login method that uses public-key cryptography instead of passwords. The user's device holds a private key; the server stores the matching public key. At login, the server issues a challenge, the device signs it using the private key (after biometric/PIN verification), and the server confirms the signature. No password is ever created, stored, or transmitted.

What is a passkey?

A passkey is the credential created during passkey authentication. It consists of a cryptographic key pair: a public key on the server and a private key locked to the user's device. Users authenticate with biometrics or a device PIN — the private key never leaves the device.

How do passkeys work technically?

Passkeys use public-key cryptography. The device holds a private key; the server stores the corresponding public key. At login, the server issues a challenge, the device signs it with the private key, and the server verifies the signature. No shared secret is ever transmitted.

What is WebAuthn?

WebAuthn (Web Authentication API) is the browser API that applications use to create and use passkeys. It's a W3C standard supported in all major browsers.

Are passkeys more secure than passwords?

Yes. Passkeys can't be phished (they're domain-bound), can't be leaked in a server breach (only public keys are stored), and can't be reused across sites. They're also resistant to brute-force attacks since there's no secret to guess.

Can passkeys sync across multiple devices?

Yes. Apple (iCloud Keychain), Google (Password Manager), and Microsoft (Windows Hello) sync passkeys across a user's devices. Third-party password managers like 1Password and Dashlane also support passkey sync.

Is there a WebAuthn example I can run locally?

Yes. webauthn.io is an interactive WebAuthn demo you can test in your browser without any setup. For a local example, Google's Build your first WebAuthn app codelab walks through a full registration and authentication flow. The code examples in this article show the client-side WebAuthn calls you'd use in your own app.

Preferences

Privacy is important to us, so you have the option of disabling certain types of storage that may not be necessary for the basic functioning of the website. Blocking categories may impact your experience on the website.

Accept all cookies

These items are required to enable basic website functionality.

Always active

These items are used to deliver advertising that is more relevant to you and your interests.

These items allow the website to remember choices you make (such as your user name, language, or the region you are in) and provide enhanced, more personal features.

These items help the website operator understand how its website performs, how visitors interact with the site, and whether there may be technical issues.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.