MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr đŸ”¥ tagline

Hey there đŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Web Authentication: Passkeys and WebAuthn

Implement passwordless auth: WebAuthn, passkeys, authenticator registration, and login flows.

WebAuthnPasskeysSecurityAuthentication

By MinhVo

Introduction

Passwords have been the dominant authentication mechanism for decades, but they're fundamentally broken. Users reuse passwords across sites, choose weak ones, and fall victim to phishing attacks. The Web Authentication API (WebAuthn) and passkeys represent the most significant shift in authentication since the introduction of passwords themselves. By leveraging public-key cryptography and hardware authenticators, WebAuthn eliminates passwords entirely while providing stronger security.

Major platforms have already adopted passkeys. Apple, Google, and Microsoft now support passkey creation and synchronization across devices. As of 2024, over 15 billion accounts can use passkeys for sign-in. The FIDO Alliance reports that passkey adoption is accelerating, with major financial institutions, healthcare providers, and government agencies deploying WebAuthn-based authentication.

This guide dives deep into WebAuthn's architecture, walks through server and client implementation, and covers the critical patterns for deploying passwordless authentication in production. Whether you're adding a second factor or building a fully passwordless system, understanding WebAuthn is now essential for modern web development.

Security and authentication concept

Understanding WebAuthn: Core Concepts

How WebAuthn Works

WebAuthn uses asymmetric cryptography. During registration, the authenticator (a security key, fingerprint sensor, or device) generates a unique key pair for each account on each website. The private key never leaves the authenticator, while the public key is stored on the server. During authentication, the server sends a challenge, the authenticator signs it with the private key, and the server verifies the signature using the stored public key.

This approach provides several security advantages:

  • No shared secrets: The server never sees the private key, so database breaches don't expose credentials
  • Phishing resistant: Credentials are bound to the origin (domain), so phishing sites can't capture usable credentials
  • No replay attacks: Each authentication challenge is unique and single-use
  • Device-bound security: The private key is hardware-protected and can't be exported

Passkeys vs. Platform Authenticators

Passkeys (also called discoverable credentials or resident keys) are stored on the device or synced across devices through the platform's credential manager (iCloud Keychain, Google Password Manager, Windows Hello). They enable usernameless authentication—the authenticator knows which accounts are available.

Platform authenticators are built into the device (Touch ID, Face ID, Windows Hello). They're convenient because users don't need external hardware.

Roaming authenticators are external devices like USB security keys (YubiKey, Titan Key). They work across devices and provide the highest security level.

The WebAuthn Ceremony Flow

WebAuthn involves two ceremonies:

Registration (Attestation):

  1. Server generates a challenge and user information
  2. Browser calls navigator.credentials.create()
  3. Authenticator prompts user (biometric, PIN, etc.)
  4. Authenticator generates key pair and returns public key + attestation
  5. Server stores public key, credential ID, and sign count

Authentication (Assertion):

  1. Server generates a challenge and allowed credentials
  2. Browser calls navigator.credentials.get()
  3. Authenticator prompts user and signs the challenge
  4. Server verifies signature, challenge, and origin
  5. User is authenticated

Authentication flow diagram

Architecture and Design Patterns

Server-Side Architecture

A production WebAuthn implementation requires several components:

// types/webauthn.ts
interface Authenticator {
  credentialID: string;
  credentialPublicKey: string;
  counter: number;
  credentialDeviceType: 'singleDevice' | 'multiDevice';
  credentialBackedUp: boolean;
  transports?: AuthenticatorTransport[];
}
 
interface User {
  id: string;
  username: string;
  displayName: string;
  authenticators: Authenticator[];
}

The WebAuthn Service Pattern

Encapsulate all WebAuthn logic in a dedicated service:

// services/webauthn.service.ts
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import type {
  RegistrationResponseJSON,
  AuthenticationResponseJSON,
  AuthenticatorTransportFuture,
} from '@simplewebauthn/types';
 
const rpName = 'My Application';
const rpID = process.env.RP_ID || 'localhost';
const expectedOrigin = process.env.ORIGIN || 'http://localhost:3000';
 
export class WebAuthnService {
  constructor(private userRepo: UserRepository) {}
 
  async generateRegistrationOptions(userId: string) {
    const user = await this.userRepo.findById(userId);
    const userAuthenticators = user.authenticators.map(auth => ({
      id: Buffer.from(auth.credentialID, 'base64url'),
      type: 'public-key' as const,
      transports: auth.transports as AuthenticatorTransportFuture[],
    }));
 
    const options = await generateRegistrationOptions({
      rpName,
      rpID,
      userID: Buffer.from(user.id),
      userName: user.username,
      userDisplayName: user.displayName,
      attestationType: 'none',
      excludeCredentials: userAuthenticators,
      authenticatorSelection: {
        residentKey: 'preferred',
        userVerification: 'preferred',
        authenticatorAttachment: 'platform',
      },
      supportedAlgorithmIDs: [-7, -257],
    });
 
    // Store challenge in session for verification
    return options;
  }
 
