Skip to content

BLOG · UPDATED 2026-04-17

Passkeys 2026: A Developer's Implementation Guide to WebAuthn

April 17, 2026 · 22 min read · By FastTool Editors

800 million Google accounts. 175 million Amazon accounts in year one. Apple, Microsoft, and Google all defaulting to passkeys at account creation. The password era didn't end with a bang; it ended with every major identity provider quietly making WebAuthn the recommended option and passwords the fallback.

If you ship authentication in 2026 and haven't added passkeys, you're behind. This guide is the practical developer-side of that migration: the registration flow, the authentication flow, the fallback strategy, the mistakes that keep shipping to production, and the spec gotchas that cost a week of debugging.

Table of contents

What a Passkey Actually Is

A passkey is a FIDO2 credential stored on a user's device (or synced across their devices via a cloud provider). It's a public/private key pair scoped to a specific origin. The private key can be processed without a FastTool upload workflow; authentication happens via a challenge-response protocol that proves possession of the private key without revealing it.

Three properties make passkeys transformative:

  • Unphishable. The credential is bound to the exact origin. A fake login page at acme-secure.com cannot use a passkey issued to acme.com.
  • No server secret. You store public keys. A database breach reveals nothing an attacker can use.
  • No typing. User touches a biometric sensor. Authentication completes in under a second on mobile.

Passwords are a protocol that asks users to type a secret that the server verifies. Passkeys are a protocol that asks users to touch a sensor and the server verifies a signature. The second one has been possible for years; it just took the browsers, OSes, and UX teams this long to align.

The Crypto in 30 Seconds

During registration:

  1. Server generates a random challenge (32 bytes).
  2. Browser asks the authenticator to create a key pair.
  3. Authenticator stores the private key, returns the public key + credential ID + signed attestation.
  4. Server verifies the attestation and stores the public key against the user.

During authentication:

  1. Server generates a random challenge.
  2. Browser asks the authenticator to sign the challenge with a known credential ID.
  3. Authenticator signs the challenge with the private key, returns the signature.
  4. Server verifies the signature against the stored public key.

That's it. The rest is plumbing. To generate random challenges client-side during development, use our UUID generator or password generator. For debugging challenge bytes, our Base64 encoder/decoder handles the URL-safe Base64 that WebAuthn requires.

Registration Flow (Frontend + Backend)

Backend: generate options

// Node.js example
import { randomBytes } from 'crypto';

function generateRegistrationOptions(user) {
  const challenge = randomBytes(32).toString('base64url');

  // Store challenge in session or short-lived cache keyed by user.id
  sessionStore.set(user.id, { challenge, expiresAt: Date.now() + 60_000 });

  return {
    rp: { name: 'Example Corp', id: 'example.com' },  // relying party
    user: {
      id: Buffer.from(user.id).toString('base64url'),
      name: user.email,
      displayName: user.name,
    },
    challenge,
    pubKeyCredParams: [
      { type: 'public-key', alg: -7 },   // ES256
      { type: 'public-key', alg: -257 }, // RS256
    ],
    timeout: 60_000,
    attestation: 'none',  // don't require attestation for consumer apps
    authenticatorSelection: {
      residentKey: 'preferred',           // enables conditional UI later
      userVerification: 'preferred',
    },
    excludeCredentials: user.existingCredentials?.map(c => ({
      type: 'public-key',
      id: c.credentialId,
    })),
  };
}

Frontend: create the credential

async function registerPasskey() {
  const res = await fetch('/webauthn/register/begin', { method: 'POST' });
  const options = await res.json();

  // Convert base64url strings to ArrayBuffers
  options.challenge = base64urlToBuffer(options.challenge);
  options.user.id = base64urlToBuffer(options.user.id);
  if (options.excludeCredentials) {
    options.excludeCredentials.forEach(c => {
      c.id = base64urlToBuffer(c.id);
    });
  }

  let credential;
  try {
    credential = await navigator.credentials.create({ publicKey: options });
  } catch (err) {
    if (err.name === 'InvalidStateError') {
      // user already has a passkey; prompt them to sign in instead
    } else if (err.name === 'NotAllowedError') {
      // user cancelled
    }
    throw err;
  }

  // Send back to server
  const body = {
    id: credential.id,
    rawId: bufferToBase64url(credential.rawId),
    type: credential.type,
    response: {
      clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
      attestationObject: bufferToBase64url(credential.response.attestationObject),
      transports: credential.response.getTransports?.() ?? [],
    },
  };

  await fetch('/webauthn/register/finish', {
    method: 'POST',
    body: JSON.stringify(body),
    headers: { 'Content-Type': 'application/json' },
  });
}

Backend: verify and store

Do not write your own WebAuthn verifier. Use @simplewebauthn/server or equivalent for your language. Example:

import { verifyRegistrationResponse } from '@simplewebauthn/server';

const { challenge } = sessionStore.get(user.id) ?? {};
if (!challenge) throw new Error('Challenge expired');

const verification = await verifyRegistrationResponse({
  response: req.body,
  expectedChallenge: challenge,
  expectedOrigin: 'https://example.com',
  expectedRPID: 'example.com',
  requireUserVerification: true,
});

