Published on

Passkeys in 2026 — Replacing Passwords in Your Production App

Authors

Introduction

Passwords are dead in 2026. Apple, Google, and Microsoft mandate passkey support in all platforms. Your users expect passwordless login. Passkeys replace passwords with cryptographic keys stored in iCloud Keychain, Google Password Manager, or Windows Hello.

This post covers building production-ready passkey authentication: WebAuthn flow, credential storage, cross-device sync, and fallback strategies for users without passkey support.

State of Passkey Adoption in 2026

By 2026, passkey adoption is mainstream:

  • iOS 15+: iCloud Keychain passkeys integrated into iOS
  • Android: Google Password Manager and PasskeyKit support
  • Windows: Windows Hello biometric passkeys
  • Desktop Chrome/Edge: Passkey support via system authenticators
  • Safari: Cross-device passkeys via iCloud

Adoption rates: 40% of users on Apple devices, 25% on Android, 15% on Windows. Fallback to passwords is still necessary for legacy systems and users without compatible devices.

@simplewebauthn/server + @simplewebauthn/browser

SimpleWebAuthn is the battle-tested library for WebAuthn. Use it.

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

// Server: generate challenge for registration
const options = await generateRegistrationOptions({
  rpID: 'example.com',
  rpName: 'My App',
  userID: userId,
  userName: userEmail,
  userDisplayName: userEmail,
});

// Store challenge in session
req.session.challenge = options.challenge;

return res.json(options);

On the client:

import { startRegistration } from '@simplewebauthn/browser';

const attResp = await startRegistration(options);
// Browser shows biometric prompt, user authenticates

// Send response back to server
const response = await fetch('/api/auth/register-verify', {
  method: 'POST',
  body: JSON.stringify(attResp),
});

SimpleWebAuthn handles browser quirks. Use it.

Registration Flow

Step 1: Challenge generation

Server generates a unique, random challenge (32 bytes). This prevents replay attacks.

Step 2: Browser cryptography

Browser (via WebAuthn API) generates a keypair. Private key stays on device. Public key goes to server.

Step 3: Attestation verification

Server verifies the public key is genuine (from a real authenticator, not a fake).

Step 4: Credential storage

Server stores the public key in the database.

// Complete registration flow
app.post('/api/auth/register-options', async (req, res) => {
  const { email } = req.body;

  const user = await db.users.findUnique({ where: { email } });
  if (!user) return res.status(400).json({ error: 'User not found' });

  const options = await generateRegistrationOptions({
    rpID: 'example.com',
    rpName: 'My App',
    userID: user.id,
    userName: user.email,
    userDisplayName: user.email,
    authenticatorSelection: {
      authenticatorAttachment: 'platform', // Touch ID, Windows Hello, etc.
      userVerification: 'preferred',
    },
  });

  req.session.registrationChallenge = options.challenge;
  req.session.userId = user.id;

  return res.json(options);
});

app.post('/api/auth/register-verify', async (req, res) => {
  const { response } = req.body;

  const verified = await verifyRegistrationResponse({
    response,
    expectedChallenge: req.session.registrationChallenge,
    expectedRPID: 'example.com',
    expectedOrigin: 'https://example.com',
  });

  if (!verified.verified) {
    return res.status(400).json({ error: 'Registration failed' });
  }

  // Store credential
  await db.passkeyCredentials.create({
    data: {
      userId: req.session.userId,
      credentialID: Buffer.from(verified.registrationInfo!.credentialID).toString(
        'base64'
      ),
      credentialPublicKey: Buffer.from(
        verified.registrationInfo!.credentialPublicKey
      ).toString('base64'),
      counter: verified.registrationInfo!.counter,
      transports: verified.registrationInfo!.transports,
    },
  });

  return res.json({ success: true });
});

Authentication Flow

Step 1: Challenge generation

Server generates a new challenge. User provides email.

Step 2: Browser authentication

Browser prompts for biometric. User authenticates with fingerprint or face.

Step 3: Signature verification

Browser signs the challenge with the private key. Signature is sent to server.

Step 4: Signature validation

Server verifies the signature using the stored public key.

import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';

app.post('/api/auth/login-options', async (req, res) => {
  const { email } = req.body;

  const user = await db.users.findUnique({
    where: { email },
    include: { passkeyCredentials: true },
  });

  if (!user || user.passkeyCredentials.length === 0) {
    return res.status(400).json({ error: 'No passkey found' });
  }

  const options = await generateAuthenticationOptions({
    rpID: 'example.com',
    allowCredentials: user.passkeyCredentials.map((cred) => ({
      id: Buffer.from(cred.credentialID, 'base64'),
      type: 'public-key',
      transports: cred.transports as AuthenticatorTransport[],
    })),
  });

  req.session.authChallenge = options.challenge;
  req.session.userId = user.id;

  return res.json(options);
});

