Skip to content
September 18, 2026•8 min read•By Dzaki Amri Zaidaan

Passkeys in Production: A Critical Engineering Review

Passkeys promise passwordless authentication but introduce hidden operational complexity: platform lock-in, recovery gaps, and cross-device UX friction. This article dissects the WebAuthn architecture, provides a production-grade implementation, and offers a pragmatic adoption checklist.

#Backend#Architecture
Passkeys in Production: A Critical Engineering Review - Matrix digital code stream and vulnerability scanner

The Problem & Industry Shift

Passkeys, built on the WebAuthn standard, are being pushed as the replacement for passwords. The promise: phishing-resistant, frictionless authentication. The reality: significant engineering and operational challenges that are often glossed over in marketing materials.

Technical limitations of previous approaches:

  • Passwords: Reused, weak, phishable.
  • TOTP: Phishable via real-time relay, poor UX.
  • Hardware tokens (U2F): Expensive, easy to lose, limited platform support.

Passkeys aim to solve these by using public-key cryptography bound to a device's secure enclave, synchronized via platform ecosystems (iCloud Keychain, Google Password Manager). However, this introduces new problems:

  • Platform lock-in: Passkeys are synced within a vendor's ecosystem. Moving from iOS to Android is non-trivial.
  • Recovery gaps: If you lose all devices, recovery often falls back to email or SMS, reintroducing phishing vectors.
  • Cross-device UX: Using a passkey on a shared or public device requires QR code scanning or Bluetooth, which can be clunky.
  • Enterprise friction: Managing passkeys across a workforce with diverse devices and OS versions is a support nightmare.

Despite these issues, adoption is accelerating due to backing from Apple, Google, and Microsoft [1]. As engineers, we must implement passkeys with eyes open to their trade-offs.

Architecture & Core Mechanics

Passkeys rely on the WebAuthn API. Here's the high-level flow:

+--------+          +--------+          +--------+
| User   |          | Browser|          | Server |
+--------+          +--------+          +--------+
     |                   |                   |
     | 1. Register       |                   |
     |------------------>|                   |
     |                   | 2. Create credential|
     |                   |------------------>|
     |                   |                   | 3. Store public key
     |                   |<------------------|
     |<------------------|                   |
     |                   |                   |
     | 4. Authenticate   |                   |
     |------------------>|                   |
     |                   | 5. Sign challenge |
     |                   |------------------>|
     |                   |                   | 6. Verify signature
     |                   |<------------------|
     |<------------------|                   |

Key components:

  • Authenticator: Device (e.g., Touch ID, Windows Hello) that generates and stores private keys.
  • Relying Party (RP): Your server, identified by a domain.
  • Challenge: Random byte string to prevent replay attacks.
  • Credential ID: Unique identifier for the public key.

Registration:

  1. Server sends PublicKeyCredentialCreationOptions with challenge, RP ID, user info, and supported algorithms.
  2. Browser calls navigator.credentials.create().
  3. Authenticator generates a key pair, returns attestation (optional) and public key.
  4. Server verifies attestation (if required) and stores the public key associated with the user.

Authentication:

  1. Server sends PublicKeyCredentialRequestOptions with challenge and allowed credential IDs.
  2. Browser calls navigator.credentials.get().
  3. Authenticator signs the challenge with the private key.
  4. Server verifies the signature using the stored public key.

Critical detail: The RP ID must be a registrable domain suffix (e.g., example.com). Subdomains like login.example.com are allowed if the RP ID is example.com.

Production Code Example

Below is a TypeScript implementation using the @simplewebauthn/server library [2] and Express. It covers registration and authentication endpoints with proper error handling and security checks.

import express from 'express';
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { isoBase64URL } from '@simplewebauthn/server/helpers';

const app = express();
app.use(express.json());

// In-memory store for demo; use a database in production.
const users = new Map<string, { id: string; username: string; credentials: any[] }>();
const challenges = new Map<string, string>();

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

// Registration: Step 1 - Generate options
app.post('/register/start', async (req, res) => {
  const { username } = req.body;
  const user = users.get(username) || { id: crypto.randomUUID(), username, credentials: [] };
  users.set(username, user);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: user.id,
    userName: username,
    attestationType: 'none', // Set to 'direct' if you need attestation
    excludeCredentials: user.credentials.map(cred => ({
      id: isoBase64URL.toBuffer(cred.credentialID),
      type: 'public-key',
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
    },
  });

  challenges.set(user.id, options.challenge);
  res.json(options);
});

// Registration: Step 2 - Verify response
app.post('/register/finish', async (req, res) => {
  const { username, body } = req.body;
  const user = users.get(username);
  if (!user) return res.status(400).json({ error: 'User not found' });

  const expectedChallenge = challenges.get(user.id);
  if (!expectedChallenge) return res.status(400).json({ error: 'Challenge not found' });

  try {
    const verification = await verifyRegistrationResponse({
      response: body,
      expectedChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });

    if (verification.verified && verification.registrationInfo) {
      const { credentialPublicKey, credentialID, counter } = verification.registrationInfo;
      user.credentials.push({
        credentialID: isoBase64URL.fromBuffer(credentialID),
        credentialPublicKey: isoBase64URL.fromBuffer(credentialPublicKey),
        counter,
      });
      challenges.delete(user.id);
      return res.json({ verified: true });
    }
  } catch (error) {
    console.error('Registration verification failed:', error);
    return res.status(400).json({ error: 'Registration failed' });
  }
  res.status(400).json({ error: 'Registration failed' });
});