if (!verification.verified) throw new Error('Registration failed');

await db.passkey.create({
  data: {
    userId: user.id,
    credentialId: verification.registrationInfo.credential.id,
    publicKey: verification.registrationInfo.credential.publicKey,
    counter: verification.registrationInfo.credential.counter,
    transports: req.body.response.transports,
    deviceType: verification.registrationInfo.credentialDeviceType,
    backedUp: verification.registrationInfo.credentialBackedUp,
    createdAt: new Date(),
  },
});

Authentication Flow

Mirror the registration flow with three differences: you fetch existing credentials for the user, you call navigator.credentials.get() instead of create(), and you verify a signature instead of an attestation.

// Backend: generate auth options
function generateAuthOptions(userId) {
  const credentials = db.passkey.findMany({ where: { userId } });
  const challenge = randomBytes(32).toString('base64url');
  sessionStore.set(userId, { challenge, expiresAt: Date.now() + 60_000 });

  return {
    challenge,
    rpId: 'example.com',
    timeout: 60_000,
    userVerification: 'preferred',
    allowCredentials: credentials.map(c => ({
      type: 'public-key',
      id: c.credentialId,
      transports: c.transports,
    })),
  };
}

// Frontend
async function signInWithPasskey() {
  const res = await fetch('/webauthn/login/begin', {
    method: 'POST',
    body: JSON.stringify({ email }),
  });
  const options = await res.json();
  options.challenge = base64urlToBuffer(options.challenge);
  options.allowCredentials.forEach(c => {
    c.id = base64urlToBuffer(c.id);
  });

  const assertion = await navigator.credentials.get({ publicKey: options });

  const body = {
    id: assertion.id,
    rawId: bufferToBase64url(assertion.rawId),
    response: {
      clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
      authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
      signature: bufferToBase64url(assertion.response.signature),
      userHandle: assertion.response.userHandle
        ? bufferToBase64url(assertion.response.userHandle)
        : null,
    },
    type: assertion.type,
  };

  const result = await fetch('/webauthn/login/finish', {
    method: 'POST',
    body: JSON.stringify(body),
    headers: { 'Content-Type': 'application/json' },
  });

  if (result.ok) {
    location.href = '/dashboard';
  }
}

Conditional UI (Autofill Passkeys)

Conditional UI shows passkey suggestions inside the normal username autofill dropdown. It's the UX that makes passkeys feel invisible for returning users. Requires residentKey: 'preferred' at registration time.

<input
  type="text"
  name="username"
  autocomplete="username webauthn"
/>

<script>
async function startConditional() {
  if (!await PublicKeyCredential.isConditionalMediationAvailable?.()) return;

  const opts = await fetchLoginOptions(); // no user ID required
  opts.challenge = base64urlToBuffer(opts.challenge);

  try {
    const assertion = await navigator.credentials.get({
      publicKey: opts,
      mediation: 'conditional',
    });
    if (assertion) await completeLogin(assertion);
  } catch (err) {
    // silent failure is expected if user picks a password instead
  }
}
startConditional();
</script>

Libraries Worth Using

  • Node.js: @simplewebauthn/server + @simplewebauthn/browser. The reference library.
  • Go: go-webauthn/webauthn. Well-maintained, used by Teleport and Cloudflare.
  • Python: py_webauthn. Stable since 2022.
  • Ruby: webauthn-ruby. Recently updated for Level 3 spec.
  • PHP: web-auth/webauthn-lib. Handles all the edge cases.
  • Rust: webauthn-rs. First-party in the Rust auth ecosystem.
  • Managed platforms: Auth0, Clerk, Supabase Auth, Firebase Auth, Stytch, Descope. All support passkeys in 2026 with drop-in components.

Server-Side Storage Schema

A minimum schema:

CREATE TABLE passkeys (
  id                UUID PRIMARY KEY,
  user_id           UUID REFERENCES users(id) ON DELETE CASCADE,
  credential_id     BYTEA NOT NULL UNIQUE,
  public_key        BYTEA NOT NULL,
  counter           BIGINT DEFAULT 0,
  transports        TEXT[],
  device_type       TEXT,           -- 'singleDevice' or 'multiDevice'
  backed_up         BOOLEAN,
  name              TEXT,           -- user-editable ("My iPhone")
  last_used_at      TIMESTAMP,
  created_at        TIMESTAMP DEFAULT now()
);

CREATE INDEX passkeys_user_id_idx ON passkeys(user_id);
CREATE UNIQUE INDEX passkeys_cred_id_idx ON passkeys(credential_id);

Counter is the sign count from the authenticator. A counter going backward is a replay signal; most libraries flag this automatically. Transports is a hint for the next authentication prompt (so the browser shows the right QR code for cross-device sign-in).

Recovery and Account Linking

