Passkey Implementation Testing: End-to-End Quality for Passwordless Auth

Passkey Implementation Testing: End-to-End Quality for Passwordless Auth

Passkeys are the consumer-friendly implementation of FIDO2/WebAuthn, introduced by Apple, Google, and Microsoft as a coordinated standard for passwordless authentication. Unlike hardware security keys, passkeys sync across devices via iCloud Keychain, Google Password Manager, and Microsoft's ecosystem — which introduces testing challenges around cross-device scenarios, OS-level dialogs, and sync behavior. This guide covers passkey-specific testing from implementation validation to production monitoring.

What Makes Passkey Testing Different from WebAuthn Testing

Passkeys are a subset of WebAuthn with specific constraints:

  • Discoverable credentials are required — passkeys are always resident keys
  • User verification is required — biometric or PIN is always performed
  • Cross-device sync — a passkey created on iPhone may be used on Mac
  • Conditional UI — passkeys surface in browser autofill, not just explicit triggers
  • Hybrid transport — cross-device authentication uses QR code + Bluetooth

Testing must cover the full passkey user journey, including the UI entry points that differ from security key flows.

Setting Up Passkey Tests in Playwright

// test-helpers/passkey.js
const { chromium } = require('@playwright/test');

async function createPasskeyAuthenticator(page) {
  const client = await page.context().newCDPSession(page);
  
  await client.send('WebAuthn.enable', { enableUI: false });
  
  // Passkeys require: resident key, user verification, internal transport
  const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', {
    options: {
      protocol: 'ctap2',
      ctap2Version: 'ctap2_1',
      transport: 'internal',
      hasResidentKey: true,
      hasUserVerification: true,
      isUserVerified: true,
      automaticPresenceSimulation: true,
    },
  });

  return { client, authenticatorId };
}

module.exports = { createPasskeyAuthenticator };

Testing the Passkey Registration Flow

Standard Registration

import { test, expect } from '@playwright/test';
import { createPasskeyAuthenticator } from '../test-helpers/passkey';

test('passkey registration creates discoverable credential', async ({ page }) => {
  const { client, authenticatorId } = await createPasskeyAuthenticator(page);

  await page.goto('/register');
  await page.fill('[name="email"]', 'test@example.com');
  await page.fill('[name="display-name"]', 'Test User');
  await page.click('[data-testid="create-passkey-btn"]');

  // Verify success UI
  await expect(page.locator('[data-testid="passkey-created"]')).toBeVisible({
    timeout: 10000,
  });

  // Verify credential is stored in virtual authenticator
  const { credentials } = await client.send('WebAuthn.getCredentials', {
    authenticatorId,
  });
  expect(credentials).toHaveLength(1);
  expect(credentials[0].isResidentCredential).toBe(true);
});

Testing Registration Exclusion

WebAuthn allows servers to exclude existing credentials to prevent duplicate registrations:

test('registration excludes existing passkeys', async ({ page }) => {
  const { client, authenticatorId } = await createPasskeyAuthenticator(page);

  // Register first passkey
  await page.goto('/register');
  await page.fill('[name="email"]', 'test@example.com');
  await page.click('[data-testid="create-passkey-btn"]');
  await expect(page.locator('[data-testid="passkey-created"]')).toBeVisible();

  // Try to add another passkey for same account
  await page.goto('/settings/security');
  await page.click('[data-testid="add-another-passkey"]');

  // Should either create a new one or warn about existing credential
  const result = await Promise.race([
    page.locator('[data-testid="passkey-added"]').waitFor({ timeout: 5000 }),
    page.locator('[data-testid="credential-excluded-error"]').waitFor({ timeout: 5000 }),
  ]);

  // Either outcome is acceptable; test documents what happens
  expect(result).toBeTruthy();
});

Testing Conditional UI (Autofill Integration)

Passkeys can surface in the browser's autofill dropdown on login forms. This requires mediation: conditional in the WebAuthn call:

test('passkey appears in autofill suggestions', async ({ page }) => {
  const { client } = await createPasskeyAuthenticator(page);

  // First register a passkey
  await registerTestPasskey(page, 'user@example.com');

  // Navigate to login page
  await page.goto('/login');
  
  // Focus the email field to trigger autofill
  await page.click('[name="email"]');
  
  // In production, passkey would appear in autofill dropdown
  // In tests, we verify the page has set up conditional mediation
  const hasConditionalMediation = await page.evaluate(() => {
    return window.__webauthnConditionalMediationActive === true;
  });

  expect(hasConditionalMediation).toBe(true);
});

Note: True conditional UI rendering requires OS-level autofill that can't be fully automated. Test that the API call is configured correctly instead.

Testing the Authentication Flow

test('complete passkey authentication flow', async ({ page }) => {
  const { client } = await createPasskeyAuthenticator(page);

  // Setup: register passkey
  await registerTestPasskey(page, 'user@example.com');
  await page.click('[data-testid="logout"]');

  // Authenticate with passkey
  await page.goto('/login');
  await page.click('[data-testid="use-passkey-btn"]');

  // Virtual authenticator auto-presents the discoverable credential
  await expect(page.locator('[data-testid="dashboard"]')).toBeVisible({
    timeout: 10000,
  });

  // Verify session established correctly
  const sessionUser = await page.evaluate(() => window.__currentUser);
  expect(sessionUser.email).toBe('user@example.com');
});

