Skip to content

Cybersecurity · Authentication

Passkeys Are Ready: Implementing Passwordless Auth in Your Web App

Passkeys are no longer an experimental feature. Apple, Google, and Microsoft all support them natively. Here's what WebAuthn actually looks like in code and when passkeys make sense for your app.

Anurag Verma

Anurag Verma

8 min read

Passkeys Are Ready: Implementing Passwordless Auth in Your Web App

Sponsored

Share

Passwords have a well-documented problem: people reuse them, forget them, and give them to phishing sites. Two-factor authentication helps but adds friction. The industry’s answer, passkeys, has been in development since 2019 and quietly became mainstream in 2023-2024. By mid-2026, every major platform supports them natively. The browser APIs are stable. The user experience works.

What’s still missing is a clear guide to what passkeys actually are in code, not in marketing.

What a Passkey Is

A passkey is a credential based on public-key cryptography. When a user registers a passkey, the device generates a key pair: the private key stays on the device, and the public key is sent to your server. When the user authenticates, the device signs a challenge from your server with the private key; your server verifies that signature with the stored public key.

No shared secret is ever transmitted. Phishing doesn’t work because the signed challenge is bound to the origin. A fake site can’t use a signature intended for your domain. There’s no database of password hashes to breach. Replay attacks don’t work because each challenge is single-use.

The protocol underneath this is WebAuthn (Web Authentication API), standardized by W3C. Passkeys are the consumer-facing name for synced WebAuthn credentials, backed up to iCloud Keychain, Google Password Manager, or 1Password, so they survive device loss.

The WebAuthn Flow in Two Steps

Registration

When a user creates a passkey, your server generates a challenge and sends it to the browser. The browser calls the WebAuthn API, which prompts the user to authenticate with their device (Face ID, fingerprint, Windows Hello, or a hardware key). The authenticator creates a key pair and returns a credential object with the public key.

// Server: generate registration options
// Using @simplewebauthn/server (recommended library)
import { generateRegistrationOptions } from '@simplewebauthn/server';

const options = await generateRegistrationOptions({
  rpName: 'Your App',
  rpID: 'yourapp.com',
  userName: user.email,
  userID: Buffer.from(user.id),
  attestationType: 'none',    // no attestation required for most apps
  authenticatorSelection: {
    residentKey: 'required',   // store credential on device (passkey)
    userVerification: 'preferred',
  },
});

// Store options.challenge in the session — you'll need it to verify
req.session.currentChallenge = options.challenge;

res.json(options);
// Browser: call WebAuthn API
import { startRegistration } from '@simplewebauthn/browser';

async function registerPasskey() {
  const optionsJSON = await fetch('/auth/register/options').then(r => r.json());

  const credential = await startRegistration(optionsJSON);

  const verification = await fetch('/auth/register/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credential),
  });

  const result = await verification.json();
  if (result.verified) {
    // Passkey registered successfully
  }
}
// Server: verify registration response
import { verifyRegistrationResponse } from '@simplewebauthn/server';

const verification = await verifyRegistrationResponse({
  response: req.body,
  expectedChallenge: req.session.currentChallenge,
  expectedOrigin: 'https://yourapp.com',
  expectedRPID: 'yourapp.com',
});

if (verification.verified) {
  // Save credential to database
  await db.passkeys.create({
    userId: req.session.userId,
    credentialID: verification.registrationInfo.credentialID,
    credentialPublicKey: verification.registrationInfo.credentialPublicKey,
    counter: verification.registrationInfo.counter,
  });
}

Authentication

At login, your server generates a challenge and sends a list of the user’s registered credential IDs (or an empty list for discoverable credentials). The device finds the matching key, signs the challenge, and returns the signed assertion.

// Server: generate authentication options
import { generateAuthenticationOptions } from '@simplewebauthn/server';

// For known users (they already entered email):
const passkeys = await db.passkeys.findMany({ where: { userId: user.id } });

const options = await generateAuthenticationOptions({
  rpID: 'yourapp.com',
  allowCredentials: passkeys.map(key => ({
    id: key.credentialID,
    type: 'public-key',
  })),
  userVerification: 'preferred',
});

req.session.currentChallenge = options.challenge;
res.json(options);
// Browser: prompt for authentication
import { startAuthentication } from '@simplewebauthn/browser';

async function loginWithPasskey() {
  const optionsJSON = await fetch('/auth/login/options').then(r => r.json());

  const credential = await startAuthentication(optionsJSON);

  const result = await fetch('/auth/login/verify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(credential),
  }).then(r => r.json());

  if (result.verified) {
    window.location.href = '/dashboard';
  }
}
// Server: verify authentication response
import { verifyAuthenticationResponse } from '@simplewebauthn/server';

const passkey = await db.passkeys.findUnique({
  where: { credentialID: req.body.id },
});