  async verifyRegistration(
    userId: string,
    response: RegistrationResponseJSON,
    expectedChallenge: string
  ) {
    const verification = await verifyRegistrationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID: rpID,
    });
 
    if (!verification.verified || !verification.registrationInfo) {
      throw new Error('Registration verification failed');
    }
 
    const { credential, credentialDeviceType, credentialBackedUp } =
      verification.registrationInfo;
 
    const newAuthenticator: Authenticator = {
      credentialID: Buffer.from(credential.id).toString('base64url'),
      credentialPublicKey: Buffer.from(credential.publicKey).toString('base64url'),
      counter: credential.counter,
      credentialDeviceType,
      credentialBackedUp,
      transports: response.response.transports,
    };
 
    await this.userRepo.addAuthenticator(userId, newAuthenticator);
    return { verified: true };
  }
 
  async generateAuthenticationOptions(userId?: string) {
    const options = await generateAuthenticationOptions({
      rpID,
      userVerification: 'preferred',
      ...(userId ? {
        allowCredentials: (await this.userRepo.findById(userId))
          .authenticators.map(auth => ({
            id: Buffer.from(auth.credentialID, 'base64url'),
            type: 'public-key' as const,
            transports: auth.transports as AuthenticatorTransportFuture[],
          })),
      } : {}),
    });
 
    return options;
  }
 
  async verifyAuthentication(
    response: AuthenticationResponseJSON,
    expectedChallenge: string
  ) {
    const credentialID = response.id;
    const authenticator = await this.userRepo.findAuthenticator(credentialID);
 
    if (!authenticator) {
      throw new Error('Authenticator not found');
    }
 
    const verification = await verifyAuthenticationResponse({
      response,
      expectedChallenge,
      expectedOrigin,
      expectedRPID: rpID,
      credential: {
        id: Buffer.from(authenticator.credentialID, 'base64url'),
        publicKey: Buffer.from(authenticator.credentialPublicKey, 'base64url'),
        counter: authenticator.counter,
        transports: authenticator.transports as AuthenticatorTransportFuture[],
      },
    });
 
    if (!verification.verified) {
      throw new Error('Authentication verification failed');
    }
 
    // Update counter to prevent replay attacks
    await this.userRepo.updateAuthenticatorCounter(
      credentialID,
      verification.authenticationInfo.newCounter
    );
 
    const user = await this.userRepo.findByAuthenticatorId(credentialID);
    return { verified: true, user };
  }
}

Step-by-Step Implementation

Client-Side Registration

// hooks/useWebAuthn.ts
import { startRegistration, startAuthentication } from '@simplewebauthn/browser';
 
export function useWebAuthn() {
  const register = async (userId: string) => {
    // Get challenge from server
    const optionsRes = await fetch('/api/webauthn/register/options', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ userId }),
    });
    const options = await optionsRes.json();
 
    // Trigger authenticator
    const registrationResponse = await startRegistration(options);
 
    // Verify with server
    const verifyRes = await fetch('/api/webauthn/register/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        userId,
        response: registrationResponse,
        challenge: options.challenge,
      }),
    });
 
    const result = await verifyRes.json();
    return result.verified;
  };
 
  const authenticate = async () => {
    // Get challenge from server
    const optionsRes = await fetch('/api/webauthn/authenticate/options', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
    });
    const options = await optionsRes.json();
 
    // Trigger authenticator
    const authResponse = await startAuthentication(options);
 
    // Verify with server
    const verifyRes = await fetch('/api/webauthn/authenticate/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        response: authResponse,
        challenge: options.challenge,
      }),
    });
 
    return verifyRes.json();
  };
 
  return { register, authenticate };
}

API Routes (Next.js)

// app/api/webauthn/register/options/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { WebAuthnService } from '@/services/webauthn.service';
 
export async function POST(req: NextRequest) {
  const { userId } = await req.json();
  const service = new WebAuthnService(userRepo);
 
  const options = await service.generateRegistrationOptions(userId);
 
  // Store challenge in session/cookie for verification
  const response = NextResponse.json(options);
  response.cookies.set('webauthn-challenge', options.challenge, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 300, // 5 minutes
  });
 
  return response;
}
// app/api/webauthn/register/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { WebAuthnService } from '@/services/webauthn.service';
 
export async function POST(req: NextRequest) {
  const { userId, response: regResponse } = await req.json();
  const challenge = req.cookies.get('webauthn-challenge')?.value;
 
  if (!challenge) {
    return NextResponse.json({ error: 'Challenge expired' }, { status: 400 });
  }
 
  const service = new WebAuthnService(userRepo);
  const result = await service.verifyRegistration(userId, regResponse, challenge);
 
  const res = NextResponse.json(result);
  res.cookies.delete('webauthn-challenge');
  return res;
}

