Authentication

Passkeys vs Passwords: Why the Web Is Going Passwordless

Passkeys use public-key cryptography to eliminate passwords entirely. Learn how they work, why they're phishing-resistant, and how to implement them in your application.

August 29, 20258 min readShipSafer Team

The password is 60 years old. Fernando Corbató invented it for the Compatible Time-Sharing System at MIT in 1961. For six decades, we've been living with the consequences: billions of leaked credentials, $4.45 million average breach costs, and an arms race between increasingly complex password requirements and increasingly creative user workarounds (yes, P@ssw0rd1! doesn't fool anyone).

Passkeys represent the most significant authentication shift since the password itself. Apple, Google, and Microsoft have all adopted them. The FIDO Alliance and W3C have standardized the underlying technology. And unlike previous "passwordless" attempts, passkeys are actually gaining real-world adoption.

This guide explains how passkeys work, why they're fundamentally more secure than passwords, and how to implement them.

How Passkeys Work

Passkeys are built on the WebAuthn standard (a W3C specification) and the FIDO2 protocol. At their core, they use public-key cryptography — the same math that secures HTTPS and SSH.

The Cryptographic Foundation

When a user creates a passkey for your site:

  1. The device (phone, laptop, security key) generates an asymmetric key pair — a private key and a public key.
  2. The private key never leaves the device. It's stored in the device's secure enclave (Apple Secure Enclave, Android StrongBox, Windows TPM).
  3. The public key is sent to your server and stored in your database.

When the user authenticates:

  1. Your server generates a random challenge (a nonce).
  2. The device prompts the user for biometric verification (Face ID, Touch ID, Windows Hello) or PIN.
  3. The device signs the challenge with the private key.
  4. Your server verifies the signature using the stored public key.

What this means for security:

  • There's nothing to steal from your database — public keys are worthless without the private key.
  • There's nothing to phish — the user never types a password.
  • Each key pair is tied to a specific origin (your domain) — it can't be used on a lookalike phishing domain.

Phishing Resistance: The Killer Feature

Phishing is responsible for a majority of credential theft. Even sophisticated users are fooled by convincing phishing pages. TOTP-based 2FA is defeated by real-time phishing proxies that relay codes before they expire.

Passkeys are phishing-resistant by design. When the WebAuthn authenticator signs a challenge, it includes the origin (https://example.com) in the signed data. If a phishing site at https://examp1e.com intercepts the authentication request and tries to replay it against the real https://example.com, the origin doesn't match — the server rejects it.

There's no code to intercept, no password to type, and no way to use the credential on the wrong site.

Browser and Platform Support

As of early 2026, passkey support is widespread:

Operating Systems:

  • macOS 13+ (Ventura and later)
  • iOS 16+ / iPadOS 16+
  • Windows 11 22H2+
  • Android 9+ (with Google Play Services)
  • ChromeOS 109+

Browsers:

  • Chrome 108+
  • Safari 16+
  • Firefox 122+ (with platform authenticator)
  • Edge 108+

Passkey Sync:

  • Apple devices sync passkeys through iCloud Keychain.
  • Google devices sync through Google Password Manager.
  • Cross-platform sync via 1Password, Dashlane, and other password managers.

Sync means users don't lose access when they get a new device — their passkeys follow them.

Implementing Passkeys

The implementation uses the WebAuthn API, specifically the navigator.credentials.create() and navigator.credentials.get() methods. Using a library simplifies the server-side verification significantly.

Server Setup

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
  type RegistrationResponseJSON,
  type AuthenticationResponseJSON,
} from '@simplewebauthn/server';

const RP_ID = 'example.com';
const RP_NAME = 'Example App';
const ORIGIN = 'https://example.com';

// Registration: Generate options
export async function generatePasskeyRegistrationOptions(userId: string) {
  const user = await db.users.findOne({ userId });

  // Get existing credentials to prevent duplicates
  const existingCredentials = await db.passkeys.find({ userId });

  const options = await generateRegistrationOptions({
    rpName: RP_NAME,
    rpID: RP_ID,
    userID: new TextEncoder().encode(userId),
    userName: user.email,
    userDisplayName: user.name ?? user.email,
    attestationType: 'none',  // Use 'indirect' or 'direct' for attestation
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.credentialId,
      type: 'public-key',
    })),
    authenticatorSelection: {
      userVerification: 'preferred',
      residentKey: 'required',  // Required for passkeys (discoverable credentials)
    },
  });

  // Store challenge server-side (not in cookie)
  await cache.set(`passkey_challenge:${userId}`, options.challenge, { ex: 300 });

  return options;
}

// Registration: Verify response and store credential
export async function verifyPasskeyRegistration(
  userId: string,
  response: RegistrationResponseJSON,
  credentialName: string
) {
  const challenge = await cache.get(`passkey_challenge:${userId}`);
  if (!challenge) throw new Error('Challenge expired or not found');

  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
    requireUserVerification: true,
  });

  if (!verification.verified || !verification.registrationInfo) {
    return { success: false, error: 'Registration verification failed' };
  }

  const { credentialID, credentialPublicKey, counter, credentialDeviceType } =
    verification.registrationInfo;

  await db.passkeys.create({
    userId,
    credentialId: Buffer.from(credentialID).toString('base64url'),
    publicKey: Buffer.from(credentialPublicKey).toString('base64url'),
    counter,
    deviceType: credentialDeviceType,
    name: credentialName,
    createdAt: new Date(),
    lastUsedAt: null,
  });

  await cache.del(`passkey_challenge:${userId}`);

  return { success: true };
}

