Passkeys: The Future of Passwordless Authentication

May 26, 202610 min read

Introduction

Passwords have been the primary method of authentication for decades, but they are increasingly showing their age. Users struggle with password fatigue, weak passwords, and phishing attacks. Meanwhile, organizations face the cost of password resets, breach notifications, and compliance issues.

Passkeys represent a paradigm shift in authentication. Built on the WebAuthn (Web Authentication) API and FIDO2 (Fast Identity Online) standards, passkeys offer a passwordless, phishing-resistant authentication method that is both more secure and more user-friendly.

This guide explains how passkeys work, how they compare to passwords, and how to implement them in your applications.

What Are Passkeys?

A passkey is a cryptographic credential that replaces passwords. Instead of typing a memorable string, the user authenticates using:

  • Biometric sensors (fingerprint, face recognition)
  • Device PIN or pattern
  • Security keys (YubiKey, Titan Key)

The passkey itself is stored on the user's device and never leaves it. When authenticating, the device uses the passkey to cryptographically sign a challenge from the server.

Key Characteristics

FeaturePasswordsPasskeys
Phishing resistantNoYes
Reusable across sitesOften (bad practice)Never (unique per site)
Stored on serverYes (hashed)No (only public key)
User remembersYes (burden)No (device handles it)
Works cross-deviceYes (with sync)Yes (with sync)

How Passkeys Work

Passkeys use public-key cryptography. When a user creates a passkey, a key pair is generated:

  • Private key: Stays on the user's device (never transmitted)
  • Public key: Sent to the server and stored in the user's account

Registration Flow

// Step 1: Server generates a challenge and sends to client
async function startRegistration(userId, username) {
  const challenge = crypto.getRandomValues(new Uint8Array(32));

  // Send challenge to server, receive registration options
  const options = await fetch('/api/passkey/register-options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId, username, challenge })
  }).then(r => r.json());

  // Step 2: Create the passkey on the client
  const credential = await navigator.credentials.create({
    publicKey: {
      challenge: base64urlToBuffer(options.challenge),
      rp: {
        name: "Example App",
        id: "example.com"
      },
      user: {
        id: base64urlToBuffer(options.userId),
        name: username,
        displayName: username
      },
      pubKeyCredParams: [
        { alg: -7, type: "public-key" },   // ES256
        { alg: -257, type: "public-key" }  // RS256
      ],
      authenticatorSelection: {
        authenticatorAttachment: "platform", // or "cross-platform" for security keys
        userVerification: "required"
      },
      timeout: 60000
    }
  });

  // Step 3: Send the credential to server for storage
  await fetch('/api/passkey/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      credentialId: bufferToBase64url(credential.rawId),
      publicKey: bufferToBase64url(credential.response.publicKey),
      attestation: bufferToBase64url(credential.response.attestationObject)
    })
  });
}

Authentication Flow

async function startAuthentication() {
  // Step 1: Get authentication options from server
  const options = await fetch('/api/passkey/auth-options').then(r => r.json());

  // Step 2: Get the passkey assertion from the client
  const assertion = await navigator.credentials.get({
    publicKey: {
      challenge: base64urlToBuffer(options.challenge),
      allowCredentials: options.allowedCredentials?.map(c => ({
        id: base64urlToBuffer(c.id),
        type: 'public-key'
      })) || [],
      userVerification: "required",
      timeout: 60000
    }
  });

  // Step 3: Send assertion to server for verification
  const result = await fetch('/api/passkey/authenticate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      credentialId: bufferToBase64url(assertion.rawId),
      authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
      clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
      signature: bufferToBase64url(assertion.response.signature)
    })
  }).then(r => r.json());

  if (result.success) {
    console.log("Authenticated successfully!");
  }
}

Utility Functions