React Component

// components/PasskeyAuth.tsx
'use client';
import { useState } from 'react';
import { useWebAuthn } from '@/hooks/useWebAuthn';
 
export function PasskeyAuth({ userId }: { userId: string }) {
  const { register, authenticate } = useWebAuthn();
  const [status, setStatus] = useState<string>('');
 
  const handleRegister = async () => {
    try {
      setStatus('Requesting authenticator...');
      const success = await register(userId);
      setStatus(success ? 'Passkey registered!' : 'Registration failed');
    } catch (err: any) {
      setStatus(`Error: ${err.message}`);
    }
  };
 
  const handleLogin = async () => {
    try {
      setStatus('Authenticating...');
      const result = await authenticate();
      setStatus(result.verified ? `Welcome, ${result.user.displayName}!` : 'Auth failed');
    } catch (err: any) {
      setStatus(`Error: ${err.message}`);
    }
  };
 
  return (
    <div className="passkey-auth">
      <button onClick={handleRegister}>Register Passkey</button>
      <button onClick={handleLogin}>Sign in with Passkey</button>
      {status && <p>{status}</p>}
    </div>
  );
}

Implementation workflow

Real-World Use Cases and Case Studies

Use Case 1: Financial Services Passwordless Login

Banks and financial institutions are early WebAuthn adopters. A major European bank deployed passkeys as the primary authentication method, reducing phishing-related account takeakes by 99%. Users register a passkey during account setup, and subsequent logins require only a fingerprint or face scan. The bank saw a 40% reduction in support calls related to forgotten passwords.

Use Case 2: Healthcare Patient Portals

HIPAA-compliant patient portals need strong authentication without burdening elderly users with complex passwords. WebAuthn with platform authenticators provides a seamless experience—patients tap their fingerprint sensor instead of remembering passwords. The credential-bound approach also satisfies regulatory requirements for multi-factor authentication.

Use Case 3: Enterprise SSO with Passkeys

Enterprise applications use WebAuthn as part of their SSO strategy. Employees register passkeys with their corporate devices, and the identity provider uses WebAuthn assertions to issue SAML or OIDC tokens. This eliminates the need for hardware tokens while maintaining high assurance levels.

Use Case 4: Consumer E-Commerce

E-commerce platforms add passkeys to reduce cart abandonment caused by password friction. The checkout flow becomes: tap fingerprint, confirm payment. No passwords to remember, no 2FA codes to type. Conversion rates increase significantly when authentication friction decreases.

Best Practices for Production

  1. Always verify on the server: Never trust client-side WebAuthn responses. All attestation and assertion verification must happen server-side using a trusted library like @simplewebauthn/server.

  2. Store challenges securely: Generate challenges server-side, store them in sessions or signed cookies with short TTLs (30-300 seconds). Never accept a WebAuthn response without verifying the challenge matches.

  3. Implement credential recovery: Provide backup authentication methods (email magic link, recovery codes) in case users lose their authenticator device. Multi-device passkeys mitigate this, but roaming authenticators still need fallbacks.

  4. Use appropriate attestation: For most consumer applications, attestationType: 'none' is sufficient. Enterprise environments may require direct attestation to verify specific hardware authenticators.

  5. Support multiple authenticators per account: Allow users to register multiple passkeys (phone, laptop, security key). This provides redundancy and supports multiple device types.

  6. Handle the conditional UI: Use navigator.credentials.get({ mediation: 'conditional' }) for autofill-style passkey prompts. This provides a smoother user experience on supported browsers.

  7. Monitor sign counts: Track the authenticator's sign count to detect potential credential cloning. If the counter value doesn't increment as expected, flag the account for review.

  8. Test across platforms: WebAuthn behavior varies between platforms (iOS, Android, Windows, macOS). Test your registration and authentication flows on all target platforms.

Common Pitfalls and Solutions

PitfallImpactSolution
Storing credentials client-sideSecurity breachStore public keys server-side only
Not verifying originPhishing vulnerabilityAlways validate expectedOrigin
Expired challengesAuth failuresUse short TTLs and clear error messages
Single authenticator per accountLockout riskAllow multiple authenticator registration
Ignoring transport hintsPoor UX on mobileUse transports for platform-specific prompts
Not updating sign counterReplay attack riskUpdate counter after successful assertion

Performance Optimization

WebAuthn operations are inherently fast (cryptographic operations take milliseconds), but the network round-trips and UI prompts add latency. Optimize the user experience:

// Pre-fetch registration options while user is on the registration page
async function preloadRegistrationOptions(userId: string) {
  const options = await fetch('/api/webauthn/register/options', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ userId }),
  });
  // Cache options for when user clicks "Register"
  sessionStorage.setItem('webauthn-options', JSON.stringify(await options.json()));
}
 