Authentication Flow

// Authentication: Generate options (for usernameless flow)
export async function generatePasskeyAuthOptions() {
  const options = await generateAuthenticationOptions({
    rpID: RP_ID,
    userVerification: 'preferred',
    // Not specifying allowCredentials enables discoverable credential flow
    // (the browser shows available passkeys without needing a username)
  });

  const challengeId = crypto.randomUUID();
  await cache.set(`auth_challenge:${challengeId}`, options.challenge, { ex: 300 });

  return { options, challengeId };
}

// Authentication: Verify response and issue session
export async function verifyPasskeyAuth(
  challengeId: string,
  response: AuthenticationResponseJSON
) {
  const challenge = await cache.get(`auth_challenge:${challengeId}`);
  if (!challenge) throw new Error('Challenge expired');

  // Find the credential being used
  const credentialId = response.id;
  const passkey = await db.passkeys.findOne({ credentialId });

  if (!passkey) {
    return { success: false, error: 'Unknown credential' };
  }

  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challenge,
    expectedOrigin: ORIGIN,
    expectedRPID: RP_ID,
    authenticator: {
      credentialID: Buffer.from(passkey.credentialId, 'base64url'),
      credentialPublicKey: Buffer.from(passkey.publicKey, 'base64url'),
      counter: passkey.counter,
    },
    requireUserVerification: true,
  });

  if (!verification.verified) {
    return { success: false, error: 'Authentication failed' };
  }

  // Update counter (prevents replay attacks)
  await db.passkeys.updateOne(
    { credentialId },
    {
      $set: {
        counter: verification.authenticationInfo.newCounter,
        lastUsedAt: new Date(),
      }
    }
  );

  await cache.del(`auth_challenge:${challengeId}`);

  return {
    success: true,
    userId: passkey.userId,
  };
}

Frontend Implementation

'use client';

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

// Register a new passkey
async function registerPasskey(credentialName: string) {
  // Get options from server
  const { options } = await fetch('/api/passkeys/register/begin').then(r => r.json());

  try {
    // Browser prompts for biometric/PIN
    const registrationResponse = await startRegistration(options);

    // Send to server for verification
    const result = await fetch('/api/passkeys/register/complete', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ response: registrationResponse, name: credentialName }),
    }).then(r => r.json());

    return result;
  } catch (error) {
    if (error.name === 'InvalidStateError') {
      return { error: 'A passkey for this device already exists' };
    }
    if (error.name === 'NotAllowedError') {
      return { error: 'Registration was cancelled' };
    }
    throw error;
  }
}

// Authenticate with passkey (no username required)
async function authenticateWithPasskey() {
  const { options, challengeId } = await fetch('/api/passkeys/auth/begin').then(r => r.json());

  try {
    const authResponse = await startAuthentication(options);

    const result = await fetch('/api/passkeys/auth/complete', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ response: authResponse, challengeId }),
    }).then(r => r.json());

    return result;
  } catch (error) {
    if (error.name === 'NotAllowedError') {
      return { error: 'Authentication was cancelled' };
    }
    throw error;
  }
}

Migration Strategy From Passwords

Don't rip out passwords overnight. Migrate gradually:

Phase 1: Offer Passkeys as an Additional Factor (2-4 weeks)

  • Add passkey registration to the security settings page.
  • Allow users to add a passkey alongside their existing password.
  • Show a banner encouraging passkey adoption.

Phase 2: Allow Passkey-Only Login (ongoing)

  • Let users choose passkey or password on the login screen.
  • Track the percentage of authentications using passkeys.
  • Gather feedback on the user experience.

Phase 3: Incentivize Passkey Adoption (1-3 months)

  • Prompt users without passkeys on each login.
  • For high-risk accounts, require passkeys for admin access.
  • Remove the ability to log in with password alone for security-critical operations.

Phase 4: Password Deprecation (long term)

  • Allow users who have registered a passkey to remove their password.
  • Eventually, for new accounts, passkey-first with password as fallback.
// Show passkey prompt on login if user has none registered
async function handleLogin(userId: string) {
  const passkeys = await db.passkeys.countDocuments({ userId });

  if (passkeys === 0) {
    // Show passkey enrollment prompt after successful password login
    return {
      success: true,
      suggestPasskeyEnrollment: true,
    };
  }

  return { success: true };
}

Passkeys vs Passwords: Summary

PropertyPasswordPasskey
Phishing resistantNoYes
Reuse riskHighNone (unique per site)
Breach exposureHigh (hashed DB)None (no secret on server)
ForgottenCommonSynced across devices
Brute-forceableYesNo
User experienceFrictionFast biometric

Passkeys aren't perfect — cross-device recovery still requires planning, enterprise deployment needs MDM integration, and some edge cases (shared accounts, kiosks) don't fit the model well. But for the vast majority of use cases, passkeys are strictly better than passwords in every dimension that matters for security.

The transition won't happen overnight, but the industry has clearly committed to it. Starting to support passkeys now puts you ahead of the curve and delivers immediate security improvements for users who adopt them.

passkeys
passwordless
webauthn
fido2
authentication

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.