Testing Cross-Device Passkey Scenarios

True cross-device passkey sync can't be automated, but you can test the QR code / hybrid flow initiation:

test('hybrid transport option is presented on incompatible device', async ({ page }) => {
  // Simulate a device with no passkeys (e.g., signing in on new browser)
  const client = await page.context().newCDPSession(page);
  await client.send('WebAuthn.enable', { enableUI: false });
  await client.send('WebAuthn.addVirtualAuthenticator', {
    options: {
      protocol: 'ctap2',
      transport: 'internal',
      hasResidentKey: true,
      hasUserVerification: true,
      isUserVerified: true,
      // No credentials registered on this device
    },
  });

  await page.goto('/login');
  await page.click('[data-testid="use-passkey-btn"]');

  // Should offer cross-device option
  await expect(page.locator('[data-testid="use-phone-passkey"]')).toBeVisible();
  // Or show the browser's native "Use a phone or tablet" prompt
});

Testing Passkey Management UI

Users need to manage their passkeys — name them, delete them, see when they were last used.

test('passkey management operations', async ({ page }) => {
  const { client } = await createPasskeyAuthenticator(page);

  await registerTestPasskey(page, 'user@example.com');
  await page.goto('/settings/security/passkeys');

  // Verify passkey is listed
  await expect(page.locator('[data-testid="passkey-item"]')).toBeVisible();

  // Rename passkey
  await page.click('[data-testid="passkey-rename-btn"]');
  await page.fill('[data-testid="passkey-name-input"]', 'My MacBook');
  await page.click('[data-testid="save-name-btn"]');
  await expect(page.locator('[data-testid="passkey-item"]')).toContainText('My MacBook');

  // Delete passkey
  await page.click('[data-testid="passkey-delete-btn"]');
  await page.click('[data-testid="confirm-delete-btn"]');
  await expect(page.locator('[data-testid="passkey-item"]')).not.toBeVisible();

  // Verify credential removed from virtual authenticator
  const { credentials } = await client.send('WebAuthn.getCredentials', {
    authenticatorId: client.authenticatorId,
  });
  expect(credentials).toHaveLength(0);
});

Testing Fallback Flows

Always test what happens when passkeys aren't available:

test('graceful fallback when WebAuthn not supported', async ({ page }) => {
  // Override WebAuthn API to simulate unsupported browser
  await page.addInitScript(() => {
    Object.defineProperty(navigator, 'credentials', {
      value: undefined,
      writable: true,
    });
    delete window.PublicKeyCredential;
  });

  await page.goto('/login');

  // Passkey option should not be shown
  await expect(page.locator('[data-testid="use-passkey-btn"]')).not.toBeVisible();

  // Password login should be available
  await expect(page.locator('[name="password"]')).toBeVisible();
});

test('fallback when passkey ceremony fails', async ({ page }) => {
  const { client } = await createPasskeyAuthenticator(page, {
    isUserVerified: false, // Simulate biometric failure
  });

  await page.goto('/login');
  await page.click('[data-testid="use-passkey-btn"]');

  // Should offer alternative login method, not leave user stuck
  await expect(page.locator('[data-testid="use-password-instead"]')).toBeVisible({
    timeout: 10000,
  });
});

Server-Side Passkey Validation Tests

const { verifyAuthenticationResponse } = require('@simplewebauthn/server');

describe('Passkey server validation', () => {
  it('validates passkey assertion with correct counter', async () => {
    const verification = await verifyAuthenticationResponse({
      response: mockAssertionResponse,
      expectedChallenge: testChallenge,
      expectedOrigin: 'https://example.com',
      expectedRPID: 'example.com',
      authenticator: {
        credentialPublicKey: storedPublicKey,
        credentialID: storedCredentialID,
        counter: 0,
      },
    });

    expect(verification.verified).toBe(true);
    expect(verification.authenticationInfo.newCounter).toBeGreaterThan(0);
  });

  it('rejects assertion when user verification flag not set', async () => {
    // Passkeys require UV=true in flags
    const responseWithoutUV = { ...mockAssertionResponse };
    responseWithoutUV.response.authenticatorData = buildAuthData({ uv: false });

    await expect(
      verifyAuthenticationResponse({
        ...verifyOptions,
        response: responseWithoutUV,
        requireUserVerification: true,
      })
    ).rejects.toThrow(/user verification/i);
  });
});

Monitoring Passkey Flows with HelpMeTest

Passkey authentication is critical path — a break means users can't log in. HelpMeTest can monitor this flow every 5 minutes:

As new test user
Register a passkey
Log out
Authenticate with passkey
Verify dashboard loads
Verify user session is correct

If the passkey flow breaks — due to a server config change, certificate rotation, or origin mismatch — you'll know within 5 minutes, not hours.

Summary

Testing passkey implementations requires: virtual authenticators configured with resident keys and UV enabled, registration tests verifying discoverable credential creation, authentication tests covering the full ceremony, management UI tests for rename/delete operations, fallback tests for WebAuthn-unavailable scenarios, server-side tests for assertion validation and UV flag requirements, and continuous monitoring of the authentication flow in production. The most commonly missed test is the fallback — always ensure users can still authenticate when passkeys fail.

Read more