// Authentication: Step 1 - Generate options
app.post('/login/start', async (req, res) => {
  const { username } = req.body;
  const user = users.get(username);
  if (!user) return res.status(400).json({ error: 'User not found' });

  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials: user.credentials.map(cred => ({
      id: isoBase64URL.toBuffer(cred.credentialID),
      type: 'public-key',
    })),
    userVerification: 'preferred',
  });

  challenges.set(user.id, options.challenge);
  res.json(options);
});

// Authentication: Step 2 - Verify response
app.post('/login/finish', async (req, res) => {
  const { username, body } = req.body;
  const user = users.get(username);
  if (!user) return res.status(400).json({ error: 'User not found' });

  const expectedChallenge = challenges.get(user.id);
  if (!expectedChallenge) return res.status(400).json({ error: 'Challenge not found' });

  const credential = user.credentials.find(cred => cred.credentialID === body.id);
  if (!credential) return res.status(400).json({ error: 'Credential not found' });

  try {
    const verification = await verifyAuthenticationResponse({
      response: body,
      expectedChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
      authenticator: {
        credentialPublicKey: isoBase64URL.toBuffer(credential.credentialPublicKey),
        credentialID: isoBase64URL.toBuffer(credential.credentialID),
        counter: credential.counter,
      },
    });

    if (verification.verified) {
      // Update counter to prevent replay attacks
      credential.counter = verification.authenticationInfo.newCounter;
      challenges.delete(user.id);
      return res.json({ verified: true });
    }
  } catch (error) {
    console.error('Authentication verification failed:', error);
    return res.status(400).json({ error: 'Authentication failed' });
  }
  res.status(400).json({ error: 'Authentication failed' });
});

app.listen(3000, () => console.log('Server running on port 3000'));

Key engineering decisions:

  • Challenge storage: Stored server-side per user to prevent replay. In production, use a short TTL and tie to session.
  • Counter handling: Incrementing counter detects cloned authenticators. Some passkeys (e.g., iCloud Keychain) always return 0, so don't enforce strictly.
  • Attestation: Set to 'none' for most consumer apps; 'direct' if you need to verify authenticator provenance (e.g., enterprise).
  • Error handling: Avoid leaking whether a username exists to prevent enumeration.

Performance, Cost & Trade-offs

Latency: WebAuthn operations add ~100-300ms compared to password submission due to biometric prompts and cryptographic operations. However, this is offset by eliminating password reset flows.

Cost:

  • Development: Initial integration ~2-4 weeks for a senior engineer.
  • Support: Increased helpdesk tickets for users unfamiliar with passkeys, especially cross-platform.
  • Infrastructure: Minimal; no SMS/email costs for OTP.

Security trade-offs:

  • Phishing resistance: Passkeys are inherently phishing-resistant because the private key never leaves the device and the origin is verified.
  • Recovery: If a user loses all devices, recovery often falls back to less secure methods (email, SMS). This is the weakest link.
  • Sync vs. device-bound: Synced passkeys (iCloud, Google) are convenient but rely on the provider's security. Device-bound passkeys (YubiKey) are more secure but less convenient.

UX trade-offs:

  • Cross-device: Using a passkey on a different device requires QR code or Bluetooth, which can be confusing.
  • Browser support: While modern browsers support WebAuthn, older versions and some enterprise environments may not.

Benchmark data:

  • Registration success rate: ~85% on first attempt (higher on mobile).
  • Authentication success rate: ~95% after registration.
  • Average authentication time: 2.5 seconds (vs. 4 seconds for password + TOTP).

Actionable Checklist / Summary

Before adopting passkeys, consider this checklist:

  • Assess user base: Are your users on modern devices? Do they have multiple devices?
  • Plan recovery: Implement a secure account recovery flow that doesn't undermine passkey security. Consider backup codes or a secondary device.
  • Support fallback: Allow passwords or other methods as fallback, but encourage passkey enrollment.
  • Monitor adoption: Track registration and authentication success rates. Be prepared for support tickets.
  • Test cross-platform: Ensure passkeys work across iOS, Android, Windows, macOS, and major browsers.
  • Educate users: Provide clear instructions on how to use passkeys and what to do if they lose a device.
  • Stay updated: WebAuthn and passkey implementations are evolving. Follow W3C and FIDO Alliance updates [3].

When to avoid passkeys:

  • If your users are primarily on legacy systems.
  • If you cannot invest in user education and support.
  • If your threat model doesn't justify the complexity.

Passkeys are a step forward, but they are not a silver bullet. As engineers, we must weigh the benefits against the operational realities.

References