// Base64URL encoding/decoding for WebAuthn
function bufferToBase64url(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

function base64urlToBuffer(base64url) {
  const base64 = base64url
    .replace(/-/g, '+')
    .replace(/_/g, '/');
  const padded = base64 + '==='.slice((base64.length + 3) % 4);
  const binary = atob(padded);
  const buffer = new ArrayBuffer(binary.length);
  const bytes = new Uint8Array(buffer);
  for (let i = 0; i < binary.length; i++) {
    bytes[i] = binary.charCodeAt(i);
  }
  return buffer;
}

Passkeys vs Passwords: Detailed Comparison

Security

ThreatPasswordsPasskeys
PhishingVulnerable (user types password on fake site)Immune (passkey bound to domain)
Credential stuffingHigh risk (reused passwords)Impossible (unique per site)
Server breachHashed passwords can be crackedOnly public keys exposed (useless to attacker)
Brute forcePossible with weak passwordsNot applicable (private key never leaves device)
Man-in-the-middleVulnerable (if not using HTTPS)Resistant (origin binding)

User Experience

AspectPasswordsPasskeys
CreationUser must invent/rememberAutomatic (one tap)
Login speedType + submit (10-30 seconds)Biometric/PIN (2-5 seconds)
RecoveryEmail reset (can be phished)New device sync or backup
Cross-deviceWorks everywhereWorks with sync or QR code

Implementation Complexity

AspectPasswordsPasskeys
BackendSimple (store hash)Moderate (WebAuthn library needed)
FrontendSimple (form POST)Moderate (WebAuthn API)
RecoveryEmail-basedDevice sync or fallback methods
Legacy supportUniversalRequires modern browser

Browser and Platform Support

Current Support (2026)

PlatformBrowserSupportedNotes
DesktopChrome 108+Full support
Firefox 119+Full support
Safari 16+Full support
Edge 108+Full support
MobileiOS 16+ SafariFace ID / Touch ID
Android Chrome 108+Fingerprint / Screen lock
Samsung Internet 23+Fingerprint

Checking for Support

function isPasskeySupported() {
  return window.PublicKeyCredential !== undefined &&
         typeof window.PublicKeyCredential === 'function' &&
         // Check if platform authenticator is available
         PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
}

isPasskeySupported().then(supported => {
  if (supported) {
    console.log("Passkeys are supported!");
  } else {
    console.log("Passkeys not available, show password login");
  }
});

Server-Side Implementation

Node.js Example

const express = require('express');
const crypto = require('crypto');
const app = express();

// In production, use a WebAuthn library like @simplewebauthn/server
// This is a simplified example

const userChallenges = new Map(); // Store challenges temporarily
const userCredentials = new Map(); // Store registered credentials

// Step 1: Generate registration options
app.post('/api/passkey/register-options', (req, res) => {
  const { userId, username } = req.body;
  const challenge = crypto.randomBytes(32).toString('base64url');

  // Store challenge for this user
  userChallenges.set(userId, challenge);

  res.json({
    challenge,
    rp: { name: "Example App", id: "localhost" },
    user: { id: userId, name: username, displayName: username },
    pubKeyCredParams: [
      { alg: -7, type: "public-key" },   // ES256
      { alg: -257, type: "public-key" }   // RS256
    ]
  });
});

// Step 2: Verify registration
app.post('/api/passkey/register', (req, res) => {
  const { credentialId, publicKey, userId } = req.body;

  // In production: verify the attestation using a library
  // Store the public key for this user
  userCredentials.set(userId, { credentialId, publicKey });

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

// Step 3: Generate authentication options
app.post('/api/passkey/auth-options', (req, res) => {
  const challenge = crypto.randomBytes(32).toString('base64url');

  // In production: associate challenge with session
  res.json({
    challenge,
    allowCredentials: [] // or list specific credential IDs
  });
});

// Step 4: Verify authentication
app.post('/api/passkey/authenticate', (req, res) => {
  const { credentialId, signature, authenticatorData, clientDataJSON } = req.body;

  // In production: verify the signature using the stored public key
  // If valid, issue a session token

  res.json({ success: true, token: "session-token-here" });
});

For production, use a well-tested library like @simplewebauthn/server:

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

const rpName = 'Example App';
const rpID = 'example.com';
const origin = `https://${rpID}`;

// Registration
const options = await generateRegistrationOptions({
  rpName,
  rpID,
  userID: userId,
  userName: username,
  attestationType: 'none',
  excludeCredentials: existingCredentials,
});

const verification = await verifyRegistrationResponse({
  response: credential,
  expectedChallenge: options.challenge,
  expectedOrigin: origin,
  expectedRPID: rpID,
});

Cross-Device Authentication

One of the challenges with passkeys is using them on a new device. The FIDO standard supports this via:

1. Cloud Sync (Most Common)

Passkeys synced via:

  • iCloud Keychain (Apple devices)
  • Google Password Manager (Android/Chrome)
  • Microsoft Account (Windows/Edge)

When the user signs into their account on a new device, their passkeys sync automatically.

2. QR Code Flow (Hybrid)

For using a passkey from a phone on a desktop:

// Desktop browser shows QR code
// User scans with phone (which has the passkey)
// Phone signs the challenge and sends it back
const assertion = await navigator.credentials.get({
  publicKey: {
    challenge: ...,
    extensions: {
      appid: true // For backwards compatibility
    }
  }
});

Fallback Strategies

Not all users will have passkey-capable devices. Implement a graceful fallback:

async function authenticate() {
  // Try passkey first
  if (await isPasskeySupported()) {
    try {
      return await startAuthentication();
    } catch (error) {
      console.log("Passkey failed, falling back to password");
    }
  }

  // Fallback to password
  showPasswordLogin();
}

Common fallback methods:

  1. Password + MFA (traditional)
  2. Magic link email
  3. SMS verification (less secure)
  4. Backup codes

Summary

Passkeys represent the future of authentication:

  • More secure: Phishing-resistant, unique per site, private key never leaves device
  • Better UX: No passwords to remember, instant biometric login
  • Industry backed: Supported by Apple, Google, Microsoft, and the FIDO Alliance

While passwords won't disappear overnight, passkeys are quickly becoming the preferred authentication method for modern applications. Start implementing passkey support today to give your users the best possible authentication experience.