The single most common failure mode is a user losing their passkey. Build recovery before you ship passkeys.

  1. Multiple passkeys per account. Let users register passkeys on multiple devices. Most users have 2-4 devices. Make "Add another device" a prominent setting.
  2. Email magic link fallback. If a user has no working passkey and no password, send a one-time magic link to their verified email. Gate this with rate limiting.
  3. Trusted contacts. For high-value accounts, let users designate recovery contacts who can approve a reset.
  4. Identity verification. For paid or high-stakes accounts, offer ID verification as a last-resort recovery. Paid services like Stripe Identity and Persona handle this.

Whatever recovery path you build, assume an attacker will probe it. The recovery flow is now your weakest link, not the passkey itself.

Browser Quirks You Will Hit

  • Safari mobile (iOS 16-17) rejects passkey creation if the origin has any http-only cookies with SameSite=None but no Secure flag. Set Secure on everything.
  • Firefox on Linux historically had flaky platform authenticator support. Fine in Firefox 124+.
  • Android Chrome shows different UI for synced vs device-bound passkeys. Your copy shouldn't promise "syncs everywhere" because platform credentials on security keys don't sync.
  • Localhost works without HTTPS but production must be HTTPS. http:// origins are rejected by WebAuthn spec.
  • Subdomains: rpId must be eTLD+1 or the exact origin. rpId: 'example.com' works for auth.example.com and app.example.com; rpId: 'auth.example.com' only works for auth.example.com.

Common Implementation Mistakes

Patterns we keep seeing in code reviews:

  1. Storing challenge in a cookie. Challenges should be server-side session state. Cookies are forgeable.
  2. Not verifying expectedRPID. Without this check, a passkey registered on app.example.com could authenticate to marketing.example.com.
  3. Not rate-limiting the registration endpoint. An attacker can enumerate users or fill your passkey table.
  4. Forgetting excludeCredentials. Users who already registered a passkey on the same device get confused. The browser handles it for you if you pass the right list.
  5. Using attestation='direct' for consumer apps. Requires users to answer "share hardware info" prompts. 'none' is correct for most consumer flows.
  6. No device naming. Users with 4 passkeys all named "iPhone" can't tell which to delete. Prompt for a friendly name at registration.
  7. Requiring user verification always. Some platforms fail UV quietly. 'preferred' is the right default.
  8. Broken recovery. No fallback, or fallback is easier to exploit than the passkey. The whole system is as strong as the weakest path.

Migration Strategy for Existing Apps

You have a database full of password users. What's the right rollout sequence?

  1. Ship add-on, not replacement. Offer "Set up passkey" in account settings. Let users opt in without removing their password.
  2. Promote after login. After a successful password login, show a one-screen "Sign in faster next time" prompt. ~40% adoption on the first nudge based on published case studies.
  3. Autofill-first for returning users. Once a user has a passkey, default to conditional UI on the login screen.
  4. Passkey-only invites for new signups. Make it the default, password as "use email instead" escape hatch.
  5. Phase out passwords gradually. Block password creation for new accounts first. Keep password login for old accounts until passkey adoption >80%.

Companies that skipped step 1 and forced passkey-only too early saw support volume double. Companies that did the gradual opt-in path saw support volume drop by 30-50% because passkey users don't file password-reset tickets.

Debug tools that help

For debugging the auth flow, our JWT debugger is useful if you're issuing session JWTs after successful passkey sign-in. The Base64 encoder/decoder handles the URL-safe variant WebAuthn uses. For validating the attestation object manually, our hex converter and JSON formatter decode the CBOR payloads.

Frequently Asked Questions

Are passkeys really replacing passwords in 2026?

Meaningfully yes. Google has 800M passkey accounts, Amazon added 175M in year one, and every major consumer service offers them. Passwords aren't extinct but they're no longer the default.

What is the difference between a passkey and a hardware security key?

Both are FIDO2. A passkey is the user-facing term for a credential typically synced via a cloud provider (iCloud Keychain, Google Password Manager) and authenticated with biometrics. A hardware security key (YubiKey, Titan) is a physical device with the same underlying protocol but no biometric UI and no cloud sync.

Can one user have multiple passkeys?

Yes and they should. Best practice is to register passkeys on every device the user logs in from, plus optionally a hardware key for high-security scenarios. The excludeCredentials parameter prevents accidental duplicate registration on the same device.

Do I need HTTPS?

In production, yes. Localhost is an exception for development. Staging environments must use HTTPS for WebAuthn to work.

Can I use passkeys for step-up authentication?

Yes. A common pattern is to allow low-risk actions with any passkey and require user verification (biometric) for high-risk actions like password changes, large transactions, or admin operations. Set userVerification: 'required' for step-up.

What is the user experience on Windows?

Windows Hello handles the biometric or PIN prompt. Works with Chrome, Edge, and Firefox. Cross-device sign-in via QR code is available if Windows Hello isn't configured (authenticate with phone instead).

Further Reading

Passkeys aren't the future; they're the present most apps are late to adopt. The spec is stable, the libraries are mature, the browsers support it, and the users are ready. Ship registration this quarter. Ship conditional UI next quarter. Make passwords the fallback your support team hears about less every month. The entire industry is moving in this direction; the only question is whether your app is pulling users forward or trailing behind them.