app.post('/api/auth/login-verify', async (req, res) => {
  const { response } = req.body;

  const user = await db.users.findUnique({
    where: { id: req.session.userId },
    include: { passkeyCredentials: true },
  });

  const credential = user!.passkeyCredentials.find(
    (c) => c.credentialID === response.id
  );

  const verified = await verifyAuthenticationResponse({
    response,
    expectedChallenge: req.session.authChallenge,
    expectedRPID: 'example.com',
    expectedOrigin: 'https://example.com',
    credential: {
      credentialID: Buffer.from(credential!.credentialID, 'base64'),
      credentialPublicKey: Buffer.from(credential!.credentialPublicKey, 'base64'),
      counter: credential!.counter,
    },
  });

  if (!verified.verified) {
    return res.status(400).json({ error: 'Authentication failed' });
  }

  // Update counter to prevent replay attacks
  await db.passkeyCredentials.update({
    where: { id: credential!.id },
    data: { counter: verified.authenticationInfo.newCounter },
  });

  // Create session
  req.session.userId = user!.id;
  return res.json({ success: true });
});

Storing Credential Data in PostgreSQL

// schema.ts (Drizzle)
import { pgTable, text, integer, timestamp, jsonb } from 'drizzle-orm/pg-core';

export const passkeyCredentials = pgTable('passkey_credentials', {
  id: text('id').primaryKey().defaultRandom(),
  userId: text('user_id')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  credentialID: text('credential_id').notNull(),
  credentialPublicKey: text('credential_public_key').notNull(),
  counter: integer('counter').notNull(),
  transports: jsonb('transports'),
  createdAt: timestamp('created_at').defaultNow(),
});

Store credentials as base64 strings for compatibility. Counters prevent replay attacks.

Cross-Device Passkeys via iCloud Keychain/Google Password Manager

Cross-device passkeys let users authenticate on a different device using their phone.

User scans QR code on desktop, authenticates on phone, and logs in. Works seamlessly.

// Enable conditional UI (auto-fill passkeys)
const options = await generateAuthenticationOptions({
  rpID: 'example.com',
  userVerification: 'preferred',
});

// Client-side
const attResp = await startAuthentication(options, true); // true = conditional UI

SimpleWebAuthn and the browser handle the rest. No extra code needed.

Passkey Fallback Strategy

Not all users have passkeys. Support password login as fallback.

app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;

  const user = await db.users.findUnique({ where: { email } });
  if (!user) return res.status(400).json({ error: 'Invalid credentials' });

  // If user has passkeys, offer passkey-first UX
  const hasPasskeys =
    await db.passkeyCredentials.count({ where: { userId: user.id } }) > 0;

  if (hasPasskeys) {
    return res.json({
      method: 'passkey',
      options: await generateAuthenticationOptions({ rpID: 'example.com' }),
    });
  }

  // Fall back to password
  const passwordValid = await verifyPassword(password, user.passwordHash);
  if (!passwordValid) return res.status(400).json({ error: 'Invalid credentials' });

  req.session.userId = user.id;
  return res.json({ method: 'password', success: true });
});

Graceful degradation. Passkeys first, password fallback.

Adding Passkeys to Existing Password Auth

Migrate gradually. Users can add passkeys without removing passwords.

// Passkey settings page
app.post('/api/settings/passkey-add', async (req, res) => {
  const { userId } = req.session;

  const options = await generateRegistrationOptions({
    rpID: 'example.com',
    userID: userId,
    userName: req.user.email,
  });

  req.session.registrationChallenge = options.challenge;
  return res.json(options);
});

Users can register passkeys in settings. Once they have one, they can use passkey-first login.

Conditional UI (Auto-Fill Passkeys)

Conditional UI shows passkey auto-fill suggestions in password fields (modern browsers only).

// Client-side login form
const input = document.getElementById('email') as HTMLInputElement;

// Enable conditional UI
const options = await generateAuthenticationOptions({
  rpID: 'example.com',
  userVerification: 'preferred',
});

const response = await startAuthentication(options, true); // true = conditional UI

In browsers that support it, a passkey appears as an auto-fill option. Users tap it, authenticate biometrically, and are logged in. Frictionless.

Passkey Analytics

Track adoption and usage.

// Log passkey creation
await db.analytics.create({
  data: {
    event: 'passkey_registered',
    userId,
    deviceType: detectDeviceType(), // iOS, Android, Windows, etc.
    timestamp: new Date(),
  },
});

// Log authentication method
await db.analytics.create({
  data: {
    event: 'user_login',
    userId,
    method: 'passkey', // or 'password'
    timestamp: new Date(),
  },
});

Measure adoption. Track which devices register passkeys. Use data to prioritize passkey features.

Checklist

  • Install @simplewebauthn/server and @simplewebauthn/browser
  • Create passkey credentials table in database
  • Implement registration flow (challenge generation, verification)
  • Implement authentication flow (challenge, verification, counter update)
  • Add passkey registration to user settings
  • Enable conditional UI for auto-fill
  • Test on iOS, Android, Windows, macOS
  • Test cross-device passkeys (QR code flow)
  • Implement password fallback
  • Add passkey analytics

Conclusion

Passkeys are no longer experimental. They''re the future of authentication, and 2026 is the year they go mainstream. Implement them now, support password fallback, and measure adoption. Users will prefer passkeys; your support costs will drop; and your security posture will improve. Start with SimpleWebAuthn. It''s battle-tested, well-documented, and production-ready.