// Use conditional mediation for seamless autofill
async function conditionalLogin() {
  if (PublicKeyCredential.isConditionalMediationAvailable?.()) {
    const options = await getAuthenticationOptions();
    const response = await navigator.credentials.get({
      publicKey: options,
      mediation: 'conditional',
    });
    if (response) await verifyAuthentication(response);
  }
}

Comparison with Alternatives

FeatureWebAuthn/PasskeysPassword + 2FAOAuth/OIDCMagic Links
Phishing ResistantYesPartialPartialNo
User ExperienceExcellentPoorGoodGood
Shared SecretsNonePassword + TOTPTokensEmail
Device BindingOptionalNoNoNo
Offline CapableYesNoNoNo
Recovery ComplexityMediumLowLowLow
Implementation EffortHighLowMediumLow

Advanced Patterns and Techniques

Conditional UI for Passkey Autofill

async function setupConditionalUI() {
  const available = await PublicKeyCredential.isConditionalMediationAvailable?.();
  if (!available) return;
 
  const options = await fetch('/api/webauthn/authenticate/options').then(r => r.json());
 
  try {
    const credential = await navigator.credentials.get({
      publicKey: {
        ...options,
        challenge: base64URLToBuffer(options.challenge),
      },
      mediation: 'conditional',
    });
 
    if (credential) {
      await verifyAndLogin(credential);
    }
  } catch (err) {
    // User dismissed or no passkey available - not an error
  }
}

Cross-Device Authentication with Hybrid Transport

// Enable QR code-based cross-device auth
const options = await generateAuthenticationOptions({
  rpID,
  userVerification: 'preferred',
  hints: ['hybrid'], // Enable cross-device flow
});

Attestation Metadata Verification

// For enterprise environments requiring specific hardware
const verification = await verifyRegistrationResponse({
  response,
  expectedChallenge,
  expectedOrigin,
  expectedRPID: rpID,
  supportedAlgorithmIDs: [-7, -257],
});

Testing Strategies

// webauthn.test.ts
import { describe, it, expect, vi } from 'vitest';
import { WebAuthnService } from './webauthn.service';
 
// Mock SimpleWebAuthn
vi.mock('@simplewebauthn/server', () => ({
  generateRegistrationOptions: vi.fn().mockResolvedValue({
    challenge: 'test-challenge',
    rp: { name: 'Test', id: 'localhost' },
  }),
  verifyRegistrationResponse: vi.fn().mockResolvedValue({
    verified: true,
    registrationInfo: {
      credential: { id: 'cred-id', publicKey: new Uint8Array(), counter: 0 },
      credentialDeviceType: 'singleDevice',
      credentialBackedUp: false,
    },
  }),
}));
 
describe('WebAuthnService', () => {
  it('generates registration options', async () => {
    const service = new WebAuthnService(mockUserRepo);
    const options = await service.generateRegistrationOptions('user-1');
 
    expect(options.challenge).toBeDefined();
    expect(options.rp.name).toBe('My Application');
  });
});

Future Outlook

The passkey ecosystem is evolving rapidly:

  • Cross-platform sync: Passkeys now sync across Apple, Google, and Microsoft ecosystems, making multi-device use seamless
  • Enterprise adoption: FIDO Alliance is developing enterprise-specific features for managed devices
  • Browser support: All major browsers now support WebAuthn Level 3, with improved conditional UI
  • Recovery mechanisms: New standards for credential delegation and recovery are emerging
  • Government mandates: NIST guidelines increasingly favor FIDO-based authentication for government services

WebAuthn and passkeys represent the future of web authentication. As the ecosystem matures and user adoption grows, password-based authentication will become the exception rather than the norm.

Conclusion

WebAuthn and passkeys offer the most secure and user-friendly authentication mechanism available today. By eliminating passwords, they solve the fundamental security problems of credential theft, phishing, and password reuse. The implementation complexity is manageable with libraries like @simplewebauthn, and the user experience is superior to any password-based system.

Key takeaways:

  1. Passkeys eliminate passwords: Public-key cryptography provides stronger security without shared secrets
  2. Platform integration is key: Leverage Touch ID, Face ID, and Windows Hello for seamless UX
  3. Always verify server-side: Never trust client-side WebAuthn responses alone
  4. Support multiple authenticators: Allow users to register multiple devices for account recovery
  5. The ecosystem is ready: Major platforms and browsers fully support passkeys

Start by adding passkey registration alongside your existing authentication. Once users experience the convenience and security of passwordless login, they'll never want to go back. The WebAuthn specification and tooling are mature enough for production use today.

For more information, explore the W3C WebAuthn specification and the SimpleWebAuthn library.