const verification = await verifyAuthenticationResponse({
  response: req.body,
  expectedChallenge: req.session.currentChallenge,
  expectedOrigin: 'https://yourapp.com',
  expectedRPID: 'yourapp.com',
  credential: {
    id: passkey.credentialID,
    publicKey: passkey.credentialPublicKey,
    counter: passkey.counter,
  },
});

if (verification.verified) {
  // Update the counter — this detects cloned authenticators
  await db.passkeys.update({
    where: { credentialID: req.body.id },
    data: { counter: verification.authenticationInfo.newCounter },
  });
  // Issue session token
}

Database Schema

Each user can have multiple passkeys (different devices, hardware keys). Store them separately:

CREATE TABLE passkeys (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id      UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  credential_id BYTEA NOT NULL UNIQUE,
  public_key   BYTEA NOT NULL,
  counter      BIGINT NOT NULL DEFAULT 0,
  device_type  TEXT,            -- 'platform' (phone/laptop) or 'cross-platform' (hardware key)
  backed_up    BOOLEAN NOT NULL DEFAULT false,  -- is this synced passkey or device-bound?
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_used_at TIMESTAMPTZ
);

The counter field is critical. Every authentication increments the counter. If your server sees a counter lower than the stored value, someone may have cloned the authenticator. For synced passkeys (iCloud Keychain, Google), the counter is always 0. Synced credentials don’t use monotonic counters. Check the backed_up flag and handle accordingly.

Discoverable Credentials (Autofill)

Passkeys support a “conditional UI” mode where the browser shows available passkeys directly in the autofill dropdown when a user focuses the email field. No email entry required. The user just picks their passkey from the list.

<!-- Mark the input for passkey autofill -->
<input 
  type="email" 
  autocomplete="username webauthn" 
  placeholder="Email address"
/>
// Start authentication with mediation: 'conditional'
const optionsJSON = await fetch('/auth/login/options-conditional').then(r => r.json());

startAuthentication(optionsJSON, true); // second arg enables conditional UI
// This doesn't await — it runs in the background
// The promise resolves when the user picks a passkey from autofill

On the server side, use empty allowCredentials for the conditional options endpoint. You don’t know which user is logging in yet. After the authentication response comes back, look up the user by the credential ID.

When to Offer Passkeys vs When to Require Them

Passkeys work well as an additional authentication method, not a forced replacement. Some users won’t have compatible devices. Some platforms (older Android, some enterprise environments) have limited support.

A practical rollout for an existing app:

  1. Add passkeys as an optional auth method alongside passwords.
  2. Prompt users to register a passkey after a successful password login.
  3. Show a “Use passkey” option on the login page; fall back to password.
  4. Once a user has a registered passkey, offer to let them remove their password.
  5. Track adoption and increase the passkey nudge if it’s working.

For new apps targeting consumer audiences (mobile, fintech, healthcare), making passkeys the primary flow from the start is worth considering. The UX is genuinely better than passwords, and browser support in 2026 is good enough that most users will hit the happy path.

Libraries and Services

Building the full WebAuthn flow yourself is error-prone. The cryptographic verification has subtle edge cases. Use a library:

OptionTypeNotes
@simplewebauthn/server + @simplewebauthn/browserOpen source libraryMost popular; framework-agnostic; TypeScript support
HankoHosted servicePasskey-first, free tier for dev, embeddable UI component
Auth0 (with passkey add-on)Hosted serviceEnterprise-grade; passkeys as one factor
Passage by 1PasswordHosted serviceGood developer experience; REST API

For apps where auth is not the differentiator, a hosted service saves weeks. For apps that need full control (healthcare, financial services, self-hosted), @simplewebauthn with your own backend is the right call.

Common Mistakes

Not updating the counter. If you don’t update the stored counter after each authentication, the replay detection doesn’t work. The counter update and the session creation should be in a transaction.

Single-origin assumption. If your app runs on multiple origins (app.example.com and example.com), the expectedOrigin must be an array. Passkeys are bound to the exact origin used during registration.

No account recovery. Passkeys don’t have a “forgot password” equivalent. If a user loses all their devices, they need another way in. Email verification code, backup codes, or support-assisted recovery: pick one and build it before launching passkeys.

Missing iOS 16 / Android 9 cutoffs. WebAuthn support for synced passkeys requires iOS 16+ and Android 9+ (Chrome 108+). Older devices get hardware key support only. Know your user base before removing passwords.

Passkeys have been “coming soon” long enough that it’s easy to treat them as still experimental. They’re not. The browser APIs are stable, the platform support is broad, and the security properties are better than anything achievable with passwords and TOTP. The implementation complexity, using a library, is about the same as any other auth method.

Sponsored

Enjoyed it? Pass it on.

Share this article.

Sponsored

The dispatch

Working notes from
the studio.

A short letter twice a month — what we shipped, what broke, and the AI tools earning their keep.

No spam, ever. Unsubscribe anytime.

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored